From eb2a002c2c152d18f4e2013f5f2759ffe3e8095b Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 19 Jun 2026 21:05:54 +0200 Subject: [PATCH] feat(ms-ai-architect): Sesjon 2 - registry-skjema (authority_source + course reservert) build-registry leser na **Source:**/**Primary source:**-header via ny lib/kb-headers.mjs (parseSourceHeader, 7 enhetstester) og logger dekning (0/N i dag). Hver URL-entry far reserverte felt authority_source:null + course:null (bakoverkompatibelt). Populering med korrekt semantikk utsatt til verifiseringslaget (lag 3) - minimal-reservert per operator-valg. Eksisterende status-felt (poll: tracked/not_in_sitemap/unpolled) urort for a unnga kollisjon. kb-update 56 tester gronne. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01REiKFhP4w6xGXXqWKpPCJJ --- scripts/kb-update/build-registry.mjs | 12 ++++++ scripts/kb-update/lib/kb-headers.mjs | 26 ++++++++++++ tests/kb-update/test-kb-headers.test.mjs | 52 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 scripts/kb-update/lib/kb-headers.mjs create mode 100644 tests/kb-update/test-kb-headers.test.mjs diff --git a/scripts/kb-update/build-registry.mjs b/scripts/kb-update/build-registry.mjs index 02dd8eb..1420cd5 100644 --- a/scripts/kb-update/build-registry.mjs +++ b/scripts/kb-update/build-registry.mjs @@ -9,6 +9,7 @@ import { join, relative, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { normalizeUrl, extractUrls } from './lib/url-normalize.mjs'; import { loadRegistry, saveRegistry } from './lib/registry-io.mjs'; +import { parseSourceHeader } from './lib/kb-headers.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = join(__dirname, '..', '..'); @@ -34,6 +35,7 @@ function walkMd(dir) { const existing = merge ? loadRegistry() : null; const urlToFiles = new Map(); // normalizedUrl → Set let totalFiles = 0; +let filesWithSourceHeader = 0; // coverage of the optional **Source:** authority header const skillDirs = readdirSync(SKILLS_DIR, { withFileTypes: true }) .filter(d => d.isDirectory()) @@ -49,6 +51,11 @@ for (const skill of skillDirs) { const urls = extractUrls(content); const relPath = relative(PLUGIN_ROOT, file); + // Read the optional **Source:**/**Primary source:** authority header. + // 0% coverage today; tracked here so we can populate authority_source as + // files adopt it (population semantics designed in the verification layer). + if (parseSourceHeader(content)) filesWithSourceHeader++; + for (const url of urls) { if (!urlToFiles.has(url)) urlToFiles.set(url, new Set()); urlToFiles.get(url).add(relPath); @@ -72,6 +79,10 @@ for (const [url, files] of urlToFiles) { sitemap_lastmod: prev?.sitemap_lastmod || null, reference_files: [...files].sort(), status: prev?.status || 'unpolled', + // Reserved schema (lag 0): authority_source = declared verification source + // (**Source:** header, 0% coverage today); course = Learn course-track ref. + authority_source: prev?.authority_source ?? null, + course: prev?.course ?? null, }; } @@ -81,6 +92,7 @@ saveRegistry(registry); const multiRef = [...urlToFiles.values()].filter(s => s.size > 1).length; console.log(`Registry built: ${urlToFiles.size} unique URLs from ${totalFiles} files`); console.log(` URLs referenced by multiple files: ${multiRef}`); +console.log(` Files declaring **Source:** header: ${filesWithSourceHeader}/${totalFiles}`); if (merge && existing?.urls) { const newUrls = [...urlToFiles.keys()].filter(u => !existing.urls[u]).length; console.log(` New URLs added (merge): ${newUrls}`); diff --git a/scripts/kb-update/lib/kb-headers.mjs b/scripts/kb-update/lib/kb-headers.mjs new file mode 100644 index 0000000..5b78777 --- /dev/null +++ b/scripts/kb-update/lib/kb-headers.mjs @@ -0,0 +1,26 @@ +// kb-headers.mjs — Parse optional authority headers from KB reference files. +// Zero dependencies. Scans only the top of the file (header region) where the +// **Source:** / **Primary source:** line lives, mirroring report-changes' +// parseLastUpdated convention. + +const HEADER_REGION_BYTES = 500; + +const SOURCE_PATTERNS = [ + /\*\*Source:\*\*\s*(\S+)/i, + /\*\*Primary source:\*\*\s*(\S+)/i, +]; + +/** + * Extract the declared authority source URL from a KB reference file's header. + * @param {string} content — full file content (only the first 500 bytes are scanned) + * @returns {string|null} the source URL, or null if no header is present + */ +export function parseSourceHeader(content) { + if (!content || typeof content !== 'string') return null; + const head = content.slice(0, HEADER_REGION_BYTES); + for (const pattern of SOURCE_PATTERNS) { + const match = head.match(pattern); + if (match) return match[1].trim(); + } + return null; +} diff --git a/tests/kb-update/test-kb-headers.test.mjs b/tests/kb-update/test-kb-headers.test.mjs new file mode 100644 index 0000000..7e46a97 --- /dev/null +++ b/tests/kb-update/test-kb-headers.test.mjs @@ -0,0 +1,52 @@ +// tests/kb-update/test-kb-headers.test.mjs +// Unit tests for scripts/kb-update/lib/kb-headers.mjs — parses the optional +// **Source:** / **Primary source:** authority header from a KB reference file. +// Coverage is 0% today (no file declares one yet); the parser is wired into +// build-registry so authority_source can populate as files adopt the header. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseSourceHeader } from '../../scripts/kb-update/lib/kb-headers.mjs'; + +test('parseSourceHeader — **Source:** header', () => { + const content = '# Title\n\n**Source:** https://learn.microsoft.com/azure/foundry/concepts/x\n**Status:** GA\n'; + assert.equal( + parseSourceHeader(content), + 'https://learn.microsoft.com/azure/foundry/concepts/x' + ); +}); + +test('parseSourceHeader — **Primary source:** alias', () => { + const content = '# Title\n\n**Primary source:** https://learn.microsoft.com/azure/ai-services/y\n'; + assert.equal( + parseSourceHeader(content), + 'https://learn.microsoft.com/azure/ai-services/y' + ); +}); + +test('parseSourceHeader — case-insensitive label', () => { + const content = '**source:** https://learn.microsoft.com/azure/z\n'; + assert.equal(parseSourceHeader(content), 'https://learn.microsoft.com/azure/z'); +}); + +test('parseSourceHeader — no header returns null', () => { + const content = '# Title\n\n**Last updated:** 2026-04\n**Status:** GA\n**Category:** RAG\n'; + assert.equal(parseSourceHeader(content), null); +}); + +test('parseSourceHeader — empty/garbage input returns null', () => { + assert.equal(parseSourceHeader(''), null); + assert.equal(parseSourceHeader(null), null); + assert.equal(parseSourceHeader(undefined), null); +}); + +test('parseSourceHeader — only scans the top of the file (header region)', () => { + // A Source-like line buried far below the header region must NOT be picked up. + const buried = '# Title\n' + 'x'.repeat(800) + '\n**Source:** https://learn.microsoft.com/late\n'; + assert.equal(parseSourceHeader(buried), null); +}); + +test('parseSourceHeader — trims trailing whitespace from URL', () => { + const content = '**Source:** https://learn.microsoft.com/azure/trim \n'; + assert.equal(parseSourceHeader(content), 'https://learn.microsoft.com/azure/trim'); +});