Operatoerbeslutning: naar linje-cappen (25) sprekker, skal fri kontekst
overleve i stedet for aa vaere det foerste feltet som forsvinner.
Fri kontekst legges SIST fordi den skal LESES sist. Under et rent head-slice
gjorde nettopp det den til den foerste som falt ut - og det er den ene sloten
brukeren skriver fritt i, som onboarding-agent.md:125 selv kaller den
enkeltkategorien som gjoer raadene mest treffsikre. Posisjon i sammendraget er
en lese-rekkefoelge, ikke en prioritetsrekkefoelge; kuttet blandet de to.
Ved overflow beholdes naa (cap-2) strukturerte felt + fri kontekst + markoer,
altsaa fortsatt noeyaktig cap linjer. Uten fri kontekst er kuttet uendret.
Dette ble den bindende grensen foerst etter a7098e0: med readdir aapnet kan
brukeren legge til filer fritt, saa terskelen paa 26 felt naas naa av
brukerhandling og ikke bare av design.
Verifisert paa den ekte hooken: 40 felt + fri kontekst gir 25 linjer, med
"Fri kontekst" som siste felt og markoeren "(+22 flere felt)" til slutt.
TDD: 2 nye tester, skjermingstesten roed foerst mot forrige commit. Den andre
pinner at overflow UTEN fri kontekst er uendret (back-compat). Suite 1064/1064.
[skip-docs]: ren intern mekanikk - ingen endring i utoverrettet flate.
377 lines
16 KiB
JavaScript
377 lines
16 KiB
JavaScript
// 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/);
|
|
});
|