344 lines
13 KiB
TypeScript
344 lines
13 KiB
TypeScript
import { afterAll, describe, it, expect } from 'vitest';
|
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
loadManifest,
|
|
parseManifest,
|
|
resolveOwnership,
|
|
frameworkSubtreeRoots,
|
|
} from './manifest.js';
|
|
|
|
/**
|
|
* Bash ↔ TS parity (#791, §6.1).
|
|
*
|
|
* The installer (bash) and the config adapter (TS) each resolve path ownership
|
|
* from framework-manifest.txt. If the two resolvers disagreed on a single path,
|
|
* an upgrade could protect a file on one code path and wipe it on the other —
|
|
* exactly the two-copies drift that #631 patched by hand. This test drives the
|
|
* bash resolver (`tools/_lib/manifest.sh`) as a subprocess and asserts it agrees
|
|
* with the TS resolver for a broad set of paths spanning every ownership class.
|
|
*/
|
|
|
|
const FRAMEWORK_ROOT = fileURLToPath(new URL('../../framework', import.meta.url));
|
|
const MANIFEST_SH = join(FRAMEWORK_ROOT, 'tools', '_lib', 'manifest.sh');
|
|
|
|
const hasBash = (() => {
|
|
try {
|
|
execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
function bashResolve(relPath: string): string {
|
|
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
|
encoding: 'utf-8',
|
|
}).trim();
|
|
}
|
|
|
|
/** Drive the bash resolver against an arbitrary manifest file (MANIFEST_FILE override). */
|
|
function bashResolveWith(manifestFile: string, relPath: string): string {
|
|
return execFileSync('bash', [MANIFEST_SH, 'resolve', relPath], {
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
|
}).trim();
|
|
}
|
|
|
|
function bashSubtreeRoots(): string[] {
|
|
return execFileSync('bash', [MANIFEST_SH, 'subtree-roots'], { encoding: 'utf-8' })
|
|
.split('\n')
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
}
|
|
|
|
/**
|
|
* Drive the bash resolver CLI against a manifest file and report how it exited.
|
|
* A fail-closed manifest must make the CLI exit non-zero with a message on
|
|
* stderr — never exit 0 having silently resolved everything to operator.
|
|
*/
|
|
function bashCli(manifestFile: string): { status: number; stderr: string } {
|
|
const res = spawnSync('bash', [MANIFEST_SH, 'resolve', 'CONSTITUTION.md'], {
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, MANIFEST_FILE: manifestFile },
|
|
});
|
|
return { status: res.status ?? -1, stderr: res.stderr ?? '' };
|
|
}
|
|
|
|
// Paths spanning every ownership class: framework single-files, framework
|
|
// subtrees, operator declared trees, operator carve-out inside a framework
|
|
// subtree, local overlays, and deliberately UNANTICIPATED paths (fail-safe).
|
|
const PROBE_PATHS = [
|
|
'CONSTITUTION.md',
|
|
'AGENTS.md',
|
|
'STANDARDS.md',
|
|
'install.sh',
|
|
'framework-manifest.txt',
|
|
'guides/E2E-DELIVERY.md',
|
|
'tools/git/pr-create.sh',
|
|
'tools/_lib/manifest.sh',
|
|
'defaults/SOUL.md',
|
|
'fleet/README.md',
|
|
'fleet/roles/coder.md',
|
|
'fleet/roster.schema.json',
|
|
'fleet/examples/general.yaml',
|
|
// operator
|
|
'SOUL.md',
|
|
'USER.md',
|
|
'TOOLS.md',
|
|
'SOUL.local.md',
|
|
'USER.local.md',
|
|
'STANDARDS.local.md',
|
|
'agents/coder0.conf',
|
|
'policy/custom.md',
|
|
'memory/note.md',
|
|
'sources/skills/x.md',
|
|
'credentials/c.json',
|
|
'tools/_lib/credentials.json',
|
|
'fleet/roster.yaml',
|
|
'fleet/roster.json',
|
|
'fleet/agents/coder0.env',
|
|
'fleet/run/coder0.hb',
|
|
// #797 Runtime Session Ledger — must resolve operator on both paths.
|
|
'fleet/run/sessions/events.ndjson',
|
|
'fleet/run/sessions/ledger.json',
|
|
'fleet/backlog/data.db',
|
|
'fleet/roles.local/custom.md',
|
|
// unanticipated → operator (fail-safe)
|
|
'harvester/sop.md',
|
|
'unknown-operator-dir/x',
|
|
'fleet/my-fleet.yaml',
|
|
'random-root-file.md',
|
|
'tools/some-new-framework-tool.sh',
|
|
];
|
|
|
|
describe.skipIf(!hasBash)('bash ↔ TS manifest parity (§6.1)', () => {
|
|
it('the bash resolver CLI exists and is executable', () => {
|
|
expect(existsSync(MANIFEST_SH)).toBe(true);
|
|
});
|
|
|
|
it('bash and TS resolve identical ownership for every probe path', () => {
|
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
|
const disagreements: Array<{ path: string; ts: string; bash: string }> = [];
|
|
for (const p of PROBE_PATHS) {
|
|
const ts = resolveOwnership(manifest, p);
|
|
const bash = bashResolve(p);
|
|
if (ts !== bash) disagreements.push({ path: p, ts, bash });
|
|
}
|
|
expect(disagreements).toEqual([]);
|
|
});
|
|
|
|
it('bash and TS agree on the framework subtree roots', () => {
|
|
const manifest = loadManifest(FRAMEWORK_ROOT);
|
|
expect(bashSubtreeRoots().sort()).toEqual(frameworkSubtreeRoots(manifest).sort());
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Format-safety parity (#791, Decision 1 — the `.txt` line-oriented format is
|
|
* accepted only because both resolvers agree on the format edge cases a hand-
|
|
* edited text file invites: comments, blank lines, stray whitespace, duplicate
|
|
* and overlapping globs (where deny-wins must resolve), and section ordering.
|
|
* Each fixture is driven through BOTH resolvers (bash via MANIFEST_FILE, TS via
|
|
* parseManifest) and must agree AND land on the expected ownership. Any
|
|
* divergence here means the format itself is unsafe and must be fixed/converted.
|
|
*/
|
|
describe.skipIf(!hasBash)('bash ↔ TS manifest format-edge parity (§6.1, Decision 1)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'mf-parity-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
let fixtureSeq = 0;
|
|
function writeFixture(text: string): string {
|
|
const file = join(tmp, `manifest-${fixtureSeq++}.txt`);
|
|
writeFileSync(file, text);
|
|
return file;
|
|
}
|
|
|
|
// Both resolvers must agree, and on the expected value, for every probe.
|
|
function expectParity(text: string, cases: ReadonlyArray<readonly [string, string]>): void {
|
|
const file = writeFixture(text);
|
|
const manifest = parseManifest(text);
|
|
for (const [path, expected] of cases) {
|
|
const ts = resolveOwnership(manifest, path);
|
|
const bash = bashResolveWith(file, path);
|
|
expect(bash, `bash disagrees with TS on ${path}`).toBe(ts);
|
|
expect(ts, `ownership of ${path}`).toBe(expected);
|
|
}
|
|
}
|
|
|
|
it('tolerates comments, blank lines, and leading/trailing whitespace identically', () => {
|
|
// Entries and headers are padded with spaces/tabs; comments and blanks are
|
|
// interleaved. Both resolvers must trim and ignore them the same way.
|
|
const text = [
|
|
'# leading comment',
|
|
' ',
|
|
'\t[framework] ',
|
|
' tools/** ',
|
|
'# mid-section comment',
|
|
'',
|
|
'\tguides/**\t',
|
|
' [operator] ',
|
|
'\ttools/_lib/credentials.json ',
|
|
'*.local.md',
|
|
'',
|
|
].join('\n');
|
|
expectParity(text, [
|
|
['tools/git/pr-create.sh', 'framework'],
|
|
['guides/E2E-DELIVERY.md', 'framework'],
|
|
['tools/_lib/credentials.json', 'operator'], // deny-wins carve-out inside tools/**
|
|
['SOUL.local.md', 'operator'],
|
|
['nowhere/unknown.md', 'operator'], // negative probe: matches NO rule → operator
|
|
]);
|
|
});
|
|
|
|
it('resolves deny-wins for overlapping and duplicate globs identically', () => {
|
|
// Framework claims tools/** (twice) and the overlapping tools/git/**;
|
|
// operator carves out tools/_lib/**. Operator must win the overlap on both.
|
|
const text = [
|
|
'[framework]',
|
|
'tools/**',
|
|
'tools/**', // duplicate — must not change resolution
|
|
'tools/git/**', // overlaps tools/**
|
|
'[operator]',
|
|
'tools/_lib/**',
|
|
].join('\n');
|
|
expectParity(text, [
|
|
['tools/git/pr-create.sh', 'framework'],
|
|
['tools/other.sh', 'framework'],
|
|
['tools/_lib/credentials.json', 'operator'], // deny-wins over both framework globs
|
|
['tools/_lib/nested/deep.json', 'operator'],
|
|
]);
|
|
});
|
|
|
|
it('is independent of section and glob ordering', () => {
|
|
// Same rule set, operator section first and entries reordered. Resolution
|
|
// must be identical because deny-wins checks all operator globs before any
|
|
// framework glob — order within or between sections cannot matter.
|
|
const forward = [
|
|
'[framework]',
|
|
'guides/**',
|
|
'tools/**',
|
|
'[operator]',
|
|
'tools/_lib/credentials.json',
|
|
'*.local.md',
|
|
].join('\n');
|
|
const reversed = [
|
|
'[operator]',
|
|
'*.local.md',
|
|
'tools/_lib/credentials.json',
|
|
'[framework]',
|
|
'tools/**',
|
|
'guides/**',
|
|
].join('\n');
|
|
const probes: ReadonlyArray<readonly [string, string]> = [
|
|
['tools/git/pr-create.sh', 'framework'],
|
|
['guides/E2E-DELIVERY.md', 'framework'],
|
|
['tools/_lib/credentials.json', 'operator'],
|
|
['SOUL.local.md', 'operator'],
|
|
['unanticipated/path.md', 'operator'],
|
|
];
|
|
expectParity(forward, probes);
|
|
expectParity(reversed, probes);
|
|
// And the two orderings agree path-for-path on both resolvers.
|
|
const fFile = writeFixture(forward);
|
|
const rFile = writeFixture(reversed);
|
|
for (const [path] of probes) {
|
|
expect(bashResolveWith(fFile, path)).toBe(bashResolveWith(rFile, path));
|
|
}
|
|
});
|
|
|
|
it('defaults an unmatched path to operator on both resolvers (UNKNOWN → operator)', () => {
|
|
// A manifest that names only a narrow framework slice. Everything else —
|
|
// including paths under no rule at all — must fail safe to operator.
|
|
const text = ['[framework]', 'guides/**', '[operator]', 'agents/**'].join('\n');
|
|
expectParity(text, [
|
|
['guides/x.md', 'framework'],
|
|
['agents/coder0.conf', 'operator'],
|
|
['totally/unlisted/file.txt', 'operator'], // negative probe
|
|
['fleet/run/sessions/ledger.json', 'operator'], // unlisted → operator
|
|
['README.md', 'operator'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Failure-mode parity (#791 B2/B3). A bad manifest is the dangerous case: if the
|
|
* two resolvers DISAGREED on rejection — one throwing while the other quietly
|
|
* resolved everything to operator — an upgrade could fail loud on one code path
|
|
* and no-op on the other. So for every malformed/empty/missing manifest, BOTH
|
|
* must reject: TS throws, and the bash CLI exits non-zero with a stderr message.
|
|
*/
|
|
describe.skipIf(!hasBash)('bash ↔ TS manifest failure-mode parity (§6.1, B2/B3)', () => {
|
|
const tmp = mkdtempSync(join(tmpdir(), 'mf-failmode-'));
|
|
afterAll(() => rmSync(tmp, { recursive: true, force: true }));
|
|
|
|
let seq = 0;
|
|
function writeFixture(text: string): string {
|
|
const file = join(tmp, `bad-manifest-${seq++}.txt`);
|
|
writeFileSync(file, text);
|
|
return file;
|
|
}
|
|
|
|
// TS throws AND bash CLI exits non-zero with a non-empty stderr — identical rejection.
|
|
function expectBothReject(label: string, manifestFile: string): void {
|
|
expect(() => parseManifestFile(manifestFile), `TS accepted ${label}`).toThrow();
|
|
const cli = bashCli(manifestFile);
|
|
expect(cli.status, `bash did not exit non-zero for ${label}`).not.toBe(0);
|
|
expect(cli.stderr.trim().length, `bash was silent for ${label}`).toBeGreaterThan(0);
|
|
}
|
|
|
|
// Read the file for the TS side the same way loadManifest does, so both halves
|
|
// see identical bytes (loadManifest keys off a directory, not an arbitrary file).
|
|
function parseManifestFile(file: string): void {
|
|
parseManifest(readFileSync(file, 'utf-8'));
|
|
}
|
|
|
|
it('both reject a completely empty manifest', () => {
|
|
expectBothReject('empty', writeFixture(''));
|
|
});
|
|
|
|
it('both reject a comment/blank-only manifest', () => {
|
|
expectBothReject('comment-only', writeFixture('# header only\n\n \n'));
|
|
});
|
|
|
|
it('both reject an operator-only manifest (zero framework paths)', () => {
|
|
expectBothReject('operator-only', writeFixture('[operator]\nSOUL.md\n*.local.md\n'));
|
|
});
|
|
|
|
it('both reject a [framework] section with no entries', () => {
|
|
expectBothReject('empty-framework-section', writeFixture('[framework]\n[operator]\nSOUL.md\n'));
|
|
});
|
|
|
|
it('both reject a [framework] entry that normalizes to an empty glob (/)', () => {
|
|
expectBothReject('root-slash-framework', writeFixture('[framework]\n/\n'));
|
|
});
|
|
|
|
it('both reject a [framework] entry that normalizes to nothing (./)', () => {
|
|
expectBothReject('dot-slash-framework', writeFixture('[framework]\n./\n[operator]\nSOUL.md\n'));
|
|
});
|
|
|
|
it('both reject [framework] entries that are only bare dot segments', () => {
|
|
expectBothReject('bare-dot-framework', writeFixture('[framework]\n.\n..\n'));
|
|
});
|
|
|
|
it('both reject an entry that appears before any section header', () => {
|
|
expectBothReject('entry-before-header', writeFixture('stray.md\n[framework]\nguides/**\n'));
|
|
});
|
|
|
|
it('both reject an unknown section header', () => {
|
|
expectBothReject('unknown-header', writeFixture('[bogus]\nx\n'));
|
|
});
|
|
|
|
it('both reject a missing manifest file (fail-closed, not empty result)', () => {
|
|
const missing = join(tmp, 'does-not-exist.txt');
|
|
// TS: loadManifest would throw a read error; here read-then-parse throws on read.
|
|
expect(() => parseManifestFile(missing)).toThrow();
|
|
const cli = bashCli(missing);
|
|
expect(cli.status).not.toBe(0);
|
|
expect(cli.stderr.trim().length).toBeGreaterThan(0);
|
|
});
|
|
});
|