// tests/kb-update/test-user-data.test.mjs // Spor C fase C2.1: user-owned storage + ambient org-context injection (#1 + #2). // // lib/user-data.mjs is the PURE resolver every later C2 sub-session builds on. // It NEVER writes — it only resolves user-owned paths and builds a compact, // deterministic org-context summary from file contents handed to it. This suite // proves: // (A) the resolvers point at ~/.claude/ms-ai-architect/ (a USER-owned dir, // independent of pluginRoot => survives plugin reinstall — acceptance K2), // and the config filename matches C1's so loadScheduleConfig stays compat, // (B) buildOrgSummary is deterministic, length-capped, tolerant, and pure // (no fs) so the SessionStart hook can inject it ambiently (K1). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { USER_DATA_DIRNAME, CONFIG_FILENAME, ORG_FILES, FREE_CONTEXT_FILE, resolveUserDataDir, resolveOrgDir, resolveConfigPath, buildOrgSummary, orderOrgFiles, } from '../../scripts/kb-update/lib/user-data.mjs'; // =========================================================================== // (A) Resolvers — pure, homedir-injectable, user-owned (survives reinstall) // =========================================================================== test('USER_DATA_DIRNAME — the plugin folder under ~/.claude', () => { assert.equal(USER_DATA_DIRNAME, 'ms-ai-architect'); }); test('resolveUserDataDir — user-owned dir under ~/.claude, independent of pluginRoot (K2)', () => { assert.equal(resolveUserDataDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect')); }); test('resolveUserDataDir — defaults to os.homedir()', () => { assert.equal(resolveUserDataDir(), join(homedir(), '.claude', 'ms-ai-architect')); }); test('resolveOrgDir — org/ under the user data dir', () => { assert.equal(resolveOrgDir('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect', 'org')); }); test('resolveConfigPath — config under user data dir; filename matches C1 (backward-compat)', () => { assert.equal( resolveConfigPath('/Users/x'), join('/Users/x', '.claude', 'ms-ai-architect', CONFIG_FILENAME), ); assert.equal(CONFIG_FILENAME, 'ms-ai-architect.local.md', 'must match detection-schedule CONFIG_FILENAME'); }); test('ORG_FILES — the five canonical onboarding files in deterministic order', () => { assert.deepEqual([...ORG_FILES], [ 'organization-profile.md', 'technology-stack.md', 'security-compliance.md', 'architecture-decisions.md', 'business-references.md', ]); }); // =========================================================================== // (B) buildOrgSummary — deterministic, capped, pure // =========================================================================== const PROFILE = [ '---', 'category: organization-profile', 'completed: true', 'last_updated: 2026-06-22', '---', '', '# Virksomhetsprofil', '', '## Sektortype', 'Offentlig sektor', '', '## Sektor', 'Statlig', '', '## Virksomhet', 'Statens vegvesen — nasjonal veiforvaltning', '', '## Størrelse', '2000-10000', '', '## Regulatoriske krav', 'Personopplysningsloven/GDPR, Arkivloven, Forvaltningsloven', '', ].join('\n'); test('buildOrgSummary — no files / null content => empty string', () => { assert.equal(buildOrgSummary({}), ''); assert.equal(buildOrgSummary({ 'organization-profile.md': null }), ''); }); test('buildOrgSummary — null/undefined/garbage input is tolerated (returns "")', () => { assert.equal(buildOrgSummary(null), ''); assert.equal(buildOrgSummary(undefined), ''); assert.equal(buildOrgSummary({ 'organization-profile.md': 12345 }), ''); }); test('buildOrgSummary — strips frontmatter + H1, extracts H2 section => value lines', () => { const s = buildOrgSummary({ 'organization-profile.md': PROFILE }); assert.doesNotMatch(s, /category:/, 'frontmatter stripped'); assert.doesNotMatch(s, /completed:/, 'frontmatter stripped'); assert.doesNotMatch(s, /Virksomhetsprofil/, 'H1 not included'); assert.match(s, /Sektortype: Offentlig sektor/); assert.match(s, /Sektor: Statlig/); assert.match(s, /Virksomhet: Statens vegvesen/); assert.match(s, /Størrelse: 2000-10000/); assert.match(s, /Regulatoriske krav: .*Arkivloven/); }); test('buildOrgSummary — deterministic order follows ORG_FILES regardless of input key order', () => { const TECH = '---\ncategory: technology-stack\n---\n## Lisenstype\nE5\n'; const out = buildOrgSummary({ 'technology-stack.md': TECH, 'organization-profile.md': PROFILE }); assert.ok( out.indexOf('Sektortype') < out.indexOf('Lisenstype'), 'organization-profile sections precede technology-stack sections', ); }); test('buildOrgSummary — non-.md keys and non-string values contribute nothing', () => { assert.equal(buildOrgSummary({ 'notater.txt': '## Hemmelig\nverdi' }), ''); assert.equal(buildOrgSummary({ 'ekstra.md': 12345 }), ''); }); test('buildOrgSummary — skips empty sections (header with no body)', () => { const c = '## Sektor\n\n## Størrelse\n500-2000\n'; const s = buildOrgSummary({ 'organization-profile.md': c }); assert.doesNotMatch(s, /Sektor:/, 'empty section omitted'); assert.match(s, /Størrelse: 500-2000/); }); test('buildOrgSummary — collapses a multi-line section body to one line', () => { const c = '## Virksomhet\nLinje en\nLinje to\n'; const s = buildOrgSummary({ 'organization-profile.md': c }); assert.match(s, /Virksomhet: Linje en Linje to/); }); test('buildOrgSummary — caps a long free-text value with an ellipsis (budget guard)', () => { const long = 'x'.repeat(500); const s = buildOrgSummary( { 'business-references.md': `## Referansearkitektur\n${long}\n` }, { maxTotalLen: 80 }, ); const line = s.split('\n').find((l) => l.includes('Referansearkitektur')); assert.ok(line, 'value line present'); assert.match(line, /…$/, 'truncation marker appended'); // label "Referansearkitektur: " + value(<=80) assert.ok(line.length <= 'Referansearkitektur: '.length + 80, `line too long: ${line.length}`); }); test('buildOrgSummary — caps total line count and marks omitted fields', () => { const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n'); const s = buildOrgSummary({ 'organization-profile.md': many }, { cap: 10 }); const lines = s.split('\n').filter(Boolean); assert.ok(lines.length <= 10, `capped to 10, got ${lines.length}`); assert.match(s, /flere/, 'truncation marker indicates omitted fields'); }); test('buildOrgSummary — default cap keeps the summary compact (<= 25 lines)', () => { const all = {}; for (const f of ORG_FILES) { all[f] = Array.from({ length: 8 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n'); } const s = buildOrgSummary(all); // default cap const lines = s.split('\n').filter(Boolean); assert.ok(lines.length <= 25, `default cap should keep <=25 lines, got ${lines.length}`); }); // =========================================================================== // (C) Free-prose context (C2.2) — the optional sixth file (#3, acceptance K3) // =========================================================================== test('FREE_CONTEXT_FILE — optional free-prose filename, NOT part of ORG_FILES', () => { assert.equal(FREE_CONTEXT_FILE, 'free-context.md'); assert.ok( !ORG_FILES.includes(FREE_CONTEXT_FILE), 'free-context is optional and must not count toward onboarding completeness', ); }); const FREE = [ '---', 'category: free-context', 'completed: true', 'last_updated: 2026-06-22', '---', '', '# Fri kontekst', '', '## Fri kontekst', 'Vi migrerer fra on-prem til Azure i 2026.', '', ].join('\n'); test('buildOrgSummary — surfaces free-context as a "Fri kontekst" field', () => { const s = buildOrgSummary({ 'free-context.md': FREE }); assert.match(s, /Fri kontekst: Vi migrerer fra on-prem til Azure i 2026\./); }); test('buildOrgSummary — free-context is robust to raw prose without any H2 (K3: no silent drop)', () => { // The whole point of #3 is a single free field; it must surface even when the // agent writes a plain paragraph with no markdown structure at all. const s = buildOrgSummary({ 'free-context.md': 'Vi har hatt en hendelse med Copilot som lekket interne dokumenter.', }); assert.match(s, /Fri kontekst: Vi har hatt en hendelse med Copilot/); }); test('buildOrgSummary — free-context strips frontmatter and all header lines', () => { const s = buildOrgSummary({ 'free-context.md': '---\ncategory: free-context\n---\n# Fri kontekst\n## Fri kontekst\nKun prosaen skal vises.\n', }); assert.doesNotMatch(s, /category:/, 'frontmatter stripped'); assert.equal(s, 'Fri kontekst: Kun prosaen skal vises.'); }); test('buildOrgSummary — free-context appears AFTER the structured fields', () => { const out = buildOrgSummary({ 'free-context.md': '## Fri kontekst\nTilleggsnotat.', 'organization-profile.md': PROFILE, }); assert.ok( out.indexOf('Sektortype') < out.indexOf('Fri kontekst'), 'structured context leads; free prose trails', ); }); test('buildOrgSummary — long free-context is capped with an ellipsis (budget guard)', () => { const s = buildOrgSummary({ 'free-context.md': 'y'.repeat(500) }, { maxTotalLen: 80 }); const line = s.split('\n').find((l) => l.startsWith('Fri kontekst:')); assert.ok(line, 'free-context line present'); assert.match(line, /…$/, 'truncation marker appended'); assert.ok(line.length <= 'Fri kontekst: '.length + 80, `line too long: ${line.length}`); }); test('buildOrgSummary — empty/whitespace/header-only free-context yields no field', () => { assert.equal(buildOrgSummary({ 'free-context.md': ' \n\n' }), ''); assert.equal( buildOrgSummary({ 'free-context.md': '---\ncategory: free-context\n---\n# Fri kontekst\n' }), '', 'frontmatter + bare H1 (no prose) => nothing', ); }); test('buildOrgSummary — free-context absent leaves the structured summary unchanged (back-compat)', () => { const s = buildOrgSummary({ 'organization-profile.md': PROFILE }); assert.doesNotMatch(s, /Fri kontekst/); }); test('buildOrgSummary — realistic full onboarding keeps free-context visible (K3)', () => { const all = {}; for (const f of ORG_FILES) all[f] = `## ${f}-felt\nverdi for ${f}`; all['free-context.md'] = '## Fri kontekst\nViktig tilleggskontekst som må være synlig.'; const s = buildOrgSummary(all); // default cap 25 assert.match(s, /Fri kontekst: Viktig tilleggskontekst/, 'free-context visible within default budget'); }); // =========================================================================== // (D) Reduksjon — the total value budget replaces the per-field cap, and the // file set is no longer frozen to ORG_FILES (measured 2026-08-26: a 160- // char per-field cap threw away 47 % of a 300-char answer and 92 % of a // 2000-char one while most of the budget stood unspent, and a seventh file // in org/ never reached the model at all). // =========================================================================== test('buildOrgSummary — a long field survives whole while the total budget is unspent', () => { const long = 'x'.repeat(1500); const s = buildOrgSummary({ 'organization-profile.md': '## Sektor\noffentlig', 'free-context.md': long, }); assert.ok(s.includes(long), 'a single long answer must survive uncut when the budget allows it'); }); test('buildOrgSummary — unspent budget is redistributed, so every field beats the old 160 cap', () => { const all = {}; for (const f of ORG_FILES) { all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(1000)}`).join('\n\n'); } const s = buildOrgSummary(all); // 20 fields, default budget 4000 => 200 each const values = s.split('\n').map((l) => l.slice(l.indexOf(': ') + 2)); for (const v of values) { assert.ok(v.length >= 200, `fair share of 4000 over 20 fields is 200, got ${v.length}`); } }); test('buildOrgSummary — the total value budget is enforced across all fields', () => { const all = {}; for (const f of ORG_FILES) { all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\n${'x'.repeat(5000)}`).join('\n\n'); } const s = buildOrgSummary(all); const total = s .split('\n') .reduce((sum, l) => sum + l.slice(l.indexOf(': ') + 2).length, 0); assert.ok(total <= 4000, `total value chars must stay within the 4000 budget, got ${total}`); }); test('buildOrgSummary — a short field is never padded away from its own length', () => { const s = buildOrgSummary({ 'organization-profile.md': '## Sektor\noffentlig', 'technology-stack.md': `## Stack\n${'x'.repeat(9000)}`, }); assert.match(s, /^Sektor: offentlig$/m, 'a short field is kept whole, never trimmed to its share'); }); test('buildOrgSummary — maxTotalLen is overridable', () => { const s = buildOrgSummary({ 'organization-profile.md': `## Sektor\n${'x'.repeat(500)}` }, { maxTotalLen: 50 }); const value = s.slice(s.indexOf(': ') + 2); assert.equal(value.length, 50, `explicit budget must bind, got ${value.length}`); }); test('buildOrgSummary — an extra .md file beyond ORG_FILES contributes its sections', () => { const s = buildOrgSummary({ 'kundeportefolje.md': '## Kunder\nStore offentlige etater' }); assert.match(s, /Kunder: Store offentlige etater/); }); test('buildOrgSummary — extra files come after ORG_FILES, before free-context, sorted by name', () => { const s = buildOrgSummary({ 'zeta.md': '## Zeta\nz', 'alfa.md': '## Alfa\na', 'organization-profile.md': '## Sektor\noffentlig', 'free-context.md': 'fritekst', }); assert.deepEqual( s.split('\n').map((l) => l.slice(0, l.indexOf(':'))), ['Sektor', 'Alfa', 'Zeta', 'Fri kontekst'], ); }); test('orderOrgFiles — ORG_FILES first in canonical order, then extra .md sorted by name', () => { assert.deepEqual( orderOrgFiles(['zeta.md', 'technology-stack.md', 'alfa.md', 'organization-profile.md']), ['organization-profile.md', 'technology-stack.md', 'alfa.md', 'zeta.md'], ); }); test('orderOrgFiles — free-context is excluded (callers append it last, labelled)', () => { assert.deepEqual(orderOrgFiles([FREE_CONTEXT_FILE, 'alfa.md']), ['alfa.md']); }); test('orderOrgFiles — non-.md entries and garbage input are dropped', () => { assert.deepEqual(orderOrgFiles(['notater.txt', '.DS_Store', 'alfa.md']), ['alfa.md']); assert.deepEqual(orderOrgFiles(null), []); }); test('buildOrgSummary — Fri kontekst survives the overflow cut (it is added last, not least)', () => { const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n'); const s = buildOrgSummary({ 'organization-profile.md': many, 'free-context.md': 'Den ene sloten der brukeren skriver fritt.', }); const lines = s.split('\n'); assert.ok(lines.length <= 25, `line budget still binds, got ${lines.length}`); assert.match(s, /Fri kontekst: Den ene sloten/, 'free-context must not be the first field dropped'); assert.match(lines[lines.length - 1], /flere felt/, 'marker still last'); assert.match(lines[lines.length - 2], /^Fri kontekst:/, 'free-context kept as the last field'); }); test('buildOrgSummary — overflow without free-context is unchanged (back-compat)', () => { const many = Array.from({ length: 40 }, (_, i) => `## Felt${i}\nverdi${i}`).join('\n\n'); const lines = buildOrgSummary({ 'organization-profile.md': many }, { cap: 10 }).split('\n'); assert.equal(lines.length, 10); assert.equal(lines[0], 'Felt0: verdi0'); assert.equal(lines[8], 'Felt8: verdi8'); assert.match(lines[9], /flere felt/); }); // =========================================================================== // (E) Reduksjon ved SKALA — the unit of reduction is the DOCUMENT, not the // field. Measured 2026-08-26 (bake-off arm A2b, flat org/, n=55 corpus // docs): 61 files and 183 fields went in, 24 fields came out, and those 24 // came from 8 files — 53 files were invisible while 1013 of the 4000 value // chars stood unspent. What remained was selection by FIXED ORDER, which is // relevance-blind, and the summary is built ambiently at session start — // before any question exists — so there is no relevance signal to rank // against. The honest substitute for ranking is COVERAGE. // =========================================================================== test('buildOrgSummary — every file contributes at least one field when the budget allows it', () => { const all = {}; for (let f = 0; f < 40; f++) { all[`fil${String(f).padStart(2, '0')}.md`] = Array.from( { length: 3 }, (_, i) => `## F${f}H${i}\nverdi fra fil ${f} felt ${i}`, ).join('\n\n'); } const s = buildOrgSummary(all); const missing = []; for (let f = 0; f < 40; f++) { if (!new RegExp(`^F${f}H\\d: `, 'm').test(s)) missing.push(f); } assert.deepEqual(missing, [], `every file must reach the summary; ${missing.length}/40 did not`); }); test('buildOrgSummary — selection is breadth-first across files, not fixed order within one', () => { const all = { 'organization-profile.md': '## A0\na0\n\n## A1\na1\n\n## A2\na2', 'technology-stack.md': '## B0\nb0\n\n## B1\nb1\n\n## B2\nb2', 'security-compliance.md': '## C0\nc0\n\n## C1\nc1\n\n## C2\nc2', }; // cap 4 => 3 field lines + marker. Fixed order would spend all three on the // first file; coverage spends one on each. const lines = buildOrgSummary(all, { cap: 4 }).split('\n'); assert.deepEqual(lines.slice(0, 3), ['A0: a0', 'B0: b0', 'C0: c0']); assert.match(lines[3], /flere felt/); }); test('buildOrgSummary — the overflow marker reports how many FILES were cut, not only fields', () => { const all = {}; for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\nverdi ${f}`; const s = buildOrgSummary(all); const marker = s.split('\n').at(-1); assert.match(marker, /flere felt/, 'field residue still reported'); assert.match(marker, /\d+ filer/, 'file residue must be named — a silent drop is the defect'); }); test('buildOrgSummary — coverage yields to the char budget, never starving lines to fragments', () => { const all = {}; for (let f = 0; f < 200; f++) all[`fil${String(f).padStart(3, '0')}.md`] = `## H${f}\n${'x'.repeat(400)}`; const s = buildOrgSummary(all); const values = s.split('\n').filter((l) => /^H\d+: /.test(l)).map((l) => l.slice(l.indexOf(': ') + 2)); assert.ok(values.length > 0, 'fields emitted'); for (const v of values) { assert.ok(v.length >= 60, `a coverage line must stay readable, got ${v.length}`); } assert.ok( values.reduce((a, v) => a + v.length, 0) <= 4000, 'the total value budget still binds at scale', ); }); test('buildOrgSummary — the designed onboarding size is untouched by the coverage rule', () => { const all = {}; for (const f of ORG_FILES) { all[f] = Array.from({ length: 4 }, (_, i) => `## ${f}-Felt${i}\nverdi${i}`).join('\n\n'); } const lines = buildOrgSummary(all).split('\n'); assert.equal(lines.length, 20, '20 fields over 5 files fit; no marker, no reordering'); assert.equal(lines[0], 'organization-profile.md-Felt0: verdi0'); assert.equal(lines[3], 'organization-profile.md-Felt3: verdi3'); }); test('buildOrgSummary — Fri kontekst still survives the cut at scale', () => { const all = {}; for (let f = 0; f < 40; f++) all[`fil${String(f).padStart(2, '0')}.md`] = `## H${f}\nverdi ${f}`; all['free-context.md'] = 'Den ene sloten der brukeren skriver fritt.'; const s = buildOrgSummary(all, { cap: 10 }); assert.match(s, /Fri kontekst: Den ene sloten/, 'free-context must not be the first field dropped'); });