diff --git a/scripts/kb-update/lib/registry-migrate.mjs b/scripts/kb-update/lib/registry-migrate.mjs new file mode 100644 index 0000000..98a3267 --- /dev/null +++ b/scripts/kb-update/lib/registry-migrate.mjs @@ -0,0 +1,80 @@ +// registry-migrate.mjs — One-shot ai-foundry -> foundry URL remap + reserved +// schema migration. Pure functions (no I/O) so they are unit-testable; the +// migrate-ai-foundry.mjs script wires backup + atomic write around them. +// +// Microsoft rebranded "Azure AI Foundry" -> "Microsoft Foundry"; doc URLs moved +// /azure/ai-foundry/ -> /azure/foundry/. Two pages probed in Sesjon 1 are DEAD +// on a naive prefix swap and need explicit targets. + +const AI_FOUNDRY_PREFIX = 'https://learn.microsoft.com/azure/ai-foundry/'; +const FOUNDRY_PREFIX = 'https://learn.microsoft.com/azure/foundry/'; + +// Probed DEAD pages (Sesjon 1): naive prefix swap 404s, these are the live targets. +const DEAD_REMAPS = { + 'https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-evaluators': + 'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators', + 'https://learn.microsoft.com/azure/ai-foundry/agent-service': + 'https://learn.microsoft.com/azure/foundry/agents/overview', +}; + +/** + * Remap a single registry URL key. DEAD overrides win; otherwise prefix-swap + * ai-foundry -> foundry; non-ai-foundry URLs are returned unchanged. + * @param {string} url + * @returns {string} + */ +export function remapKey(url) { + if (DEAD_REMAPS[url]) return DEAD_REMAPS[url]; + if (url.startsWith(AI_FOUNDRY_PREFIX)) { + return FOUNDRY_PREFIX + url.slice(AI_FOUNDRY_PREFIX.length); + } + return url; +} + +/** + * Migrate a registry: remap ai-foundry keys (resetting their tracking state so + * the next poll re-evaluates them), merge any target collisions, and ensure the + * reserved schema fields (authority_source, course) exist on every entry. + * Pure — does not mutate the input. + * @param {object} registry + * @returns {{registry: object, stats: {remapped: number, deadRemapped: number, collisions: number}}} + */ +export function migrateRegistry(registry) { + const out = { ...registry, urls: {} }; + const stats = { remapped: 0, deadRemapped: 0, collisions: 0 }; + + for (const [url, entry] of Object.entries(registry.urls)) { + const target = remapKey(url); + const isRemap = target !== url; + + // Normalize every entry to the full schema (reserved fields included). + const base = { + sitemap_lastmod: entry.sitemap_lastmod ?? null, + reference_files: entry.reference_files ?? [], + status: entry.status ?? 'unpolled', + authority_source: entry.authority_source ?? null, + course: entry.course ?? null, + }; + + let next = base; + if (isRemap) { + stats.remapped++; + if (DEAD_REMAPS[url]) stats.deadRemapped++; + // The URL changed — old poll state is invalid; let the next poll decide. + next = { ...base, sitemap_lastmod: null, status: 'unpolled' }; + } + + if (out.urls[target]) { + // Two source keys collapsed onto one target — union the reference files. + stats.collisions++; + const merged = [ + ...new Set([...out.urls[target].reference_files, ...next.reference_files]), + ].sort(); + out.urls[target] = { ...out.urls[target], ...next, reference_files: merged }; + } else { + out.urls[target] = next; + } + } + + return { registry: out, stats }; +} diff --git a/scripts/kb-update/migrate-ai-foundry.mjs b/scripts/kb-update/migrate-ai-foundry.mjs new file mode 100644 index 0000000..7fd21ba --- /dev/null +++ b/scripts/kb-update/migrate-ai-foundry.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +// migrate-ai-foundry.mjs — One-shot migration: back up data/, remap registry +// keys ai-foundry -> foundry (+ 2 probed DEAD targets), migrate reserved schema +// fields, write atomically. Idempotent: re-running finds nothing to remap. +// Usage: node migrate-ai-foundry.mjs + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadRegistry, saveRegistry } from './lib/registry-io.mjs'; +import { backupDir } from './lib/backup.mjs'; +import { migrateRegistry } from './lib/registry-migrate.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = join(__dirname, 'data'); +const PLUGIN_ROOT = join(__dirname, '..', '..'); +const BACKUP_ROOT = join(PLUGIN_ROOT, '.kb-backup'); + +const registry = loadRegistry(DATA_DIR); +const before = Object.keys(registry.urls).length; + +const { backupPath } = backupDir(DATA_DIR, BACKUP_ROOT); +console.log(`Backed up data/ -> ${backupPath}`); + +const { registry: migrated, stats } = migrateRegistry(registry); +const after = Object.keys(migrated.urls).length; + +saveRegistry(migrated, DATA_DIR); + +const remainingAiFoundry = Object.keys(migrated.urls) + .filter((u) => u.includes('/azure/ai-foundry/')).length; + +console.log('=== ai-foundry -> foundry remap ==='); +console.log(`URLs: ${before} -> ${after} (collisions merged: ${stats.collisions})`); +console.log(`Remapped: ${stats.remapped} (DEAD explicit targets: ${stats.deadRemapped})`); +console.log(`Remaining ai-foundry keys: ${remainingAiFoundry} (expect 0)`); diff --git a/tests/kb-update/test-registry-migrate.test.mjs b/tests/kb-update/test-registry-migrate.test.mjs new file mode 100644 index 0000000..094536f --- /dev/null +++ b/tests/kb-update/test-registry-migrate.test.mjs @@ -0,0 +1,123 @@ +// tests/kb-update/test-registry-migrate.test.mjs +// Unit tests for scripts/kb-update/lib/registry-migrate.mjs — the one-shot +// ai-foundry -> foundry URL remap + reserved-schema migration. +// Microsoft rebranded "Azure AI Foundry" -> "Microsoft Foundry"; URLs moved +// /azure/ai-foundry/ -> /azure/foundry/. Two probed pages are DEAD on a naive +// prefix swap and need explicit targets (verified in Sesjon 1). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { remapKey, migrateRegistry } from '../../scripts/kb-update/lib/registry-migrate.mjs'; + +const AF = 'https://learn.microsoft.com/azure/ai-foundry/'; +const FD = 'https://learn.microsoft.com/azure/foundry/'; + +test('remapKey — prefix swap ai-foundry -> foundry', () => { + assert.equal(remapKey(AF + 'openai/how-to/manage-costs'), FD + 'openai/how-to/manage-costs'); + assert.equal(remapKey(AF + 'responsible-ai/openai/content-filter'), FD + 'responsible-ai/openai/content-filter'); +}); + +test('remapKey — two probed DEAD pages get explicit targets', () => { + assert.equal( + remapKey('https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-evaluators'), + 'https://learn.microsoft.com/azure/foundry/concepts/built-in-evaluators' + ); + assert.equal( + remapKey('https://learn.microsoft.com/azure/ai-foundry/agent-service'), + 'https://learn.microsoft.com/azure/foundry/agents/overview' + ); +}); + +test('remapKey — non-ai-foundry URLs untouched', () => { + const other = 'https://learn.microsoft.com/azure/machine-learning/overview'; + assert.equal(remapKey(other), other); + // already-foundry URL is left as-is + assert.equal(remapKey(FD + 'what-is-azure-ai-foundry'), FD + 'what-is-azure-ai-foundry'); +}); + +function fixtureRegistry() { + return { + version: 1, + created_at: '2026-01-01', + last_poll: '2026-06-19T11:00:00Z', + sitemap_state: { 'azure_en-us_7': { lastmod: '2026-06-01', checked_at: '2026-06-19' } }, + urls: { + [AF + 'openai/how-to/manage-costs']: { + sitemap_lastmod: null, reference_files: ['skills/a.md'], status: 'not_in_sitemap', + }, + [AF + 'agent-service']: { + sitemap_lastmod: null, reference_files: ['skills/b.md'], status: 'not_in_sitemap', + }, + // already-tracked non-foundry URL must survive untouched + 'https://learn.microsoft.com/azure/search/rag': { + sitemap_lastmod: '2026-05-10', reference_files: ['skills/c.md'], status: 'tracked', + }, + }, + }; +} + +test('migrateRegistry — remaps ai-foundry keys and resets their tracking', () => { + const { registry } = migrateRegistry(fixtureRegistry()); + // keys remapped + assert.ok(registry.urls[FD + 'openai/how-to/manage-costs'], 'prefix-swap key missing'); + assert.ok(registry.urls[FD + 'agents/overview'], 'DEAD-remap key missing'); + assert.ok(!registry.urls[AF + 'openai/how-to/manage-costs'], 'old ai-foundry key still present'); + // remapped entries reset so the next poll re-evaluates + const e = registry.urls[FD + 'openai/how-to/manage-costs']; + assert.equal(e.status, 'unpolled'); + assert.equal(e.sitemap_lastmod, null); + assert.deepEqual(e.reference_files, ['skills/a.md']); +}); + +test('migrateRegistry — non-ai-foundry entry preserved verbatim (plus reserved fields)', () => { + const { registry } = migrateRegistry(fixtureRegistry()); + const e = registry.urls['https://learn.microsoft.com/azure/search/rag']; + assert.equal(e.status, 'tracked'); + assert.equal(e.sitemap_lastmod, '2026-05-10'); + assert.deepEqual(e.reference_files, ['skills/c.md']); +}); + +test('migrateRegistry — reserved schema fields present on every entry', () => { + const { registry } = migrateRegistry(fixtureRegistry()); + for (const [url, e] of Object.entries(registry.urls)) { + assert.ok('authority_source' in e, `authority_source missing on ${url}`); + assert.ok('course' in e, `course missing on ${url}`); + assert.equal(e.authority_source, null); + assert.equal(e.course, null); + } +}); + +test('migrateRegistry — top-level registry metadata preserved', () => { + const { registry } = migrateRegistry(fixtureRegistry()); + assert.equal(registry.version, 1); + assert.equal(registry.created_at, '2026-01-01'); + assert.ok(registry.sitemap_state['azure_en-us_7']); +}); + +test('migrateRegistry — collision on same target merges reference_files (union, sorted)', () => { + const reg = { + version: 1, created_at: null, last_poll: null, sitemap_state: {}, + urls: { + // both map to FD + agents/overview (one via DEAD remap, one via prefix swap) + [AF + 'agent-service']: { sitemap_lastmod: null, reference_files: ['skills/z.md', 'skills/a.md'], status: 'not_in_sitemap' }, + [AF + 'agents/overview']: { sitemap_lastmod: null, reference_files: ['skills/a.md', 'skills/m.md'], status: 'not_in_sitemap' }, + }, + }; + const { registry, stats } = migrateRegistry(reg); + const e = registry.urls[FD + 'agents/overview']; + assert.deepEqual(e.reference_files, ['skills/a.md', 'skills/m.md', 'skills/z.md']); + assert.equal(Object.keys(registry.urls).length, 1); + assert.ok(stats.collisions >= 1); +}); + +test('migrateRegistry — does not mutate the input registry', () => { + const input = fixtureRegistry(); + migrateRegistry(input); + assert.ok(input.urls[AF + 'openai/how-to/manage-costs'], 'input was mutated'); +}); + +test('migrateRegistry — stats count remaps and DEAD remaps', () => { + const { stats } = migrateRegistry(fixtureRegistry()); + assert.equal(stats.remapped, 2); + assert.equal(stats.deadRemapped, 1); // agent-service is the only DEAD one in the fixture +});