agentic-workflow-manager 9.7.1 → 9.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/src/commands/job/exec-wrapper.js +53 -3
- package/dist/src/commands/job/gate.js +18 -0
- package/dist/src/commands/job/index.js +61 -0
- package/dist/src/commands/model-policy/index.js +73 -0
- package/dist/src/commands/plan/index.js +182 -7
- package/dist/src/commands/sensors/bootstrap.js +36 -5
- package/dist/src/commands/sensors/compatibility/contract.js +19 -4
- package/dist/src/commands/sensors/compatibility/discovery.js +23 -3
- package/dist/src/commands/sensors/compatibility/live.js +18 -1
- package/dist/src/commands/sensors/compatibility/probe.js +8 -2
- package/dist/src/commands/sensors/compatibility/resolve.js +5 -2
- package/dist/src/commands/sensors/coverage/evidence.js +19 -10
- package/dist/src/commands/sensors/coverage/index.js +11 -2
- package/dist/src/commands/sensors/coverage/resolve.js +10 -0
- package/dist/src/commands/sensors/exec.js +16 -1
- package/dist/src/commands/sensors/migrate.js +62 -2
- package/dist/src/commands/sensors/prepare.js +2 -0
- package/dist/src/commands/sensors/result.js +2 -0
- package/dist/src/commands/sensors/run.js +64 -1
- package/dist/src/commands/sensors/status.js +6 -4
- package/dist/src/commands/watch/apply.js +32 -0
- package/dist/src/commands/watch/archive-unused.js +138 -0
- package/dist/src/commands/watch/generations.js +32 -9
- package/dist/src/commands/watch/index.js +108 -2
- package/dist/src/commands/watch/init.js +149 -18
- package/dist/src/commands/watch/supervisor.js +167 -13
- package/dist/src/commands/watch/teardown-driver.js +13 -13
- package/dist/src/commands/watch/track-state.js +59 -0
- package/dist/src/commands/watch/tracks.js +38 -102
- package/dist/src/core/admission/index.js +231 -0
- package/dist/src/core/admission/registry-contracts.js +290 -0
- package/dist/src/core/journal/fingerprint.js +35 -1
- package/dist/src/core/journal/paths.js +11 -0
- package/dist/src/core/journal/requests.js +1 -1
- package/dist/src/core/journal/store.js +146 -4
- package/dist/src/core/journal/types.js +57 -2
- package/dist/src/core/migration/historical-provenance.js +18 -0
- package/dist/src/core/migration/index.js +322 -0
- package/dist/src/core/model-policy/canonical.js +18 -0
- package/dist/src/core/model-policy/capabilities.js +197 -0
- package/dist/src/core/model-policy/capability-types.js +2 -0
- package/dist/src/core/model-policy/journal.js +124 -0
- package/dist/src/core/model-policy/paths.js +12 -0
- package/dist/src/core/model-policy/resolve.js +116 -0
- package/dist/src/core/model-policy/store.js +182 -0
- package/dist/src/core/model-policy/types.js +2 -0
- package/dist/src/core/model-policy/validate.js +112 -0
- package/dist/src/core/plan/identity.js +77 -0
- package/dist/src/core/plan/validate.js +191 -78
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/job/exec-wrapper.test.js +16 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +40 -10
- package/dist/tests/commands/job/gate.test.js +9 -0
- package/dist/tests/commands/job/routing-report-cli.test.js +46 -0
- package/dist/tests/commands/job/routing.test.js +11 -0
- package/dist/tests/commands/model-policy/index.test.js +57 -0
- package/dist/tests/commands/pin.test.js +23 -23
- package/dist/tests/commands/plan/index.test.js +682 -14
- package/dist/tests/commands/sensors/bootstrap.test.js +57 -4
- package/dist/tests/commands/sensors/compatibility/contract.test.js +21 -0
- package/dist/tests/commands/sensors/compatibility/discovery.test.js +19 -0
- package/dist/tests/commands/sensors/compatibility/probe.test.js +14 -1
- package/dist/tests/commands/sensors/compatibility/resolve.test.js +17 -0
- package/dist/tests/commands/sensors/coverage/evidence.test.js +51 -27
- package/dist/tests/commands/sensors/coverage/resolve.test.js +51 -0
- package/dist/tests/commands/sensors/exec.test.js +112 -0
- package/dist/tests/commands/sensors/run-changed.test.js +11 -0
- package/dist/tests/commands/sensors/run.test.js +15 -2
- package/dist/tests/commands/sensors/status.test.js +3 -3
- package/dist/tests/commands/watch/apply.test.js +12 -0
- package/dist/tests/commands/watch/e2e-crash.test.js +126 -11
- package/dist/tests/commands/watch/generations.test.js +113 -4
- package/dist/tests/commands/watch/plan-lifecycle.test.js +263 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +247 -49
- package/dist/tests/commands/watch/track-finalize.test.js +20 -3
- package/dist/tests/commands/watch/track-freeze.test.js +43 -30
- package/dist/tests/commands/watch/track-state-dependency.test.js +15 -0
- package/dist/tests/commands/watch/watch-init.test.js +244 -0
- package/dist/tests/core/admission/index.test.js +140 -0
- package/dist/tests/core/journal/fingerprint.test.js +22 -0
- package/dist/tests/core/journal/store.test.js +53 -0
- package/dist/tests/core/journal/types.test.js +7 -0
- package/dist/tests/core/migration/facts.test.js +289 -0
- package/dist/tests/core/migration/historical-provenance.test.js +25 -0
- package/dist/tests/core/model-policy/capabilities.test.js +365 -0
- package/dist/tests/core/model-policy/journal.test.js +46 -0
- package/dist/tests/core/model-policy/policy.test.js +47 -0
- package/dist/tests/core/model-policy/resolve.test.js +111 -0
- package/dist/tests/core/model-policy/store.test.js +177 -0
- package/dist/tests/core/plan/validate.test.js +360 -8
- package/dist/tests/core/secure-fs/native-bridge.test.js +81 -4
- package/dist/tests/integration/pack-e2e.test.js +4 -1
- package/dist/tests/integration/preflight-json-pipe.e2e.test.js +7 -3
- package/dist/tests/integration/sensor-bootstrap.e2e.test.js +37 -0
- package/dist/tests/integration/sensor-structured-run.e2e.test.js +12 -10
- package/dist/tests/providers/injection-config.test.js +14 -6
- package/dist/tests/structural/directory-fsync-is-single-source.test.js +97 -0
- package/dist/tests/structural/jest-environment-is-isolated.test.js +29 -24
- package/dist/tests/structural/native-release-artifacts.test.js +30 -0
- package/package.json +4 -3
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/linux-arm64/secure_fs.node +0 -0
- package/prebuilds/linux-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const atomic_file_1 = require("../../../src/core/atomic-file");
|
|
10
|
+
const canonical_1 = require("../../../src/core/model-policy/canonical");
|
|
11
|
+
const store_1 = require("../../../src/core/model-policy/store");
|
|
12
|
+
const paths_1 = require("../../../src/core/model-policy/paths");
|
|
13
|
+
const policy = () => ({ schema: 'model-policy/v1', mappings: [{ target: 'codex', runtimeKind: 'native', profiles: {
|
|
14
|
+
mechanical: { selector: { kind: 'model', id: 'gpt-5.6-luna' }, effort: { kind: 'explicit', value: 'medium' } },
|
|
15
|
+
integration: { selector: { kind: 'model', id: 'gpt-5.6-terra' }, effort: { kind: 'explicit', value: 'medium' } },
|
|
16
|
+
judgment: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'medium' } },
|
|
17
|
+
}, fullCapability: { selector: { kind: 'model', id: 'gpt-5.6-sol' }, effort: { kind: 'explicit', value: 'high' } }, degradation: { allowMissingModelOverride: false, allowMissingEffortOverride: false, allowMissingObservedIdentity: false } }], implementationBudget: { maxAttempts: 3, escalation: ['mechanical', 'integration', 'judgment'], judgmentEfforts: ['medium', 'high'] } });
|
|
18
|
+
function createIdentityFencedBoundary() {
|
|
19
|
+
const observations = new WeakMap();
|
|
20
|
+
return {
|
|
21
|
+
withProjectLease: (_root, operation) => operation(),
|
|
22
|
+
readRegularFile: (file) => {
|
|
23
|
+
const identity = Object.freeze({});
|
|
24
|
+
const bytes = fs_1.default.readFileSync(file);
|
|
25
|
+
observations.set(identity, { file, bytes });
|
|
26
|
+
return { bytes, identity };
|
|
27
|
+
},
|
|
28
|
+
writeProjectTransaction: (root, destination, content, options) => {
|
|
29
|
+
const file = path_1.default.join(root, destination);
|
|
30
|
+
fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
|
|
31
|
+
if (options.mode === 'create' && fs_1.default.existsSync(file))
|
|
32
|
+
throw new Error('project destination already exists');
|
|
33
|
+
if (options.mode === 'replace') {
|
|
34
|
+
const observed = observations.get(options.expectedIdentity);
|
|
35
|
+
if (!observed || observed.file !== file || !observed.bytes.equals(options.expected) || !fs_1.default.readFileSync(file).equals(options.expected))
|
|
36
|
+
throw new Error('original changed before fenced replacement');
|
|
37
|
+
}
|
|
38
|
+
const temporary = `${file}.test-tmp`;
|
|
39
|
+
const fd = fs_1.default.openSync(temporary, 'wx', 0o600);
|
|
40
|
+
try {
|
|
41
|
+
fs_1.default.writeFileSync(fd, content);
|
|
42
|
+
fs_1.default.fsyncSync(fd);
|
|
43
|
+
fs_1.default.closeSync(fd);
|
|
44
|
+
fs_1.default.renameSync(temporary, file);
|
|
45
|
+
// La durabilidad de la ENTRADA publicada se pide por el unico helper
|
|
46
|
+
// que define esa politica (atomic-file.fsyncDirSync): fail-closed en
|
|
47
|
+
// POSIX y degradado SOLO ante el gap conocido de win32 (EPERM al
|
|
48
|
+
// fsync-ear un fd de directorio, capacidad que el SO no expone).
|
|
49
|
+
// Reimplementarla aca la hacia divergir y rompia la matriz Windows.
|
|
50
|
+
(0, atomic_file_1.fsyncDirSync)(path_1.default.dirname(file));
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
try {
|
|
54
|
+
fs_1.default.rmSync(temporary, { force: true });
|
|
55
|
+
}
|
|
56
|
+
catch { /* preserve transaction result */ }
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const identityFencedBoundary = createIdentityFencedBoundary();
|
|
62
|
+
const approve = (input) => (0, store_1.approvePolicyWithBoundary)(input, identityFencedBoundary);
|
|
63
|
+
describe('approved policy store', () => {
|
|
64
|
+
let root;
|
|
65
|
+
let oldHome;
|
|
66
|
+
let oldAwmHome;
|
|
67
|
+
beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-policy-')); oldHome = process.env.HOME; oldAwmHome = process.env.AWM_HOME; process.env.AWM_HOME = path_1.default.join(root, 'operator'); });
|
|
68
|
+
afterEach(() => { jest.restoreAllMocks(); delete process.env.POLICY_TEST_FAIL_WRITE; delete process.env.POLICY_TEST_MUTATE_DURING_REPLACE; if (oldHome === undefined)
|
|
69
|
+
delete process.env.HOME;
|
|
70
|
+
else
|
|
71
|
+
process.env.HOME = oldHome; if (oldAwmHome === undefined)
|
|
72
|
+
delete process.env.AWM_HOME;
|
|
73
|
+
else
|
|
74
|
+
process.env.AWM_HOME = oldAwmHome; fs_1.default.rmSync(root, { recursive: true, force: true }); });
|
|
75
|
+
const candidate = () => { const file = path_1.default.join(root, 'candidate.json'); fs_1.default.writeFileSync(file, JSON.stringify(policy())); return file; };
|
|
76
|
+
it('publishes only an expected digest and reads it without creating state', () => {
|
|
77
|
+
const file = candidate();
|
|
78
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
79
|
+
expect((0, store_1.readEffectivePolicy)(root)).toEqual({ state: 'absent' });
|
|
80
|
+
const approved = approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
|
|
81
|
+
expect(approved.contentDigest).toBe(digest);
|
|
82
|
+
expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'approved', provenance: 'user', policy: { contentDigest: digest } });
|
|
83
|
+
});
|
|
84
|
+
it('rejects a mismatched digest before writing', () => {
|
|
85
|
+
const file = candidate();
|
|
86
|
+
expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: '0'.repeat(64) })).toThrow(/digest/i);
|
|
87
|
+
expect(fs_1.default.existsSync((0, paths_1.userPolicyPath)())).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
it('allows exactly one initial writer and exact CAS replacement', () => {
|
|
90
|
+
const file = candidate();
|
|
91
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
92
|
+
approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
|
|
93
|
+
expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/replace|existing/i);
|
|
94
|
+
expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: 'f'.repeat(64) })).toThrow(/predecessor|replace/i);
|
|
95
|
+
expect(approve({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }).lineage.previousDigest).toBe(digest);
|
|
96
|
+
});
|
|
97
|
+
it('rejects an unexpected opaque identity token before replacing bytes', () => {
|
|
98
|
+
const target = path_1.default.join(root, 'identity.json');
|
|
99
|
+
fs_1.default.writeFileSync(target, 'observed');
|
|
100
|
+
const observed = identityFencedBoundary.readRegularFile(target, 1024);
|
|
101
|
+
expect(() => identityFencedBoundary.writeProjectTransaction(root, 'identity.json', Buffer.from('replacement'), {
|
|
102
|
+
mode: 'replace', createParents: false, expected: observed.bytes, expectedIdentity: Object.freeze({}),
|
|
103
|
+
})).toThrow(/original changed/i);
|
|
104
|
+
expect(fs_1.default.readFileSync(target, 'utf8')).toBe('observed');
|
|
105
|
+
});
|
|
106
|
+
it('resolves a relative candidate through cwd instead of process cwd', () => {
|
|
107
|
+
const cwd = path_1.default.join(root, 'project');
|
|
108
|
+
fs_1.default.mkdirSync(cwd);
|
|
109
|
+
const file = path_1.default.join(cwd, 'candidate.json');
|
|
110
|
+
fs_1.default.writeFileSync(file, JSON.stringify(policy()));
|
|
111
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
112
|
+
expect(approve({ file: 'candidate.json', scope: 'project', cwd, expectedDigest: digest }).contentDigest).toBe(digest);
|
|
113
|
+
});
|
|
114
|
+
it('accepts exactly one of two competing fenced replacements', () => {
|
|
115
|
+
const file = candidate();
|
|
116
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
117
|
+
approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
|
|
118
|
+
const input = { file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest };
|
|
119
|
+
let entered = false;
|
|
120
|
+
let accepted = 0;
|
|
121
|
+
const boundary = {
|
|
122
|
+
withProjectLease: (_root, operation) => operation(),
|
|
123
|
+
readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
|
|
124
|
+
writeProjectTransaction: (base, destination, bytes, options) => {
|
|
125
|
+
const target = path_1.default.join(base, destination);
|
|
126
|
+
if (options.mode === 'replace' && !entered) {
|
|
127
|
+
entered = true;
|
|
128
|
+
// Model a non-cooperating external process: the native
|
|
129
|
+
// identity fence, not this advisory lock, must reject us.
|
|
130
|
+
fs_1.default.rmSync(`${target}.lock`);
|
|
131
|
+
(0, store_1.approvePolicyWithBoundary)(input, boundary);
|
|
132
|
+
fs_1.default.writeFileSync(`${target}.lock`, 'outer lock placeholder');
|
|
133
|
+
}
|
|
134
|
+
if (options.mode === 'replace' && (!options.expected || !fs_1.default.readFileSync(target).equals(options.expected)))
|
|
135
|
+
throw new Error('original changed before fenced replacement');
|
|
136
|
+
fs_1.default.mkdirSync(path_1.default.dirname(target), { recursive: true });
|
|
137
|
+
fs_1.default.writeFileSync(target, bytes);
|
|
138
|
+
accepted++;
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
expect(() => (0, store_1.approvePolicyWithBoundary)(input, boundary)).toThrow(/changed|replacement/i);
|
|
142
|
+
expect(accepted).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
it('returns a failed publication and preserves predecessor bytes on injected transaction failure', () => {
|
|
145
|
+
const file = candidate();
|
|
146
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
147
|
+
approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
|
|
148
|
+
const before = fs_1.default.readFileSync((0, paths_1.userPolicyPath)());
|
|
149
|
+
const boundary = {
|
|
150
|
+
withProjectLease: (_root, operation) => operation(),
|
|
151
|
+
readRegularFile: (target) => ({ bytes: fs_1.default.readFileSync(target), identity: Object.freeze({}) }),
|
|
152
|
+
writeProjectTransaction: () => { throw new Error('injected durable publication failure'); },
|
|
153
|
+
};
|
|
154
|
+
expect(() => (0, store_1.approvePolicyWithBoundary)({ file, scope: 'user', cwd: root, expectedDigest: digest, replaceDigest: digest }, boundary)).toThrow(/publication failure/);
|
|
155
|
+
expect(fs_1.default.readFileSync((0, paths_1.userPolicyPath)()).equals(before)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
it('blocks an invalid project policy instead of falling back to a valid user policy', () => {
|
|
158
|
+
const file = candidate();
|
|
159
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
160
|
+
approve({ file, scope: 'user', cwd: root, expectedDigest: digest });
|
|
161
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '.awm'));
|
|
162
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '.awm', 'model-policy.json'), '{"content":');
|
|
163
|
+
expect((0, store_1.readEffectivePolicy)(root)).toMatchObject({ state: 'invalid', provenance: 'project' });
|
|
164
|
+
});
|
|
165
|
+
it('rejects duplicate JSON keys and symlinked parent or destination without touching the target', () => {
|
|
166
|
+
const duplicate = path_1.default.join(root, 'duplicate.json');
|
|
167
|
+
fs_1.default.writeFileSync(duplicate, '{"schema":"model-policy/v1","schema":"model-policy/v1"}');
|
|
168
|
+
expect(() => approve({ file: duplicate, scope: 'user', cwd: root, expectedDigest: 'a'.repeat(64) })).toThrow(/duplicate/i);
|
|
169
|
+
const file = candidate();
|
|
170
|
+
const digest = (0, canonical_1.canonicalPolicyDigest)(policy());
|
|
171
|
+
fs_1.default.mkdirSync(process.env.AWM_HOME);
|
|
172
|
+
fs_1.default.symlinkSync(root, path_1.default.join(process.env.AWM_HOME, 'link'));
|
|
173
|
+
const target = (0, paths_1.userPolicyPath)();
|
|
174
|
+
fs_1.default.symlinkSync(path_1.default.join(root, 'outside'), target);
|
|
175
|
+
expect(() => approve({ file, scope: 'user', cwd: root, expectedDigest: digest })).toThrow(/symlink/i);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
7
7
|
const child_process_1 = require("child_process");
|
|
8
8
|
const os_1 = __importDefault(require("os"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
10
11
|
const json_1 = require("../../../src/core/plan/json");
|
|
11
12
|
const validate_1 = require("../../../src/core/plan/validate");
|
|
12
13
|
jest.mock('child_process', () => ({ exec: jest.fn(), execFileSync: jest.fn(), execSync: jest.fn(), spawn: jest.fn(), spawnSync: jest.fn() }));
|
|
@@ -21,6 +22,18 @@ function expectApprovedPlanValid(report) {
|
|
|
21
22
|
}
|
|
22
23
|
expect(report.schema).toBe('compact-slices/v1');
|
|
23
24
|
}
|
|
25
|
+
function assertFrozenObjectGraph(value, location = 'report', visited = new WeakSet()) {
|
|
26
|
+
if (value === null || typeof value !== 'object' || visited.has(value))
|
|
27
|
+
return;
|
|
28
|
+
visited.add(value);
|
|
29
|
+
if (!Object.isFrozen(value))
|
|
30
|
+
throw new Error(`${location} is mutable`);
|
|
31
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
32
|
+
const property = Object.getOwnPropertyDescriptor(value, key);
|
|
33
|
+
if (property && 'value' in property)
|
|
34
|
+
assertFrozenObjectGraph(property.value, `${location}.${String(key)}`, visited);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
24
37
|
function fixture(root, mutate) {
|
|
25
38
|
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'), { recursive: true });
|
|
26
39
|
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'source.md'), '## Source\nKnown fact\n');
|
|
@@ -37,21 +50,307 @@ function fixture(root, mutate) {
|
|
|
37
50
|
fs_1.default.writeFileSync(plan, markdown);
|
|
38
51
|
return plan;
|
|
39
52
|
}
|
|
53
|
+
function v2Fixture(root, profile = 'mechanical') {
|
|
54
|
+
const plan = fixture(root, manifest => {
|
|
55
|
+
manifest.schema = 'compact-slices/v2';
|
|
56
|
+
const slices = manifest.slices;
|
|
57
|
+
slices[0].implementerProfile = profile;
|
|
58
|
+
});
|
|
59
|
+
fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(START, '<!-- AWM:COMPACT-SLICES:START v2 -->').replace(END, '<!-- AWM:COMPACT-SLICES:END v2 -->'));
|
|
60
|
+
return plan;
|
|
61
|
+
}
|
|
40
62
|
describe('validatePlanFile', () => {
|
|
63
|
+
it('validates an authenticated snapshot without reopening a swapped plan parent', () => {
|
|
64
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-'));
|
|
65
|
+
const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-snapshot-outside-'));
|
|
66
|
+
try {
|
|
67
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
68
|
+
const snapshot = '### Task 1: anchored\n';
|
|
69
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plan.md'), snapshot);
|
|
70
|
+
fs_1.default.rmSync(path_1.default.join(root, 'docs'), { recursive: true });
|
|
71
|
+
fs_1.default.writeFileSync(path_1.default.join(outside, 'plan.md'), '<!-- attacker -->');
|
|
72
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs'));
|
|
73
|
+
expect((0, validate_1.validatePlanSnapshot)('docs/plan.md', root, snapshot)).toMatchObject({ state: 'migration-required' });
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
77
|
+
fs_1.default.rmSync(outside, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
41
80
|
let root;
|
|
42
81
|
beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-')); });
|
|
43
82
|
afterEach(() => { fs_1.default.rmSync(root, { recursive: true, force: true }); });
|
|
83
|
+
test.each([
|
|
84
|
+
['valid.md', 'valid'],
|
|
85
|
+
['unmarked.md', 'migration-required'],
|
|
86
|
+
['future-schema.md', 'unsupported'],
|
|
87
|
+
['missing-section.md', 'invalid'],
|
|
88
|
+
])('classifies the versioned v1 corpus member %s as %s without transforms', (file, state) => {
|
|
89
|
+
const corpus = path_1.default.resolve(__dirname, 'fixtures', 'compact-slices-v1');
|
|
90
|
+
expect((0, validate_1.validatePlanFile)(file, corpus).state).toBe(state);
|
|
91
|
+
});
|
|
44
92
|
test('accepts a valid compact plan without modifying it', () => {
|
|
45
93
|
const plan = fixture(root);
|
|
46
94
|
const before = fs_1.default.readFileSync(plan, 'utf8');
|
|
47
95
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
|
|
48
96
|
expect(fs_1.default.readFileSync(plan, 'utf8')).toBe(before);
|
|
49
97
|
});
|
|
98
|
+
test.each(['mechanical', 'integration', 'judgment'])('accepts v2 with the exact %s implementer profile', profile => {
|
|
99
|
+
const report = (0, validate_1.validatePlanFile)(v2Fixture(root, profile), root);
|
|
100
|
+
expect(report).toMatchObject({ state: 'valid', schema: 'compact-slices/v2', manifest: { slices: [expect.objectContaining({ implementerProfile: profile })] } });
|
|
101
|
+
});
|
|
102
|
+
test('rejects v1 profile without weakening exact keys', () => {
|
|
103
|
+
expect((0, validate_1.validatePlanFile)(fixture(root, manifest => { (manifest.slices[0]).implementerProfile = 'mechanical'; }), root)).toMatchObject({ state: 'invalid' });
|
|
104
|
+
});
|
|
105
|
+
test.each([
|
|
106
|
+
['missing profile', (text) => text.replace(/,\n\s*"implementerProfile": "mechanical"/, '')],
|
|
107
|
+
['concrete model', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "mechanical",\n "model": "vendor-model"')],
|
|
108
|
+
['unknown profile', (text) => text.replace('"implementerProfile": "mechanical"', '"implementerProfile": "cheap"')],
|
|
109
|
+
])('rejects v2 %s without weakening exact keys', (_name, mutate) => {
|
|
110
|
+
const plan = v2Fixture(root);
|
|
111
|
+
fs_1.default.writeFileSync(plan, mutate(fs_1.default.readFileSync(plan, 'utf8')));
|
|
112
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid' });
|
|
113
|
+
});
|
|
114
|
+
test('preserves v2 profile bytes in the execution identity', () => {
|
|
115
|
+
const mechanical = fs_1.default.readFileSync(v2Fixture(root, 'mechanical'), 'utf8');
|
|
116
|
+
const integration = mechanical.replace('"implementerProfile": "mechanical"', '"implementerProfile": "integration"');
|
|
117
|
+
const integrationPath = path_1.default.join(root, 'integration.md');
|
|
118
|
+
fs_1.default.writeFileSync(integrationPath, integration);
|
|
119
|
+
const left = (0, validate_1.validatePlanFile)(path_1.default.join(root, 'plan.md'), root);
|
|
120
|
+
const right = (0, validate_1.validatePlanFile)(integrationPath, root);
|
|
121
|
+
expect(left).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
|
|
122
|
+
expect(right).toMatchObject({ state: 'valid', schema: 'compact-slices/v2' });
|
|
123
|
+
if (left.state !== 'valid' || right.state !== 'valid')
|
|
124
|
+
throw new Error('v2 fixture must validate');
|
|
125
|
+
expect(left.executionDigest).not.toBe(right.executionDigest);
|
|
126
|
+
});
|
|
127
|
+
test('makes an approved valid report and its nested manifest immutable after verification', () => {
|
|
128
|
+
const report = (0, validate_1.validatePlanFile)(fixture(root), root);
|
|
129
|
+
expectApprovedPlanValid(report);
|
|
130
|
+
if (report.state !== 'valid')
|
|
131
|
+
throw new Error('expected an approved compact plan');
|
|
132
|
+
const slice = report.manifest.slices[0];
|
|
133
|
+
assertFrozenObjectGraph(report);
|
|
134
|
+
expect(Reflect.set(report.manifest.requirements, '0', 'R4-FORGED')).toBe(false);
|
|
135
|
+
expect(Reflect.set(report.manifest.sources[0], 'fact', 'forged fact')).toBe(false);
|
|
136
|
+
expect(Reflect.set(report.manifest.commands[0].args, '0', 'forged-arg')).toBe(false);
|
|
137
|
+
expect(Reflect.set(slice.fallback, '0', 'forged fallback')).toBe(false);
|
|
138
|
+
expect(Reflect.set(slice.reviewEvidence, '1', 'bogus')).toBe(false);
|
|
139
|
+
expect(Reflect.set(slice, 'risk', 'unbounded')).toBe(false);
|
|
140
|
+
expect(Reflect.set(report.manifest.closureCommands, '0', 'CMD-MISSING')).toBe(false);
|
|
141
|
+
expect(report.manifest.requirements).toEqual(['R4-VAL-2']);
|
|
142
|
+
expect(report.manifest.sources[0].fact).toBe('Known fact');
|
|
143
|
+
expect(report.manifest.commands[0].args).toEqual(['test']);
|
|
144
|
+
expect(slice.fallback).toEqual(['Use a reviewed fallback']);
|
|
145
|
+
expect(slice.reviewEvidence).toEqual(['specification', 'code-quality']);
|
|
146
|
+
expect(slice.risk).toBe('bounded');
|
|
147
|
+
expect(report.manifest.closureCommands).toEqual(['CMD-ONE']);
|
|
148
|
+
});
|
|
149
|
+
test('detects a selectively thawed command-args node even when every ancestor is frozen', () => {
|
|
150
|
+
const report = (0, validate_1.validatePlanFile)(fixture(root), root);
|
|
151
|
+
expectApprovedPlanValid(report);
|
|
152
|
+
const thawed = JSON.parse(JSON.stringify(report));
|
|
153
|
+
const mutableArgs = thawed.manifest.commands[0].args;
|
|
154
|
+
const freezeExceptArgs = (value) => {
|
|
155
|
+
if (value === null || typeof value !== 'object' || value === mutableArgs)
|
|
156
|
+
return;
|
|
157
|
+
for (const child of Object.values(value))
|
|
158
|
+
freezeExceptArgs(child);
|
|
159
|
+
Object.freeze(value);
|
|
160
|
+
};
|
|
161
|
+
freezeExceptArgs(thawed);
|
|
162
|
+
expect(Object.isFrozen(thawed.manifest.commands[0])).toBe(true);
|
|
163
|
+
expect(Object.isFrozen(mutableArgs)).toBe(false);
|
|
164
|
+
expect(Reflect.set(mutableArgs, '0', 'tampered')).toBe(true);
|
|
165
|
+
expect(() => assertFrozenObjectGraph(thawed)).toThrow('report.manifest.commands.0.args is mutable');
|
|
166
|
+
});
|
|
167
|
+
test('scans bounded markerless brace runs without quadratic retries', () => {
|
|
168
|
+
const plan = path_1.default.join(root, 'braces.md');
|
|
169
|
+
fs_1.default.writeFileSync(plan, '{'.repeat(32_000));
|
|
170
|
+
const started = Date.now();
|
|
171
|
+
const report = (0, validate_1.validatePlanFile)(plan, root);
|
|
172
|
+
expect(report).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
|
|
173
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
174
|
+
});
|
|
175
|
+
test('retains markerless unsupported classification for nested JSON in an unclosed outer object', () => {
|
|
176
|
+
const plan = path_1.default.join(root, 'nested-future.md');
|
|
177
|
+
fs_1.default.writeFileSync(plan, '# Notes\n{"outer":\n{"\\u0073chema":"compact\\u002dslices\\/v2"}\n');
|
|
178
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
|
|
179
|
+
});
|
|
180
|
+
test('uses exact bigint filesystem identities for legitimate Windows-sized inode values', () => {
|
|
181
|
+
const plan = fixture(root);
|
|
182
|
+
const nativeLstat = fs_1.default.lstatSync;
|
|
183
|
+
const nativeFstat = fs_1.default.fstatSync;
|
|
184
|
+
const largeIdentity = (observed) => Object.assign(Object.create(observed), {
|
|
185
|
+
ino: typeof observed.ino === 'bigint' ? 9007199254740993n : 9007199254740992,
|
|
186
|
+
});
|
|
187
|
+
const lstat = jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeLstat, fs_1.default, args))));
|
|
188
|
+
const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation(((...args) => largeIdentity(Reflect.apply(nativeFstat, fs_1.default, args))));
|
|
189
|
+
try {
|
|
190
|
+
expectApprovedPlanValid((0, validate_1.validatePlanFile)(plan, root));
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
lstat.mockRestore();
|
|
194
|
+
fstat.mockRestore();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
test('retains the manifest byte limit beneath the bounded plan-file limit', () => {
|
|
198
|
+
const plan = fixture(root);
|
|
199
|
+
const text = fs_1.default.readFileSync(plan, 'utf8');
|
|
200
|
+
fs_1.default.writeFileSync(plan, text.replace(END, `${' '.repeat(256 * 1024)}\n${END}`));
|
|
201
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_LIMIT' })] });
|
|
202
|
+
});
|
|
203
|
+
test.each([
|
|
204
|
+
['plan', 'PLAN_LIMIT'],
|
|
205
|
+
['source', 'PLAN_SOURCE_LIMIT'],
|
|
206
|
+
])('bounds %s bytes when the inspected file grows during descriptor read', (target, code) => {
|
|
207
|
+
const plan = fixture(root);
|
|
208
|
+
const targetPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
|
|
209
|
+
const targetInode = fs_1.default.statSync(targetPath, { bigint: true }).ino;
|
|
210
|
+
const nativeFstat = fs_1.default.fstatSync;
|
|
211
|
+
let grew = false;
|
|
212
|
+
const fstat = jest.spyOn(fs_1.default, 'fstatSync').mockImplementation((descriptor) => {
|
|
213
|
+
const observed = nativeFstat(descriptor, { bigint: true });
|
|
214
|
+
if (!grew && observed.ino === targetInode) {
|
|
215
|
+
fs_1.default.appendFileSync(targetPath, Buffer.alloc(1024 * 1024));
|
|
216
|
+
grew = true;
|
|
217
|
+
}
|
|
218
|
+
return observed;
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
|
|
222
|
+
expect(grew).toBe(true);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
fstat.mockRestore();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
test.each([
|
|
229
|
+
['plan', 'final', 'PLAN_PATH_UNSAFE'],
|
|
230
|
+
['plan', 'ancestor', 'PLAN_PATH_UNSAFE'],
|
|
231
|
+
['source', 'final', 'PLAN_SOURCE_UNSAFE'],
|
|
232
|
+
['source', 'ancestor', 'PLAN_SOURCE_UNSAFE'],
|
|
233
|
+
])('rejects a %s %s symlink swap after inspection without reading outside', (target, swap, code) => {
|
|
234
|
+
let plan = fixture(root);
|
|
235
|
+
const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-plan-race-outside-'));
|
|
236
|
+
const targetDirectory = target === 'plan' ? path_1.default.join(root, 'plans') : path_1.default.join(root, 'docs');
|
|
237
|
+
if (target === 'plan') {
|
|
238
|
+
fs_1.default.mkdirSync(targetDirectory);
|
|
239
|
+
const nestedPlan = path_1.default.join(targetDirectory, 'plan.md');
|
|
240
|
+
fs_1.default.renameSync(plan, nestedPlan);
|
|
241
|
+
plan = nestedPlan;
|
|
242
|
+
}
|
|
243
|
+
const inspectedPath = target === 'plan' ? plan : path_1.default.join(root, 'docs', 'source.md');
|
|
244
|
+
const outsideFile = path_1.default.join(outside, path_1.default.basename(inspectedPath));
|
|
245
|
+
fs_1.default.copyFileSync(inspectedPath, outsideFile);
|
|
246
|
+
const outsideIdentity = fs_1.default.statSync(outsideFile);
|
|
247
|
+
const nativeRealpath = fs_1.default.realpathSync;
|
|
248
|
+
let swapped = false;
|
|
249
|
+
const realpath = jest.spyOn(fs_1.default, 'realpathSync').mockImplementation((file) => {
|
|
250
|
+
const resolved = nativeRealpath(file);
|
|
251
|
+
if (!swapped && file === inspectedPath) {
|
|
252
|
+
swapped = true;
|
|
253
|
+
if (swap === 'final') {
|
|
254
|
+
fs_1.default.renameSync(inspectedPath, `${inspectedPath}-original`);
|
|
255
|
+
fs_1.default.symlinkSync(outsideFile, inspectedPath, 'file');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
fs_1.default.renameSync(targetDirectory, `${targetDirectory}-original`);
|
|
259
|
+
fs_1.default.symlinkSync(outside, targetDirectory, 'dir');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return resolved;
|
|
263
|
+
});
|
|
264
|
+
const reads = jest.spyOn(fs_1.default, 'readFileSync');
|
|
265
|
+
const nativeReadSync = fs_1.default.readSync;
|
|
266
|
+
let outsideDescriptorReads = 0;
|
|
267
|
+
const descriptorReads = jest.spyOn(fs_1.default, 'readSync').mockImplementation((...args) => {
|
|
268
|
+
const opened = fs_1.default.fstatSync(args[0]);
|
|
269
|
+
if (opened.dev === outsideIdentity.dev && opened.ino === outsideIdentity.ino)
|
|
270
|
+
outsideDescriptorReads += 1;
|
|
271
|
+
return Reflect.apply(nativeReadSync, fs_1.default, args);
|
|
272
|
+
});
|
|
273
|
+
try {
|
|
274
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code })] });
|
|
275
|
+
expect(swapped).toBe(true);
|
|
276
|
+
expect(reads.mock.calls.some(([file]) => file === inspectedPath)).toBe(false);
|
|
277
|
+
expect(outsideDescriptorReads).toBe(0);
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
descriptorReads.mockRestore();
|
|
281
|
+
reads.mockRestore();
|
|
282
|
+
realpath.mockRestore();
|
|
283
|
+
fs_1.default.rmSync(outside, { recursive: true, force: true });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
50
286
|
test('accepts a valid compact plan converted to CRLF', () => {
|
|
51
287
|
const plan = fixture(root);
|
|
52
288
|
fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace(/\n/g, '\r\n'));
|
|
53
289
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'valid', schema: 'compact-slices/v1' });
|
|
54
290
|
});
|
|
291
|
+
test('returns the SHA-256 identity of the complete normalized valid plan', () => {
|
|
292
|
+
const plan = fixture(root);
|
|
293
|
+
const lf = fs_1.default.readFileSync(plan, 'utf8');
|
|
294
|
+
const expected = crypto_1.default.createHash('sha256').update(lf).digest('hex');
|
|
295
|
+
const lfReport = (0, validate_1.validatePlanFile)(plan, root);
|
|
296
|
+
fs_1.default.writeFileSync(plan, lf.replace(/\n/g, '\r\n'));
|
|
297
|
+
const crlfReport = (0, validate_1.validatePlanFile)(plan, root);
|
|
298
|
+
expect(lfReport).toMatchObject({ state: 'valid', planDigest: expected });
|
|
299
|
+
expect(crlfReport).toMatchObject({ state: 'valid', planDigest: expected });
|
|
300
|
+
});
|
|
301
|
+
test.each([
|
|
302
|
+
['manifest requirement', (text) => text.replace(/R4-VAL-2/g, 'R4-VAL-3')],
|
|
303
|
+
['source fact', (text) => text.replace('Known fact', 'Changed fact')],
|
|
304
|
+
['command argv', (text) => text.replace('"test"', '"build"')],
|
|
305
|
+
['slice prose', (text) => text.replace('One evidence item.', 'Changed evidence item.')],
|
|
306
|
+
['execution-mode prose', (text) => `${text}\nModo de ejecución: desatendido\n`],
|
|
307
|
+
])('changes the plan identity when %s changes', (_name, edit) => {
|
|
308
|
+
const plan = fixture(root);
|
|
309
|
+
const first = (0, validate_1.validatePlanFile)(plan, root);
|
|
310
|
+
expect(first.state).toBe('valid');
|
|
311
|
+
if (first.state !== 'valid')
|
|
312
|
+
throw new Error('expected valid fixture');
|
|
313
|
+
fs_1.default.writeFileSync(plan, edit(fs_1.default.readFileSync(plan, 'utf8')));
|
|
314
|
+
const amended = (0, validate_1.validatePlanFile)(plan, root);
|
|
315
|
+
expect(amended.state).toBe('valid');
|
|
316
|
+
if (amended.state !== 'valid')
|
|
317
|
+
throw new Error('expected valid amended fixture');
|
|
318
|
+
expect(amended.planDigest).not.toBe(first.planDigest);
|
|
319
|
+
});
|
|
320
|
+
test('separates exact progress updates from the full normalized identity', () => {
|
|
321
|
+
const plan = fixture(root);
|
|
322
|
+
fs_1.default.appendFileSync(plan, '\n- [ ] Reviewed task.\n');
|
|
323
|
+
const before = (0, validate_1.validatePlanFile)(plan, root);
|
|
324
|
+
fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Reviewed task.', '- [x] Reviewed task.')
|
|
325
|
+
.replace('#### Evidence', '<!-- awm-qa-complete: 2026-09-16 -->\n<!-- awm-docs-complete: 2026-09-16 -->\n<!-- awm-retro-complete: Release R1 -->\n#### Evidence'));
|
|
326
|
+
const after = (0, validate_1.validatePlanFile)(plan, root);
|
|
327
|
+
expect(before.executionDigest).toEqual(expect.any(String));
|
|
328
|
+
expect(after.planDigest).not.toBe(before.planDigest);
|
|
329
|
+
expect(after.executionDigest).toBe(before.executionDigest);
|
|
330
|
+
});
|
|
331
|
+
test.each([
|
|
332
|
+
['task prose', '- [x] Changed task.\n'],
|
|
333
|
+
['inline marker', 'prose <!-- awm-qa-complete: 2026-09-16 -->\n'],
|
|
334
|
+
['unknown marker', '<!-- awm-qa-complete: arbitrary requirements -->\n'],
|
|
335
|
+
['fenced task', '```md\n- [x] Reviewed task.\n```\n'],
|
|
336
|
+
['fenced marker', '~~~~md\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n'],
|
|
337
|
+
])('does not canonicalize %s away from execution identity', (_label, addition) => {
|
|
338
|
+
const plan = fixture(root);
|
|
339
|
+
const before = (0, validate_1.validatePlanFile)(plan, root);
|
|
340
|
+
fs_1.default.appendFileSync(plan, addition);
|
|
341
|
+
const after = (0, validate_1.validatePlanFile)(plan, root);
|
|
342
|
+
expect(before.executionDigest).toEqual(expect.any(String));
|
|
343
|
+
expect(after.executionDigest).not.toBe(before.executionDigest);
|
|
344
|
+
});
|
|
345
|
+
test('a false fence closer cannot make code checkboxes and markers into lifecycle progress', () => {
|
|
346
|
+
const plan = fixture(root);
|
|
347
|
+
fs_1.default.appendFileSync(plan, '\n~~~~md\n~~~~not-a-closer\n- [ ] Code task.\n<!-- awm-qa-complete: 2026-09-16 -->\n~~~~\n');
|
|
348
|
+
const before = (0, validate_1.validatePlanFile)(plan, root);
|
|
349
|
+
fs_1.default.writeFileSync(plan, fs_1.default.readFileSync(plan, 'utf8').replace('- [ ] Code task.', '- [x] Code task.')
|
|
350
|
+
.replace('<!-- awm-qa-complete: 2026-09-16 -->', '<!-- awm-docs-complete: 2026-09-16 -->'));
|
|
351
|
+
const after = (0, validate_1.validatePlanFile)(plan, root);
|
|
352
|
+
expect(after.executionDigest).not.toBe(before.executionDigest);
|
|
353
|
+
});
|
|
55
354
|
test('accepts a contained plan when path.relative returns Windows separators', () => {
|
|
56
355
|
const plan = fixture(root);
|
|
57
356
|
const nestedPlan = path_1.default.join(root, 'docs', 'plan.md');
|
|
@@ -73,6 +372,59 @@ describe('validatePlanFile', () => {
|
|
|
73
372
|
const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
|
|
74
373
|
expectApprovedPlanValid((0, validate_1.validatePlanFile)('docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md', repositoryRoot));
|
|
75
374
|
});
|
|
375
|
+
test('preserves every previously valid tracked compact-v1 plan', () => {
|
|
376
|
+
const repositoryRoot = path_1.default.resolve(__dirname, '../../../..');
|
|
377
|
+
const tracked = jest.requireActual('child_process')
|
|
378
|
+
.execFileSync('git', ['ls-files', '--', 'docs/plans/*.md'], { cwd: repositoryRoot, encoding: 'utf8' })
|
|
379
|
+
.trim().split('\n').filter(Boolean);
|
|
380
|
+
const marked = tracked.filter((file) => fs_1.default.readFileSync(path_1.default.join(repositoryRoot, file), 'utf8').includes(START));
|
|
381
|
+
const previouslyValid = [
|
|
382
|
+
'docs/plans/2026-08-26-r4a-compact-plan-cli-plan.md',
|
|
383
|
+
'docs/plans/2026-08-27-sensor-portability-publication-a-plan.md',
|
|
384
|
+
'docs/plans/2026-09-07-retire-onsignal.md',
|
|
385
|
+
'docs/plans/2026-09-14-compact-only-bootstrap-plan.md',
|
|
386
|
+
];
|
|
387
|
+
expect(marked.length).toBeGreaterThan(0);
|
|
388
|
+
expect(previouslyValid.length).toBeGreaterThan(0);
|
|
389
|
+
const corpusCounts = `marked=${marked.length}; previouslyValid=${previouslyValid.length}; historicalInvalid=${marked.length - previouslyValid.length}`;
|
|
390
|
+
for (const file of previouslyValid) {
|
|
391
|
+
if (!marked.includes(file))
|
|
392
|
+
throw new Error(`previously valid compact-v1 plan is not tracked with a marker: ${file}; ${corpusCounts}`);
|
|
393
|
+
const report = (0, validate_1.validatePlanFile)(file, repositoryRoot);
|
|
394
|
+
if (report.state !== 'valid')
|
|
395
|
+
throw new Error(`previously valid compact-v1 plan failed: ${file}; state=${report.state}; ${corpusCounts}`);
|
|
396
|
+
assertFrozenObjectGraph(report, file);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
test.each(['RF-1.3', 'RNF-T.2', 'R4-VAL-2', 'A'.repeat(62) + '.1'])('accepts canonical bounded requirement ID %s in every requirement reference', (id) => {
|
|
400
|
+
const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
|
|
401
|
+
manifest.requirements = [id];
|
|
402
|
+
manifest.commands[0].covers = [id];
|
|
403
|
+
manifest.slices[0].requirements = [id];
|
|
404
|
+
}), root);
|
|
405
|
+
expect(report).toMatchObject({ state: 'valid', manifest: { requirements: [id] } });
|
|
406
|
+
});
|
|
407
|
+
test.each([
|
|
408
|
+
'.RF-1', 'RF-1.', 'RF..1', 'RF--1', 'RF-', 'rf-1.3', 'RF-1.a', ' RF-1.3', 'RF-1.3 ',
|
|
409
|
+
'RF-1. 3', 'RF-1/3', 'RF-1\\3', 'RF-1.é', 'RF-1.\u0000', 'RF-1.\u007f',
|
|
410
|
+
'A'.repeat(63) + '.1',
|
|
411
|
+
])('rejects malformed or oversized requirement ID %j', (id) => {
|
|
412
|
+
const report = (0, validate_1.validatePlanFile)(fixture(root, (manifest) => {
|
|
413
|
+
manifest.requirements = [id];
|
|
414
|
+
manifest.commands[0].covers = [id];
|
|
415
|
+
manifest.slices[0].requirements = [id];
|
|
416
|
+
}), root);
|
|
417
|
+
expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SHAPE' })] });
|
|
418
|
+
});
|
|
419
|
+
test.each([
|
|
420
|
+
['source', (m) => { m.sources[0].id = 'SRC.ONE'; }, 'PLAN_SOURCE_SHAPE'],
|
|
421
|
+
['command', (m) => { m.commands[0].id = 'CMD.ONE'; }, 'PLAN_COMMAND_SHAPE'],
|
|
422
|
+
['slice', (m) => { m.slices[0].id = 'S.1'; }, 'PLAN_SLICE_SHAPE'],
|
|
423
|
+
])('keeps dotted %s entity IDs invalid', (_name, mutate, code) => {
|
|
424
|
+
expect((0, validate_1.validatePlanFile)(fixture(root, mutate), root)).toMatchObject({
|
|
425
|
+
state: 'invalid', diagnostics: [expect.objectContaining({ code })],
|
|
426
|
+
});
|
|
427
|
+
});
|
|
76
428
|
test('performs no execution, network request, model work, grouping, or rewrite', () => {
|
|
77
429
|
const plan = fixture(root);
|
|
78
430
|
const before = fs_1.default.readFileSync(plan, 'utf8');
|
|
@@ -122,7 +474,7 @@ describe('validatePlanFile', () => {
|
|
|
122
474
|
test('keeps a future schema behind partial markers invalid rather than unsupported', () => {
|
|
123
475
|
const plan = path_1.default.join(root, 'partial-future.md');
|
|
124
476
|
fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}`);
|
|
125
|
-
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: '
|
|
477
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
|
|
126
478
|
});
|
|
127
479
|
test('applies scalar limits before classifying a future schema as unsupported', () => {
|
|
128
480
|
const plan = path_1.default.join(root, 'future-too-large.md');
|
|
@@ -229,12 +581,12 @@ describe('validatePlanFile', () => {
|
|
|
229
581
|
const report = (0, validate_1.validatePlanFile)(fixture(root, (m) => { m.slices[0].sectionAnchor = 'a'.repeat(4097); }), root);
|
|
230
582
|
expect(report).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_SLICE_SHAPE' })] });
|
|
231
583
|
});
|
|
232
|
-
test('
|
|
584
|
+
test('requires migration for a document without any compact signal', () => {
|
|
233
585
|
const plan = path_1.default.join(root, 'legacy.md');
|
|
234
586
|
fs_1.default.writeFileSync(plan, '# Legacy plan\n');
|
|
235
|
-
expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: '
|
|
587
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toEqual({ state: 'migration-required', reason: 'unmarked-plan' });
|
|
236
588
|
});
|
|
237
|
-
test('does not classify an escaped future schema signal as
|
|
589
|
+
test('does not classify an escaped future schema signal as migration-required', () => {
|
|
238
590
|
const plan = path_1.default.join(root, 'escaped-future.md');
|
|
239
591
|
fs_1.default.writeFileSync(plan, '{"schema":"compact-slices\\u002fv2"}');
|
|
240
592
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
|
|
@@ -242,17 +594,17 @@ describe('validatePlanFile', () => {
|
|
|
242
594
|
test.each([
|
|
243
595
|
['escaped schema key', '{"\\u0073chema":"compact-slices/v2"}'],
|
|
244
596
|
['escaped schema hyphen', '{"schema":"compact\\u002dslices/v2"}'],
|
|
245
|
-
])('does not classify a future schema with an %s as
|
|
597
|
+
])('does not classify a future schema with an %s as migration-required', (_name, manifest) => {
|
|
246
598
|
const plan = path_1.default.join(root, 'escaped-future-schema.md');
|
|
247
599
|
fs_1.default.writeFileSync(plan, manifest);
|
|
248
600
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
|
|
249
601
|
});
|
|
250
|
-
test('does not classify an escaped future schema embedded in Markdown without markers as
|
|
602
|
+
test('does not classify an escaped future schema embedded in Markdown without markers as migration-required', () => {
|
|
251
603
|
const plan = path_1.default.join(root, 'embedded-escaped-future.md');
|
|
252
604
|
fs_1.default.writeFileSync(plan, '# Plan notes\n\n```json\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"}\n```\n');
|
|
253
605
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'unsupported', schema: 'compact-slices/v2' });
|
|
254
606
|
});
|
|
255
|
-
test('rejects an unparseable escaped compact schema embedded in Markdown rather than
|
|
607
|
+
test('rejects an unparseable escaped compact schema embedded in Markdown rather than requiring migration', () => {
|
|
256
608
|
const plan = path_1.default.join(root, 'embedded-escaped-malformed.md');
|
|
257
609
|
fs_1.default.writeFileSync(plan, '# Plan notes\n\n{"\\u0073chema":"compact\\u002dslices\\u002fv2"\n');
|
|
258
610
|
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
|
|
@@ -419,7 +771,7 @@ describe('validatePlanFile', () => {
|
|
|
419
771
|
test('recognizes a parseable future schema when only the END marker is broken', () => {
|
|
420
772
|
const plan = path_1.default.join(root, 'future-missing-end.md');
|
|
421
773
|
fs_1.default.writeFileSync(plan, `${START}\n{"schema":"compact-slices/v2"}\n`);
|
|
422
|
-
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: '
|
|
774
|
+
expect((0, validate_1.validatePlanFile)(plan, root)).toMatchObject({ state: 'invalid', diagnostics: [expect.objectContaining({ code: 'PLAN_MARKERS' })] });
|
|
423
775
|
});
|
|
424
776
|
test('accepts CRLF Markdown with nested headings and fenced code in a required section', () => {
|
|
425
777
|
const plan = fixture(root);
|