328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { readdirSync, statSync } from 'node:fs';
|
|
import { join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
parseManifest,
|
|
loadManifest,
|
|
matchGlob,
|
|
resolveOwnership,
|
|
frameworkSubtreeRoots,
|
|
planPrune,
|
|
ManifestError,
|
|
type FrameworkManifest,
|
|
} from './manifest.js';
|
|
|
|
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
|
|
|
const SAMPLE = `
|
|
# comment
|
|
[framework]
|
|
CONSTITUTION.md
|
|
guides/**
|
|
tools/**
|
|
|
|
[operator]
|
|
SOUL.md
|
|
*.local.md
|
|
agents/**
|
|
tools/_lib/credentials.json
|
|
`;
|
|
|
|
describe('parseManifest', () => {
|
|
it('splits entries into framework and operator sections, ignoring comments/blanks', () => {
|
|
const m = parseManifest(SAMPLE);
|
|
expect(m.framework).toEqual(['CONSTITUTION.md', 'guides/**', 'tools/**']);
|
|
expect(m.operator).toEqual([
|
|
'SOUL.md',
|
|
'*.local.md',
|
|
'agents/**',
|
|
'tools/_lib/credentials.json',
|
|
]);
|
|
});
|
|
|
|
it('rejects an entry that appears before any section header', () => {
|
|
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(/before any \[section\]/);
|
|
});
|
|
|
|
it('rejects an unknown section header', () => {
|
|
expect(() => parseManifest('[bogus]\nx\n')).toThrow(/Unknown manifest section/);
|
|
});
|
|
});
|
|
|
|
// Fail-closed parsing/loading (#791 B2/B3). An empty, comment-only, operator-only,
|
|
// or unreadable manifest must NOT resolve to "framework owns nothing" (which would
|
|
// make an upgrade a silent no-op). Both must throw so finalizeStage surfaces the
|
|
// abort instead of reporting "Installation complete". The bash reader rejects the
|
|
// same inputs — asserted for parity in manifest-parity.spec.ts.
|
|
describe('parseManifest / loadManifest fail closed on empty or unreadable input', () => {
|
|
it('throws on a completely empty manifest', () => {
|
|
expect(() => parseManifest('')).toThrow(/no \[framework\] paths/);
|
|
});
|
|
|
|
it('throws on a comment- and blank-only manifest (no entries at all)', () => {
|
|
expect(() => parseManifest('# just a header comment\n\n \n')).toThrow(
|
|
/no \[framework\] paths/,
|
|
);
|
|
});
|
|
|
|
it('throws when only an [operator] section is present (zero framework paths)', () => {
|
|
expect(() => parseManifest('[operator]\nSOUL.md\n*.local.md\n')).toThrow(
|
|
/no \[framework\] paths/,
|
|
);
|
|
});
|
|
|
|
it('throws on a [framework] header with no entries beneath it', () => {
|
|
expect(() => parseManifest('[framework]\n\n[operator]\nSOUL.md\n')).toThrow(
|
|
/no \[framework\] paths/,
|
|
);
|
|
});
|
|
|
|
// Degenerate framework entries that pass the length check but normalize to a
|
|
// glob matching nothing — the manifest would silently protect the whole tree
|
|
// as operator (#791 blocker-B). Both `/` and `./` normalize to '' ; `.`/`..`
|
|
// are bare-dot segments.
|
|
it.each([['/'], ['./'], ['.'], ['..'], ['/\n./']])(
|
|
'throws when the only [framework] entry (%j) normalizes to nothing usable',
|
|
(entry) => {
|
|
expect(() => parseManifest(`[framework]\n${entry}\n`)).toThrow(
|
|
/no usable \[framework\] paths/,
|
|
);
|
|
},
|
|
);
|
|
|
|
it('accepts a wildcard-only framework glob (** is usable)', () => {
|
|
expect(() => parseManifest('[framework]\n**\n')).not.toThrow();
|
|
});
|
|
|
|
it('loadManifest throws a clear fail-closed error when the manifest file is missing', () => {
|
|
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
|
expect(() => loadManifest(missingRoot)).toThrow(/Cannot read framework manifest/);
|
|
});
|
|
|
|
// The distinct error type is what lets finalizeStage tell a pre-sync validation
|
|
// abort (nothing written) from a mid-sync filesystem failure (#791 blocker-C).
|
|
it('every fail-closed rejection is a ManifestError', () => {
|
|
expect(() => parseManifest('')).toThrow(ManifestError);
|
|
expect(() => parseManifest('[operator]\nSOUL.md\n')).toThrow(ManifestError);
|
|
expect(() => parseManifest('[framework]\n/\n')).toThrow(ManifestError);
|
|
expect(() => parseManifest('[bogus]\nx\n')).toThrow(ManifestError);
|
|
expect(() => parseManifest('stray.md\n[framework]\n')).toThrow(ManifestError);
|
|
const missingRoot = fileURLToPath(new URL('./__no_such_framework_root__', import.meta.url));
|
|
expect(() => loadManifest(missingRoot)).toThrow(ManifestError);
|
|
});
|
|
});
|
|
|
|
describe('matchGlob', () => {
|
|
it('matches an exact file', () => {
|
|
expect(matchGlob('CONSTITUTION.md', 'CONSTITUTION.md')).toBe(true);
|
|
expect(matchGlob('CONSTITUTION.md', 'AGENTS.md')).toBe(false);
|
|
});
|
|
|
|
it('treats a bare directory entry as covering its descendants', () => {
|
|
expect(matchGlob('memory', 'memory')).toBe(true);
|
|
expect(matchGlob('memory', 'memory/notes.md')).toBe(true);
|
|
expect(matchGlob('memory', 'memoryfoo')).toBe(false);
|
|
});
|
|
|
|
it('** matches any depth including the root itself', () => {
|
|
expect(matchGlob('agents/**', 'agents')).toBe(true);
|
|
expect(matchGlob('agents/**', 'agents/a.conf')).toBe(true);
|
|
expect(matchGlob('agents/**', 'agents/nested/deep.conf')).toBe(true);
|
|
expect(matchGlob('agents/**', 'agentsX')).toBe(false);
|
|
});
|
|
|
|
it('* stays within a single segment', () => {
|
|
expect(matchGlob('*.local.md', 'SOUL.local.md')).toBe(true);
|
|
expect(matchGlob('*.local.md', 'a/SOUL.local.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('resolveOwnership (deny-wins + fail-safe)', () => {
|
|
const m = parseManifest(SAMPLE);
|
|
|
|
it('operator globs win over framework globs (carve-out inside a framework subtree)', () => {
|
|
expect(resolveOwnership(m, 'tools/_lib/credentials.json')).toBe('operator');
|
|
expect(resolveOwnership(m, 'tools/git/pr-create.sh')).toBe('framework');
|
|
});
|
|
|
|
it('framework-declared paths resolve to framework', () => {
|
|
expect(resolveOwnership(m, 'guides/E2E-DELIVERY.md')).toBe('framework');
|
|
expect(resolveOwnership(m, 'CONSTITUTION.md')).toBe('framework');
|
|
});
|
|
|
|
it('UNKNOWN paths default to operator (the #791 root-cause guarantee)', () => {
|
|
expect(resolveOwnership(m, 'agents/coder0.conf')).toBe('operator'); // declared
|
|
expect(resolveOwnership(m, 'harvester/sop.md')).toBe('operator'); // undeclared → fail-safe
|
|
expect(resolveOwnership(m, 'totally-unknown-dir/x')).toBe('operator');
|
|
expect(resolveOwnership(m, 'random-root-file.md')).toBe('operator');
|
|
});
|
|
});
|
|
|
|
describe('planPrune (pure prune planner)', () => {
|
|
const m = parseManifest(SAMPLE);
|
|
|
|
it('prunes a retired framework file inside a shipped subtree', () => {
|
|
const del = planPrune({
|
|
manifest: m,
|
|
targetPaths: ['guides/OLD.md', 'guides/KEEP.md'],
|
|
sourcePaths: ['guides/KEEP.md'],
|
|
});
|
|
expect(del).toEqual(['guides/OLD.md']);
|
|
});
|
|
|
|
it('never prunes operator-reserved paths even when absent from source', () => {
|
|
const del = planPrune({
|
|
manifest: m,
|
|
targetPaths: ['agents/coder0.conf', 'tools/_lib/credentials.json', 'SOUL.local.md'],
|
|
sourcePaths: [],
|
|
});
|
|
expect(del).toEqual([]);
|
|
});
|
|
|
|
it('never prunes UNKNOWN paths outside every framework subtree (fail-safe)', () => {
|
|
const del = planPrune({
|
|
manifest: m,
|
|
targetPaths: ['harvester/sop.md', 'my-fleet.yaml', 'unknown-dir/deep/x'],
|
|
sourcePaths: [],
|
|
});
|
|
expect(del).toEqual([]);
|
|
});
|
|
|
|
it('never prunes single-file framework entries (reconcile-managed, not in subtree)', () => {
|
|
const del = planPrune({ manifest: m, targetPaths: ['CONSTITUTION.md'], sourcePaths: [] });
|
|
expect(del).toEqual([]);
|
|
});
|
|
|
|
it('property: delete-set ⊆ {framework-owned ∧ in-target ∧ not-in-source} and ∩ operator = ∅', () => {
|
|
const operatorish = [
|
|
'agents/a.conf',
|
|
'policy/p.md',
|
|
'SOUL.local.md',
|
|
'memory/m.md',
|
|
'tools/_lib/credentials.json',
|
|
'harvester/sop.md',
|
|
'unknown-top/x',
|
|
'another-unknown/deep/y.txt',
|
|
];
|
|
const frameworkish = ['guides/A.md', 'guides/sub/B.md', 'tools/git/x.sh'];
|
|
const targetPaths = [...operatorish, ...frameworkish];
|
|
const del = planPrune({ manifest: m, targetPaths, sourcePaths: [] });
|
|
|
|
for (const p of del) {
|
|
expect(resolveOwnership(m, p)).toBe('framework');
|
|
expect(targetPaths).toContain(p);
|
|
}
|
|
// No operator/unknown path ever appears in the delete-set.
|
|
for (const p of operatorish) expect(del).not.toContain(p);
|
|
});
|
|
});
|
|
|
|
// The #797 Runtime Session Ledger lives at fleet/run/sessions/. Today it is safe
|
|
// twice over: it matches the explicit `fleet/run/**` operator carve-out AND, even
|
|
// without it, the UNKNOWN→operator fail-safe. This test isolates the CARVE-OUT's
|
|
// load-bearing value by simulating a future framework author who broadens fleet
|
|
// ownership to `fleet/**`: without the operator carve-out the ledger would resolve
|
|
// framework and be pruned; deny-wins is what keeps it protected. If deleting the
|
|
// `fleet/run/**` line ever stops turning this test red, the carve-out has silently
|
|
// stopped mattering — which is exactly the #797 regression we are gating against.
|
|
describe('fleet/run/** carve-out is load-bearing for the #797 ledger (deny-wins)', () => {
|
|
const LEDGER = ['fleet/run/sessions/events.ndjson', 'fleet/run/sessions/ledger.json'];
|
|
// A framework that (hypothetically) ships all of fleet/** as a subtree.
|
|
const withoutCarveOut: FrameworkManifest = {
|
|
framework: ['fleet/**'],
|
|
operator: [],
|
|
};
|
|
const withCarveOut: FrameworkManifest = {
|
|
framework: ['fleet/**'],
|
|
operator: ['fleet/run/**'],
|
|
};
|
|
|
|
it('RED without the carve-out: the ledger resolves framework and is pruned', () => {
|
|
for (const p of LEDGER) expect(resolveOwnership(withoutCarveOut, p)).toBe('framework');
|
|
const del = planPrune({ manifest: withoutCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
|
expect(del.sort()).toEqual([...LEDGER].sort());
|
|
});
|
|
|
|
it('GREEN with the carve-out: deny-wins makes the ledger operator and unprunable', () => {
|
|
for (const p of LEDGER) expect(resolveOwnership(withCarveOut, p)).toBe('operator');
|
|
const del = planPrune({ manifest: withCarveOut, targetPaths: LEDGER, sourcePaths: [] });
|
|
expect(del).toEqual([]);
|
|
});
|
|
|
|
it('the SHIPPED manifest reserves fleet/run/** so the ledger is operator-owned', () => {
|
|
const shipped = loadManifest(FRAMEWORK_ROOT);
|
|
for (const p of LEDGER) expect(resolveOwnership(shipped, p)).toBe('operator');
|
|
// And it is structurally unreachable by pruning even if it were in a subtree.
|
|
expect(planPrune({ manifest: shipped, targetPaths: LEDGER, sourcePaths: [] })).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('frameworkSubtreeRoots', () => {
|
|
it('returns only the /** subtree roots, not single-file entries', () => {
|
|
const m = parseManifest(SAMPLE);
|
|
expect(frameworkSubtreeRoots(m)).toEqual(['guides', 'tools']);
|
|
});
|
|
});
|
|
|
|
// ── SSOT manifest: shipped-file completeness (§6.2) ──────────────────────────
|
|
// A newly-shipped framework file must not silently fall outside the manifest —
|
|
// if it did, the updater could neither guarantee it as framework-owned nor
|
|
// prune it when retired. Every file the framework actually ships must resolve
|
|
// to `framework` (except the defaults/{SOUL,USER}.md identity seeds, which are
|
|
// operator-owned by design).
|
|
describe('manifest completeness against shipped framework tree', () => {
|
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
|
|
|
const IGNORED_TOP = new Set(['.git', 'node_modules']);
|
|
// Framework-shipped files that are operator-owned by design: the identity
|
|
// seeds under defaults/, and the `.gitkeep` placeholder that lets the empty
|
|
// operator-owned memory/ directory exist in git.
|
|
function isOperatorShipped(rel: string): boolean {
|
|
if (rel === 'defaults/SOUL.md' || rel === 'defaults/USER.md') return true;
|
|
if (rel.startsWith('memory/')) return true;
|
|
return false;
|
|
}
|
|
|
|
function walk(dir: string): string[] {
|
|
const out: string[] = [];
|
|
for (const entry of readdirSync(dir)) {
|
|
const abs = join(dir, entry);
|
|
const rel = relative(FRAMEWORK_ROOT, abs);
|
|
if (IGNORED_TOP.has(rel)) continue;
|
|
if (statSync(abs).isDirectory()) out.push(...walk(abs));
|
|
else out.push(rel);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
it('every shipped framework file resolves to framework ownership', () => {
|
|
const shipped = walk(FRAMEWORK_ROOT);
|
|
const misclassified = shipped.filter(
|
|
(p) => !isOperatorShipped(p) && resolveOwnership(manifest, p) !== 'framework',
|
|
);
|
|
expect(misclassified).toEqual([]);
|
|
});
|
|
|
|
it('the operator-owned surface from #791 resolves to operator', () => {
|
|
const operatorPaths = [
|
|
'agents/coder0.conf',
|
|
'fleet/agents/coder0.env',
|
|
'memory/note.md',
|
|
'policy/custom.md',
|
|
'SOUL.local.md',
|
|
'USER.local.md',
|
|
'STANDARDS.local.md',
|
|
'tools/_lib/credentials.json',
|
|
'fleet/roster.yaml',
|
|
'fleet/roster.json',
|
|
'fleet/run/coder0.hb',
|
|
'fleet/backlog/data.db',
|
|
'fleet/roles.local/custom.md',
|
|
];
|
|
for (const p of operatorPaths) {
|
|
expect(resolveOwnership(manifest, p), p).toBe('operator');
|
|
}
|
|
});
|
|
});
|