ms-ai-architect/scripts/kb-update/lib/transform.mjs
Kjell Tore Guttormsen de0d94cbc1 feat(ms-ai-architect): R22 decision-b Enhet 3 — Status-backfill 25 none + ai-act dual-header-dedup 4 (Missing Status/Last-updated 29+4→0) [skip-docs]
To operasjoner, én økt (⊥ R7), begge ren metadata-normalisering (verdi aldri fabrikkert).

Premiss-korreksjon (ground truth 2026-07-07): roadmap sa «27 none + 4 ai-act».
Målt: 29 mangler bold **Status:** = 25 rene none + 4 ai-act (plain Status: GA).
De «27» inkluderte 2 for mye — 2 filer (custom-dashboards-ai-operations,
zero-trust-ai-services) har bold **Status:** KUN forbi byte 500 (present for
full-fil-audit, usynlig for 500B header-parser) → egen header-slanking-residual
(§8-register), utenfor Enhet 3.

Op A — Status-backfill 25 rene none: utvidet backfill-status.mjs MANIFEST 14→39
(samme statusForFile + insertMetaField + hard per-fil-invariant, idempotent skip
på de 14 R21-gjorte). Alle 25 → **Status:** Established Practice (ingen matcher
template|matrix|benchmarks|register). Diff +25/-0.

Op B — ai-act dual-header-dedup (4 filer): ny driver dedup-plain-header.mjs + 2
rene primitiver i transform.mjs — boldifyPlainField (plain→bold, verdi bevart
byte-eksakt, header-scoped, idempotent) + dropRedundantPlainField (sletter plain
KUN når bold m/ identisk verdi beviser redundans; kaster ved avvik/manglende bold).
Per fil: plain Last updated: + Status: GA → bold (2026-06-18/2026-02, GA bevart),
redundant plain Category: fjernet. Hard per-fil-invariant (net -1 linje, begge
felt bold m/ bevart verdi, ingen plain-header igjen, body byte-identisk). Diff -12/+8.

Verifisering: test-backfill-status 8/8 + test-dedup-plain-header 13/13; audit
Missing Status 29→0, Missing English Last updated 4→0; skills-diff 29 filer
+33/-12 (kun **Status:** + 8 bold-swaps), diff-kontekst inspisert per fil; begge
drivere idempotent (re-run 0 writes); suite 806/806 exit 0; none=8 uendret (Enhet 4).
2026-07-07 07:45:27 +02:00

720 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// transform.mjs — Lag 4: TRANSFORMASJON (doc → KB-fil). Pure helper library that
// turns an approved, fetched Microsoft Learn document into a KB reference file in
// the plugin's house format. It is the deterministic spine the LLM destillation
// (commands/kb-update.md, transform-prompt.md) hangs on:
//
// buildKbHeader — the mandatory header block (Status + Source obligatory)
// validateKbFile — enforce the mandatory-field contract on any candidate file
// buildChange — construct the lag-5 `change` object (the lag-4 → lag-5 seam)
// resolveTargetPath — route the file to its owning skill via the taxonomy (lag 0)
//
// ARCHITECTURE INVARIANT (spec §83): this lib NEVER writes. It returns content,
// verdicts and paths; applying a file is the operator gate's job, after lag 5
// (verify-out) has cleared it. Mirrors decisions-io / verify-out: pure functions,
// no write-util imports. The only dependency is kb-headers (read-only parser) so
// validateKbFile uses the SAME source detection as build-registry — one source of
// truth for "what counts as a Source header" (the top-500-byte region).
import {
parseSourceHeader,
parseTypeHeader,
parseVerifiedHeader,
parseVerifiedByHeader,
} from './kb-headers.mjs';
import { getCategorySkill } from './taxonomy.mjs';
// Base mandatory header fields for EVERY file type. Status is obligatory per spec
// ("status-felt obligatorisk").
const REQUIRED_BASE_META = ['title', 'status', 'category', 'lastUpdated'];
// Spor 3 Port 1 — the frontmatter-contract `type`. Only `reference` files are in the
// correctness scope (they carry a Microsoft source we verify against). Template /
// methodology / regulatory are out of scope and carry NO Microsoft source.
const VALID_TYPES = ['reference', 'template', 'methodology', 'regulatory'];
const DEFAULT_TYPE = 'reference';
// Extra fields a `reference` file MUST carry to be "born verified" (Spor 3 Port 2):
// `source` lets lag-5 authority checks fire; `verified`/`verified_by` record that the
// content cleared the judge before it shipped. A reference file without these is the
// drift-introducing failure mode the create-guard exists to stop.
const REQUIRED_REFERENCE_META = ['source', 'verified', 'verified_by'];
// The current GATE-PASS claim judge (judge-claim-prompt-v3.1, adopted G1 2026-06-30).
// A version *label string* — integer major + optional minor — so provenance names the
// adopted judge exactly (v3 was a distinct, rejected version). stampVerifiedMeta records
// `verified_by: judge-v<JUDGE_VERSION>` unless the caller overrides or marks `human`.
const JUDGE_VERSION = '3.1';
// A Microsoft Learn / docs authority URL — a non-reference file may NOT carry one.
const MS_LEARN_RE = /\b(?:learn|docs)\.microsoft\.com/i;
const DATE_RE = /^\d{4}-\d{2}(?:-\d{2})?$/;
// The top-of-file region the kb-headers parsers scan. MIRRORS kb-headers.mjs:6
// (HEADER_REGION_BYTES) — kept local rather than imported so this lib's only
// kb-headers dependency stays the read-only parser functions (same discipline as
// TOC_MIN_LINES mirroring eval.mjs). insertHeaderFields / normalizeStaleVerified use it
// to keep the fields they insert — and the stale Verified they strip — inside the window
// those parsers actually read.
const HEADER_REGION_BYTES = 500;
// A reference file longer than this many lines should carry a table of contents
// (best-practices §Structure). MIRRORS eval.mjs N4_LARGE_FILE_LINES — kept in sync
// behaviourally by test-transform's checkN4 parity assertion, NOT by importing across
// the kb-update ↔ kb-eval boundary (transform.mjs's only dependency stays kb-headers).
const TOC_MIN_LINES = 100;
/** Line count counted exactly as checkN4 does (trailing blank lines ignored). */
function lineCount(content) {
return String(content ?? '').replace(/\n+$/, '').split('\n').length;
}
/** Is this file large enough to require a TOC? (mirror of checkN4's "large" filter) */
function isLargeContent(content) {
return lineCount(content) > TOC_MIN_LINES;
}
/**
* Does the content already carry a TOC? Mirror of eval.mjs hasToc (internal there):
* an explicit TOC heading, OR a cluster (≥3) of in-page anchor links near the top.
*/
function hasToc(content) {
const s = String(content ?? '');
if (/^#{1,3}\s*(table of contents|contents|innhold(?:sfortegnelse)?|toc)\b/im.test(s)) return true;
const head = s.split('\n').slice(0, 40).join('\n');
return (head.match(/\]\(#/g) || []).length >= 3;
}
/** GitHub-style heading slug: lowercase, drop punctuation (keep letters/digits/_/-),
* spaces → hyphens. Unicode-aware so Norwegian æ/ø/å survive in anchors. */
function slugify(heading) {
return String(heading ?? '')
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
.trim()
.replace(/\s/g, '-');
}
/**
* Build the deterministic KB-file header block. Status, Source and the Port 1 contract
* fields (Type / Verified / Verified by) live IN the header (top 500 bytes) so
* kb-headers.* and the "Last updated:" scanner can see them — the old bottom-of-file
* source section was invisible to those scanners, which is why authority_source
* coverage was 0%.
*
* Type-aware create-guard (Spor 3 Port 2): a `reference` file MUST carry
* source + verified + verified_by (it is "born verified"); a non-reference file
* (template/methodology/regulatory) is out of correctness scope and may NOT carry a
* Microsoft Learn source. Either violation is a hard throw — the create-time gate.
*
* @param {{title: string, status: string, category: string, lastUpdated: string,
* type?: string, source?: string, verified?: string, verified_by?: string}} meta
* @returns {string} the header block, ending with the `---` rule + trailing newline
* @throws if the type is unknown, a mandatory field is missing, or a non-reference
* file carries a Microsoft Learn source
*/
export function buildKbHeader(meta) {
const m = meta ?? {};
const type = m.type ?? DEFAULT_TYPE;
if (!VALID_TYPES.includes(type)) {
throw new Error(`buildKbHeader: invalid type '${type}' (expected ${VALID_TYPES.join('|')})`);
}
const isReference = type === 'reference';
const empty = (k) => {
const v = m[k];
return v === undefined || v === null || String(v).trim() === '';
};
const required = isReference
? [...REQUIRED_BASE_META, ...REQUIRED_REFERENCE_META]
: REQUIRED_BASE_META;
const missing = required.filter(empty);
if (missing.length) {
throw new Error(`buildKbHeader: missing mandatory field(s): ${missing.join(', ')}`);
}
if (!isReference && !empty('source') && MS_LEARN_RE.test(String(m.source))) {
throw new Error(
`buildKbHeader: a ${type} file may not carry a Microsoft Learn source (out of correctness scope)`,
);
}
let header =
`# ${m.title}\n\n` +
`**Last updated:** ${m.lastUpdated}\n` +
`**Status:** ${m.status}\n` +
`**Category:** ${m.category}\n` +
`**Type:** ${type}\n`;
if (!empty('source')) header += `**Source:** ${m.source}\n`;
if (!empty('verified')) header += `**Verified:** ${m.verified}\n`;
if (!empty('verified_by')) header += `**Verified by:** ${m.verified_by}\n`;
return `${header}\n---\n`;
}
/**
* Format the `verified_by` judge-version label. Returns the WHOLE label (`judge-v3.1`).
* Absence (undefined) → the current JUDGE_VERSION default; otherwise the value — an integer
* major (`3` → judge-v3) or a `major.minor` string/float (`'3.2'`/`3.2` → judge-v3.2) — is
* validated against a strict numeric-label regex and honoured. Mirrors the DATE_RE throw
* discipline (stampVerifiedMeta): a malformed label THROWS rather than silently falling back
* to the default, so a caller bug surfaces instead of shipping a mislabelled provenance stamp
* into a public file.
*
* @param {number|string} [judgeVersion] — integer major or `major.minor`; absent → default
* @returns {string} the `judge-v<label>` provenance label
* @throws if judgeVersion is present but not a `\d+(\.\d+)?` numeric label
*/
function formatJudgeVersion(judgeVersion) {
if (judgeVersion === undefined) return `judge-v${JUDGE_VERSION}`;
const label = String(judgeVersion);
if (!/^\d+(?:\.\d+)?$/.test(label)) {
throw new Error(
`formatJudgeVersion: malformed judge version '${judgeVersion}' (expected integer or major.minor)`,
);
}
return `judge-v${label}`;
}
/**
* Born-verified gate (Spor 3 Port 2): stamp `verified`/`verified_by` onto a reference
* meta IFF the judge verdict passed — otherwise REFUSE (throw). This encodes
* "nytt/regenerert innhold kjøres gjennom judgen FØR commit → født verifisert; ellers
* ingen fil". transform.mjs never calls the judge itself (pure lib); the command runs
* the v3.1 claim judge, aggregates per-claim results into `{pass}`, and calls this. A
* failing verdict means no file is born.
*
* @param {object} baseMeta — the reference meta (title/status/category/source/lastUpdated)
* @param {{pass: boolean, judgeVersion?: number|string, by?: 'human'}} verdict
* @param {string} today — YYYY-MM or YYYY-MM-DD, supplied by the caller (lib stays pure)
* @returns {object} baseMeta with type='reference' + verified + verified_by set
* @throws if the verdict is not passing, or today is not a valid date
*/
export function stampVerifiedMeta(baseMeta, verdict, today) {
if (!verdict || verdict.pass !== true) {
throw new Error('stampVerifiedMeta: refusing to stamp verified — judge verdict not passing');
}
if (!DATE_RE.test(String(today ?? ''))) {
throw new Error(`stampVerifiedMeta: invalid today date '${today}' (expected YYYY-MM[-DD])`);
}
// Default stamp = the current JUDGE_VERSION label ('3.1'). An explicit override — integer
// (judgeVersion:3 → judge-v3) OR minor-bearing string/float (judgeVersion:'3.2' → judge-v3.2)
// — is honoured via formatJudgeVersion, which THROWS on a malformed label rather than silently
// falling back (§8 follow-up closed: the string override is no longer dropped).
const verifiedBy =
verdict.by === 'human' ? 'human' : formatJudgeVersion(verdict.judgeVersion);
return { ...baseMeta, type: 'reference', verified: today, verified_by: verifiedBy };
}
/**
* Build a deterministic `## Innhold` table of contents from a body's level-2 (`##`)
* section headings — the plugin house style (matches the existing TOC'd files and the
* heading eval.mjs hasToc recognises). The LLM should NOT hand-author anchor slugs;
* this generates GitHub-compatible ones. Headings inside ``` code fences are ignored,
* and the `## Innhold` heading never lists itself (so re-running is idempotent).
*
* @param {string} body — the file body (under the header `---`)
* @returns {string} a `## Innhold` block ending in a newline, or '' if there are no
* `##` sections to list
*/
export function buildToc(body) {
const lines = String(body ?? '').split('\n');
const titles = [];
let inFence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) { inFence = !inFence; continue; }
if (inFence) continue;
const m = /^##\s+(.+?)\s*$/.exec(line); // level-2 only (not # title, not ### sub)
if (!m) continue;
const title = m[1].trim();
if (/^innhold(?:sfortegnelse)?$/i.test(title)) continue; // never list the TOC itself
titles.push(title);
}
if (titles.length === 0) return '';
const items = titles.map((t) => `- [${t}](#${slugify(t)})`).join('\n');
return `## Innhold\n\n${items}\n`;
}
/**
* Assemble a complete KB file from header meta + body: the deterministic generator the
* write-path uses (replaces the bare `buildKbHeader(meta) + body` concatenation). A
* large file (>100 lines, per N4) is born with a `## Innhold` TOC inserted after the
* header so regeneration never strips the TOC back out (the Fase 1c regression guard).
* Idempotent: a body that already carries a TOC is not double-TOC'd.
*
* @param {object} meta — same shape as buildKbHeader
* @param {string} body — the distilled body (under the header `---`)
* @returns {string} the full file content
*/
export function composeKbFile(meta, body) {
const header = buildKbHeader(meta);
const b = String(body ?? '');
const full = header + b;
if (!isLargeContent(full) || hasToc(full)) return full;
const toc = buildToc(b);
if (!toc) return full;
return `${header}\n${toc}\n${b}`;
}
/**
* Backfill a `## Innhold` TOC into an EXISTING large reference file that lacks one,
* placing it immediately before the first non-fenced `## ` section heading — the same
* relative slot composeKbFile gives a freshly-generated file (TOC between the header
* preamble and the first content section), but robust to the varied legacy headers on
* disk: some carry a `---` rule after the metadata, some don't. This is the Fase 1b
* retrofit for the ~20 largest hand-written files; composeKbFile covers NEW files.
*
* No-op (returns content unchanged) when the file is small (≤ TOC_MIN_LINES) or already
* carries a TOC — so it is idempotent and never churns a small file. The body below the
* insertion point is left byte-identical: only the `## Innhold` block is added.
*
* @param {string} content — full existing file content
* @returns {string} content with a `## Innhold` block inserted, or unchanged
*/
export function insertToc(content) {
const s = String(content ?? '');
if (!isLargeContent(s) || hasToc(s)) return s;
const toc = buildToc(s);
if (!toc) return s; // no ## sections to list — nothing to anchor a TOC to
const lines = s.split('\n');
let inFence = false;
let idx = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*```/.test(lines[i])) { inFence = !inFence; continue; }
if (inFence) continue;
if (/^##\s+/.test(lines[i])) { idx = i; break; }
}
if (idx === -1) return s; // defensive: buildToc found a section we didn't — leave as-is
const before = lines.slice(0, idx);
while (before.length && before[before.length - 1].trim() === '') before.pop();
const block = ['', ...toc.replace(/\n$/, '').split('\n'), ''];
return [...before, ...block, ...lines.slice(idx)].join('\n');
}
// A bold-label meta line, e.g. **Category:** / **Kategori:** / **Status:** /
// **Sist oppdatert:** / **Confidence:** — and a pipe-delimited meta row, which also
// begins with a bold label. The non-greedy label body excludes `*` so it stops at the
// first `:**`. Language-agnostic by construction: it keys on the bold-label shape, not
// on a fixed label vocabulary.
const RE_META_LINE = /^\s*\*\*[^*\n]+?:\*\*/;
/**
* Backfill the Port-1 `**Type:**` (and, for a reference, `**Source:**`) header field(s)
* into an EXISTING legacy KB file that lacks them — the Spor 1 migration primitive for
* the ~327 non-advisor files. Unlike buildKbHeader (which emits a COMPLETE new header),
* this inserts ONLY the missing field(s), placing them immediately after the last line of
* the first contiguous run of bold-label meta lines (RE_META_LINE — covers the
* **Category:** / **Kategori:** / pipe dialects). It never inserts before the first `## `
* section or past the 500-byte scan window, so the fields land where kb-headers.* read
* them; the body is byte-identical.
*
* Per-field idempotent: skips `**Type:**` when parseTypeHeader is already set, skips
* `**Source:**` when parseSourceHeader is already set. A non-reference type NEVER receives
* a `**Source:**` (mirrors buildKbHeader's out-of-correctness-scope rule) — Type only.
*
* @param {string} content — full existing file content
* @param {{type?: string, source?: string}} meta — resolved type + (reference) authority URL
* @returns {string} content with the missing header field(s) inserted, or unchanged
* @throws if the type is not one of VALID_TYPES
*/
export function insertHeaderFields(content, meta) {
const s = String(content ?? '');
const m = meta ?? {};
const type = m.type ?? DEFAULT_TYPE;
if (!VALID_TYPES.includes(type)) {
throw new Error(`insertHeaderFields: invalid type '${type}' (expected ${VALID_TYPES.join('|')})`);
}
const isReference = type === 'reference';
const src = m.source;
const hasSource = src !== undefined && src !== null && String(src).trim() !== '';
const needType = parseTypeHeader(s) === null;
const needSource = isReference && hasSource && parseSourceHeader(s) === null;
if (!needType && !needSource) return s;
const toInsert = [];
if (needType) toInsert.push(`**Type:** ${type}`);
if (needSource) toInsert.push(`**Source:** ${String(src).trim()}`);
const lines = s.split('\n');
let anchorIdx = -1; // last line of the first contiguous meta run that fits the window
let titleIdx = -1; // fallback anchor for the "no meta line" dialect
let cum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^##\s/.test(line) || /^---\s*$/.test(line)) break; // reached body / rule
if (titleIdx === -1 && /^#\s+\S/.test(line)) titleIdx = i;
const nextCum = cum + line.length + 1; // byte offset just AFTER this line (= the insertion point)
if (RE_META_LINE.test(line)) {
// Anchor after this meta line ONLY if the insertion point still fits the scan window.
// A meta line whose own length pushes the offset past 500B (a corpus file that packs a
// paragraph into **Status:**) must NOT become the anchor — otherwise the inserted field
// lands past the window and parseTypeHeader/parseSourceHeader miss it. Back off here.
if (nextCum > HEADER_REGION_BYTES) break;
anchorIdx = i; // extend the contiguous meta run
} else if (anchorIdx !== -1) {
break; // first non-meta line after the run — the run is over (contiguous only)
}
cum = nextCum;
}
// "None" dialect (no bold-label meta): anchor on the title line so the fields still
// land in the header region, before the first section. (-1 → very top if no title.)
if (anchorIdx === -1) anchorIdx = titleIdx;
lines.splice(anchorIdx + 1, 0, ...toInsert);
return lines.join('\n');
}
/**
* Surgically insert ONE bold-label meta line — `**<label>:** <value>` — into an EXISTING KB
* file that lacks it: the R20/R21 base-field backfill primitive (**Category:** in R20;
* **Status:** / **Last updated:** in R21). Reuses insertHeaderFields' anchor + 500-byte
* back-off discipline — the line lands immediately after the last line of the first
* contiguous bold-label meta run that still leaves room in the window, with a title fallback
* for the "None" dialect (no meta lines). Idempotent on the EXACT label: a file already
* carrying `**<label>:**` anywhere in the header region is returned unchanged. The body (from
* the first `## ` section) is byte-identical.
*
* Unlike insertHeaderFields (Port-1 Type/Source, with type validation) this is field-agnostic
* — it keys on the label shape only, so it never re-derives a value or touches other lines.
*
* @param {string} content — full existing file content
* @param {string} label — the bold label WITHOUT the `**`/`:` decoration, e.g. 'Category'
* @param {string} value — the field value
* @returns {string} content with the meta line inserted, or unchanged if the label is present
* @throws if label or value is blank
*/
export function insertMetaField(content, label, value) {
const s = String(content ?? '');
const lab = String(label ?? '').trim();
const val = String(value ?? '').trim();
if (lab === '') throw new Error('insertMetaField: label is required');
if (val === '') throw new Error('insertMetaField: value is required');
const lines = s.split('\n');
const esc = lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const labelRe = new RegExp('^\\s*\\*\\*' + esc + ':\\*\\*', 'i');
// Idempotent: if the exact label already appears in the header region, return unchanged.
for (const line of lines) {
if (/^##\s/.test(line) || /^---\s*$/.test(line)) break; // reached body / rule
if (labelRe.test(line)) return s;
}
// Anchor: last line of the first contiguous meta run whose insertion point still fits the
// 500-byte window; title fallback for the "None" dialect (mirrors insertHeaderFields).
let anchorIdx = -1;
let titleIdx = -1;
let cum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^##\s/.test(line) || /^---\s*$/.test(line)) break; // reached body / rule
if (titleIdx === -1 && /^#\s+\S/.test(line)) titleIdx = i;
const nextCum = cum + line.length + 1; // byte offset just AFTER this line
if (RE_META_LINE.test(line)) {
if (nextCum > HEADER_REGION_BYTES) break; // back off — insertion point past the window
anchorIdx = i;
} else if (anchorIdx !== -1) {
break; // first non-meta line after the run — the contiguous run is over
}
cum = nextCum;
}
const at = (anchorIdx !== -1 ? anchorIdx : titleIdx) + 1; // -1 + 1 = 0 → very top if no title
lines.splice(at, 0, `**${lab}:** ${val}`);
return lines.join('\n');
}
// Header region ends at the first horizontal rule or `## ` section — mirrors the body
// boundary the anchor loops above use. A plain-text meta line below this is genuine prose.
function headerEndIndex(lines) {
for (let i = 0; i < lines.length; i++) {
if (/^##\s/.test(lines[i]) || /^---\s*$/.test(lines[i])) return i;
}
return lines.length;
}
/**
* Normalize a PLAIN-TEXT header field — `<label>: <value>` — to the house bold form
* `**<label>:** <value>`, PRESERVING the authored value byte-exact (Enhet 3 Op B dedup
* primitive for the dual-header ai-act-* files). Unlike insertMetaField this never derives a
* value: it only re-decorates a line the author already wrote. Acts on the FIRST plain match
* in the header region only (a `Status:` in body prose is untouched). Idempotent: if a bold
* `**<label>:**` already sits in the header the content is returned unchanged; likewise if no
* plain line exists. Body (from the first `## `/`---`) is byte-identical.
*
* @param {string} content — full existing file content
* @param {string} label — the label WITHOUT decoration, e.g. 'Last updated'
* @returns {string} content with the plain line re-decorated, or unchanged
* @throws if label is blank
*/
export function boldifyPlainField(content, label) {
const s = String(content ?? '');
const lab = String(label ?? '').trim();
if (lab === '') throw new Error('boldifyPlainField: label is required');
const esc = lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boldRe = new RegExp('^\\s*\\*\\*' + esc + ':\\*\\*', 'i');
const plainRe = new RegExp('^\\s*' + esc + ':\\s*(\\S.*?)\\s*$', 'i');
const lines = s.split('\n');
const end = headerEndIndex(lines);
let plainIdx = -1;
for (let i = 0; i < end; i++) {
if (boldRe.test(lines[i])) return s; // idempotent: already bold → unchanged
if (plainIdx === -1 && plainRe.test(lines[i])) plainIdx = i;
}
if (plainIdx === -1) return s; // no plain line → unchanged
const val = plainRe.exec(lines[plainIdx])[1];
lines[plainIdx] = `**${lab}:** ${val}`;
return lines.join('\n');
}
/**
* Remove a PLAIN-TEXT header field — `<label>: <value>` — that is provably redundant with an
* existing bold `**<label>:** <value>` carrying the IDENTICAL value (Enhet 3 Op B dedup
* primitive). Refuses to guess: if no bold field exists, or the bold and plain values differ,
* it THROWS (that is a human decision, never an auto-delete). No-op if the plain line is absent
* (idempotent). Operates in the header region only; body is byte-identical.
*
* @param {string} content — full existing file content
* @param {string} label — the label WITHOUT decoration, e.g. 'Category'
* @returns {string} content with the redundant plain line removed, or unchanged
* @throws if label is blank, no bold field proves redundancy, or the values differ
*/
export function dropRedundantPlainField(content, label) {
const s = String(content ?? '');
const lab = String(label ?? '').trim();
if (lab === '') throw new Error('dropRedundantPlainField: label is required');
const esc = lab.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const boldRe = new RegExp('^\\s*\\*\\*' + esc + ':\\*\\*\\s*(\\S.*?)\\s*$', 'i');
const plainRe = new RegExp('^\\s*' + esc + ':\\s*(\\S.*?)\\s*$', 'i');
const lines = s.split('\n');
const end = headerEndIndex(lines);
let boldVal = null;
let plainIdx = -1;
let plainVal = null;
for (let i = 0; i < end; i++) {
const bm = boldRe.exec(lines[i]);
if (bm && boldVal === null) boldVal = bm[1];
const pm = plainRe.exec(lines[i]);
if (pm && plainIdx === -1) { plainIdx = i; plainVal = pm[1]; }
}
if (plainIdx === -1) return s; // nothing to drop (idempotent)
if (boldVal === null) throw new Error(`dropRedundantPlainField: no bold **${lab}:** to prove redundancy for plain '${plainVal}'`);
if (boldVal !== plainVal) throw new Error(`dropRedundantPlainField: plain '${plainVal}' ≠ bold '${boldVal}' — not redundant, human decision`);
lines.splice(plainIdx, 1);
return lines.join('\n');
}
/**
* Surgically insert-or-update ONLY the `**Verified:**` and `**Verified by:**` header lines
* on an EXISTING KB file — the R7R10 born-verified STAMP primitive. Unlike composeKbFile
* (which rebuilds the whole header and may insert a `## Innhold` → body diff), this touches
* nothing but the two stamp lines: the body is byte-identical, no TOC is ever added, and a
* large file's structure is otherwise unchanged. It is the mirror image of
* normalizeStaleVerified — that strips a stale stamp; this writes a fresh, judge-cleared one.
*
* Insert vs update: a stamp line already inside the 500-byte scan window has ONLY its value
* token replaced in place (so re-stamping the same value is a byte-identical no-op —
* idempotent, and a pipe row's sibling tokens survive); an absent stamp line is inserted
* immediately after the last line of the first contiguous bold-label meta run that still
* leaves room in the window (the same anchor + back-off discipline as insertHeaderFields),
* ordered `**Verified:**` then `**Verified by:**` to match buildKbHeader.
*
* THROWS if either stamp would land past the 500-byte window — a header so full the stamp
* would be invisible to parseVerified*Header. A silent, unparseable stamp is worse than none
* (the file would read as unverified while carrying a verified line); R7 treats this throw as
* "flagged: header-slanking kreves", never a forced stamp.
*
* @param {string} content — full existing file content
* @param {{verified: string, verified_by: string}} fields — both required
* @returns {string} content with the two stamp lines inserted/updated, body byte-identical
* @throws if verified/verified_by is blank, or a stamp lands past the 500-byte window
*/
export function insertVerifiedFields(content, fields) {
const s = String(content ?? '');
const f = fields ?? {};
const blank = (v) => v === undefined || v === null || String(v).trim() === '';
if (blank(f.verified) || blank(f.verified_by)) {
throw new Error('insertVerifiedFields: both verified and verified_by are required');
}
const vStr = String(f.verified).trim();
const vbStr = String(f.verified_by).trim();
const lines = s.split('\n');
let vIdx = -1; // existing **Verified:** line (update target)
let vbIdx = -1; // existing **Verified by:** line (update target)
let anchorIdx = -1; // last contiguous meta line whose insertion point still fits the window
let titleIdx = -1; // fallback anchor for the "no meta line" dialect
let cum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/^##\s/.test(line) || /^---\s*$/.test(line)) break; // reached body / rule
if (titleIdx === -1 && /^#\s+\S/.test(line)) titleIdx = i;
if (cum < HEADER_REGION_BYTES) {
// Only recognise an existing stamp the parsers can actually read (inside the window).
// `**Verified:**` (colon straight after) never matches the `**Verified by:**` line.
if (vIdx === -1 && /\*\*Verified:\*\*/i.test(line)) vIdx = i;
if (vbIdx === -1 && /\*\*Verified by:\*\*/i.test(line)) vbIdx = i;
}
const nextCum = cum + line.length + 1; // byte offset just AFTER this line
if (RE_META_LINE.test(line)) {
if (nextCum > HEADER_REGION_BYTES) break; // back off — insertion point past the window
anchorIdx = i;
} else if (anchorIdx !== -1) {
break; // first non-meta line after the run — the contiguous run is over
}
cum = nextCum;
}
// Update present stamp lines in place — replace ONLY the value token (function replacer so a
// `$` in the value is never treated as a backreference); a pipe row's siblings are preserved.
if (vIdx !== -1) lines[vIdx] = lines[vIdx].replace(/(\*\*Verified:\*\*\s*)\S+/i, (_m, p1) => p1 + vStr);
if (vbIdx !== -1) lines[vbIdx] = lines[vbIdx].replace(/(\*\*Verified by:\*\*\s*)\S+/i, (_m, p1) => p1 + vbStr);
// Insert absent stamp lines after the meta-run anchor (title fallback for the no-meta
// dialect; -1 + 1 = 0 → very top if there is no title either), Verified then Verified by.
const toInsert = [];
if (vIdx === -1) toInsert.push(`**Verified:** ${vStr}`);
if (vbIdx === -1) toInsert.push(`**Verified by:** ${vbStr}`);
if (toInsert.length) {
lines.splice((anchorIdx !== -1 ? anchorIdx : titleIdx) + 1, 0, ...toInsert);
}
const out = lines.join('\n');
// Post-write invariant: both stamps must be visible to the header parsers (inside 500B).
// A stamp that landed past the window is a silent lie — refuse it (header-slanking needed).
if (parseVerifiedHeader(out) !== vStr || parseVerifiedByHeader(out) !== vbStr) {
throw new Error(
'insertVerifiedFields: stamp would land past the 500-byte header window (header-slanking required)',
);
}
return out;
}
/**
* Surgically strip STALE `**Verified:**` value(s) from a legacy KB file — the bounded
* Spor 1 carve-out for the 14 files carrying `**Verified:** MCP <date>`. That non-date
* value is read by parseVerifiedHeader as a bogus "verified" stamp, which drops the file
* out of the full-pass worklist AND marks it falsely verified; stripping it is the OPPOSITE
* of stamping verified. Acts on EVERY stale `**Verified:**` inside the 500-byte scan window
* (the region parseVerifiedHeader actually reads) whose value is not a clean YYYY-MM(-DD)
* date — including a stray duplicate sitting just below the first `---` rule, a corpus
* artifact (9 mlops-genaiops files) that still poisons the read once the header copy is
* gone. A clean date at the first scanned position stops the pass (a legitimately verified
* file is left alone), and anything PAST the 500-byte window is genuine body content and is
* never touched. On a pipe-delimited meta row it removes only the `**Verified:** <value>`
* token plus one adjacent ` | ` separator, preserving the sibling tokens
* (**Sist oppdatert:** etc.); on a standalone line it drops the whole line. Idempotent
* (each pass removes one match; a re-run finds nothing to strip).
*
* @param {string} content — full existing file content
* @returns {string} content with the stale in-window Verified value(s) removed, or unchanged
*/
export function normalizeStaleVerified(content) {
let s = String(content ?? '');
// Remove every stale (non-date) **Verified:** inside the scan window. Loop because a file
// can carry two (header copy + stray body-dup below ---); each pass drops one, so it
// terminates. A clean date at the first position ends the pass — parseVerifiedHeader reads
// that first occurrence, so a dated first Verified means the file is legitimately verified.
for (;;) {
const head = s.slice(0, HEADER_REGION_BYTES);
const m = /\*\*Verified:\*\*\s*(\S+)/i.exec(head);
if (!m) return s; // nothing stale left in the window
if (DATE_RE.test(m[1])) return s; // clean date at the read position → leave untouched
const matchStart = m.index; // index of "**Verified:**" in s
const lineStart = s.lastIndexOf('\n', matchStart - 1) + 1; // 0 if no preceding newline
let lineEnd = s.indexOf('\n', matchStart);
if (lineEnd === -1) lineEnd = s.length;
const line = s.slice(lineStart, lineEnd);
if (!line.includes(' | ')) {
// Standalone `**Verified:** …` line — drop the whole line, including its newline.
const cutEnd = lineEnd < s.length ? lineEnd + 1 : lineEnd;
s = s.slice(0, lineStart) + s.slice(cutEnd);
continue;
}
// Pipe row: remove only the `**Verified:** <value>` token + one adjacent ` | `.
const nextSepRel = s.slice(matchStart, lineEnd).indexOf(' | ');
const tokenEnd = nextSepRel === -1 ? lineEnd : matchStart + nextSepRel;
if (s.slice(matchStart - 3, matchStart) === ' | ') {
s = s.slice(0, matchStart - 3) + s.slice(tokenEnd); // drop the preceding separator
} else if (nextSepRel !== -1) {
s = s.slice(0, matchStart) + s.slice(tokenEnd + 3); // drop the following separator
} else {
s = s.slice(0, matchStart) + s.slice(tokenEnd); // degenerate — token only
}
}
}
const RE_TITLE = /^#\s+\S/m;
const RE_LAST_UPDATED = /\*\*Last updated:\*\*\s*[\d-]+/i;
const RE_STATUS = /\*\*Status:\*\*\s*\S/i;
/**
* Validate that a candidate KB file carries the mandatory contract: a title, a
* "Last updated:" header (so it is never dateless → never the 'unverified' bucket),
* a Status field, and — for a large file (>100 lines, per N4) — a table of contents.
* The TOC clause is the write-path regression gate: a regenerated large file that lost
* its TOC fails here and the caller re-prompts, so KB-update can never silently strip a
* TOC back out.
*
* Type-aware (Spor 3 Port 2): a `reference` file MUST additionally carry source +
* verified + verified_by (born-verified). A file with no `**Type:**` header is treated
* as a reference — the create path always stamps Type, and the legacy corpus (no Type)
* is migrated under Spor 1, where this same contract is the target. Non-reference files
* (template/methodology/regulatory) are out of correctness scope and need no source.
*
* @param {string} content — full file content
* @returns {{valid: boolean, missing: string[]}}
*/
export function validateKbFile(content) {
const s = String(content ?? '');
const missing = [];
if (!RE_TITLE.test(s)) missing.push('title');
if (!RE_LAST_UPDATED.test(s)) missing.push('last_updated');
if (!RE_STATUS.test(s)) missing.push('status');
const type = parseTypeHeader(s) ?? DEFAULT_TYPE;
if (type === 'reference') {
if (parseSourceHeader(s) === null) missing.push('source');
if (parseVerifiedHeader(s) === null) missing.push('verified');
if (parseVerifiedByHeader(s) === null) missing.push('verified_by');
}
if (isLargeContent(s) && !hasToc(s)) missing.push('toc');
return { valid: missing.length === 0, missing };
}
/**
* Construct the lag-5 `change` object from a transformation result — the explicit,
* documented seam between lag 4 and lag 5. Decouples lag 4's field names from
* verify-out's, and supplies the safe defaults lag 5 expects (no refutations yet;
* authority_source null until lag 3 backfills it).
*
* @param {{field: string, oldValue?: string, newValue: string, sourceUrl?: string,
* authoritySource?: string|null, refutations?: Array}} t
* @returns {{field: string, old_value: string, new_value: string,
* source_url: string|null, authority_source: string|null, refutations: Array}}
*/
export function buildChange(t) {
const o = t ?? {};
return {
field: o.field ?? '',
old_value: o.oldValue ?? '',
new_value: o.newValue ?? '',
source_url: o.sourceUrl ?? null,
authority_source: o.authoritySource ?? null,
refutations: Array.isArray(o.refutations) ? o.refutations : [],
};
}
/**
* Resolve where a transformed file belongs, routing via the taxonomy's canonical
* category → skill map (physical-disk truth — this is what replaced
* category-skill-map.json). Returns null for an unknown category so the caller
* must gate rather than silently misroute.
*
* @param {object} tax — loaded domain taxonomy
* @param {string} category
* @param {string} filename — kebab-case .md filename
* @returns {string|null} repo-relative path, or null if the category is unknown
*/
export function resolveTargetPath(tax, category, filename) {
const skill = getCategorySkill(tax, category);
if (!skill) return null;
return `skills/${skill}/references/${category}/${filename}`;
}