agentic-workflow-manager 9.21.0 → 9.22.1
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/dist/src/commands/change/index.js +16 -5
- package/dist/src/commands/init.js +8 -4
- package/dist/src/commands/preflight/checks.js +22 -0
- package/dist/src/commands/registry/add.js +9 -3
- package/dist/src/commands/sensors/compatibility/contract.js +56 -3
- package/dist/src/commands/sensors/compatibility/manifest.js +16 -2
- package/dist/src/commands/sensors/index.js +10 -1
- package/dist/src/commands/sensors/inputs-digest.js +315 -0
- package/dist/src/commands/sensors/prepare.js +10 -0
- package/dist/src/commands/sensors/result-cache.js +278 -0
- package/dist/src/commands/sensors/result.js +31 -12
- package/dist/src/commands/sensors/run.js +265 -8
- package/dist/src/commands/sensors/status.js +66 -7
- package/dist/src/commands/sync.js +11 -5
- package/dist/src/commands/update.js +23 -12
- package/dist/src/core/changes/derive.js +6 -3
- package/dist/src/core/changes/git.js +1 -2
- package/dist/src/core/changes/receipt.js +219 -9
- package/dist/src/core/changes/worktree.js +15 -3
- package/dist/src/core/init/steps.js +3 -8
- package/dist/src/core/registries.js +20 -15
- package/dist/src/core/registry-git-errors.js +107 -0
- package/dist/src/core/registry-git.js +93 -0
- package/dist/src/core/registry-sync-report.js +74 -0
- package/dist/src/core/versioning.js +3 -6
- package/dist/src/index.js +5 -8
- package/dist/tests/commands/change/new.test.js +104 -58
- package/dist/tests/commands/change/validate-unmeasured.test.js +246 -0
- package/dist/tests/commands/change/validate.test.js +164 -108
- package/dist/tests/commands/changes/list.test.js +41 -22
- package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
- package/dist/tests/commands/preflight/sensors-inputs.test.js +110 -0
- package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
- package/dist/tests/commands/sensors/compatibility/contract.test.js +60 -0
- package/dist/tests/commands/sensors/compatibility/manifest.test.js +94 -0
- package/dist/tests/commands/sensors/exec.test.js +3 -1
- package/dist/tests/commands/sensors/inputs-digest.test.js +373 -0
- package/dist/tests/commands/sensors/result-cache.test.js +352 -0
- package/dist/tests/commands/sensors/run-cache.test.js +434 -0
- package/dist/tests/commands/sensors/run-slice.test.js +171 -0
- package/dist/tests/commands/sensors/status-inputs.test.js +271 -0
- package/dist/tests/commands/update.test.js +8 -8
- package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
- package/dist/tests/core/changes/helpers.js +15 -3
- package/dist/tests/core/changes/list-paging.test.js +131 -110
- package/dist/tests/core/changes/list.test.js +133 -83
- package/dist/tests/core/changes/receipt.test.js +279 -8
- package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
- package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
- package/dist/tests/core/registry-git.test.js +153 -0
- package/dist/tests/helpers/commit-seeded-registry.js +18 -0
- package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
- package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
- package/dist/tests/structural/active-documentation.test.js +0 -41
- package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
- package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
- package/dist/tests/structural/native-release-artifacts.test.js +116 -107
- package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
- package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
- package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
- package/package.json +3 -2
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
- package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
- package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
- package/dist/tests/structural/process-model-description-contract.test.js +0 -34
- package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
- package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
|
@@ -83,6 +83,62 @@ function commitRecord(slug = 'rec', overall = 'pass', sensors = ['lint']) {
|
|
|
83
83
|
repo.git('commit', '-q', '-m', `docs(changes): ${slug}`);
|
|
84
84
|
return rel;
|
|
85
85
|
}
|
|
86
|
+
// Prebuilt states. Each is built once, lazily, in its own clone of the template (the git work of `change new` and the commits
|
|
87
|
+
// happens there once); every test that starts from it gets a private file copy (no git process) that it may change freely.
|
|
88
|
+
const fixtures = new Map();
|
|
89
|
+
afterAll(() => { for (const f of fixtures.values())
|
|
90
|
+
f.cleanup(); });
|
|
91
|
+
/** The prebuilt state `key` (built by `build` on first use). */
|
|
92
|
+
function built(key, build) {
|
|
93
|
+
let state = fixtures.get(key);
|
|
94
|
+
if (state === undefined) {
|
|
95
|
+
const own = repo;
|
|
96
|
+
repo = (0, helpers_1.cloneRepo)(template);
|
|
97
|
+
try {
|
|
98
|
+
build();
|
|
99
|
+
state = repo;
|
|
100
|
+
fixtures.set(key, state);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
repo.cleanup();
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
repo = own;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return state;
|
|
111
|
+
}
|
|
112
|
+
/** Starts the test from a private copy of the prebuilt state `key`. */
|
|
113
|
+
function fixture(key, build) {
|
|
114
|
+
const state = built(key, build);
|
|
115
|
+
repo.cleanup();
|
|
116
|
+
repo = (0, helpers_1.cloneRepo)(state);
|
|
117
|
+
}
|
|
118
|
+
const recordKey = (slug, overall, sensors) => `record ${slug} ${overall} ${sensors.join(',')}`;
|
|
119
|
+
/** The repository after `commitRecord(slug, overall, sensors)`, from a prebuilt state. Returns the record's path. */
|
|
120
|
+
function withRecord(slug = 'rec', overall = 'pass', sensors = ['lint']) {
|
|
121
|
+
fixture(recordKey(slug, overall, sensors), () => { commitRecord(slug, overall, sensors); });
|
|
122
|
+
return `docs/changes/2026-10-17-${slug}.md`;
|
|
123
|
+
}
|
|
124
|
+
const sharedRuns = new Map();
|
|
125
|
+
/**
|
|
126
|
+
* `runChangeValidate(root, target, opts)` on an untouched prebuilt state: `none` is the template (a branch with one commit and no
|
|
127
|
+
* record), `record` is the state of `withRecord()`. Validation is read-only (R4.6, asserted below), so each distinct run is made
|
|
128
|
+
* once and its result is shared by the tests that only read it.
|
|
129
|
+
*/
|
|
130
|
+
function untouched(state, target, opts) {
|
|
131
|
+
const key = JSON.stringify([state, target ?? null, opts]);
|
|
132
|
+
let r = sharedRuns.get(key);
|
|
133
|
+
if (r === undefined) {
|
|
134
|
+
const root = state === 'none' ? template.root : built(recordKey('rec', 'pass', ['lint']), () => { commitRecord(); }).root;
|
|
135
|
+
r = (0, change_1.runChangeValidate)(root, target, opts);
|
|
136
|
+
sharedRuns.set(key, r);
|
|
137
|
+
}
|
|
138
|
+
return r;
|
|
139
|
+
}
|
|
140
|
+
/** The repository after `commitRecord('aaa')` and `commitRecord('bbb')`, from a prebuilt state. */
|
|
141
|
+
const withTwoRecords = () => fixture('records aaa bbb', () => { commitRecord('aaa'); commitRecord('bbb'); });
|
|
86
142
|
/** Edits the working-tree copy of a record (nothing is committed). */
|
|
87
143
|
function edit(rel, fn) {
|
|
88
144
|
const before = fs_1.default.readFileSync(abs(rel), 'utf8');
|
|
@@ -112,22 +168,20 @@ function expectFailure(r, code) {
|
|
|
112
168
|
const rawUnsafe = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
113
169
|
describe('R4.1 target selection', () => {
|
|
114
170
|
test('without a path every record added in merge-base..HEAD is validated', () => {
|
|
115
|
-
|
|
116
|
-
commitRecord('bbb');
|
|
171
|
+
withTwoRecords();
|
|
117
172
|
const r = run();
|
|
118
173
|
expect(report(r).records.map((x) => x.path)).toEqual(['docs/changes/2026-10-17-aaa.md', 'docs/changes/2026-10-17-bbb.md']);
|
|
119
174
|
expect(r.code).toBe(0);
|
|
120
175
|
});
|
|
121
176
|
test('one invalid record among several makes the run invalid and is the one reported as such', () => {
|
|
122
|
-
|
|
123
|
-
commitRecord('bbb');
|
|
177
|
+
withTwoRecords();
|
|
124
178
|
fs_1.default.writeFileSync(abs('docs/changes/2026-10-17-bbb.md'), 'not a record');
|
|
125
179
|
const out = report(run());
|
|
126
180
|
expect(out.state).toBe('invalid');
|
|
127
181
|
expect(out.records.map((x) => x.state)).toEqual(['valid', 'invalid']);
|
|
128
182
|
});
|
|
129
183
|
test('a record that already exists at the merge base and is only modified is not an added record', () => {
|
|
130
|
-
const rel =
|
|
184
|
+
const rel = withRecord();
|
|
131
185
|
repo.git('branch', '-f', 'main', 'HEAD');
|
|
132
186
|
repo.commit({ [rel]: fs_1.default.readFileSync(abs(rel), 'utf8') + '\nmore\n' }, 'docs: touch the record');
|
|
133
187
|
expect(codesOf(run())).toEqual(['record-missing']);
|
|
@@ -137,27 +191,26 @@ describe('R4.1 target selection', () => {
|
|
|
137
191
|
expect(codesOf(run())).toEqual(['record-missing']);
|
|
138
192
|
});
|
|
139
193
|
test('with a path only that file is validated', () => {
|
|
140
|
-
|
|
141
|
-
commitRecord('bbb');
|
|
194
|
+
withTwoRecords();
|
|
142
195
|
fs_1.default.writeFileSync(abs('docs/changes/2026-10-17-bbb.md'), 'not a record');
|
|
143
196
|
const r = run('docs/changes/2026-10-17-aaa.md');
|
|
144
197
|
expect(report(r).records.map((x) => x.path)).toEqual(['docs/changes/2026-10-17-aaa.md']);
|
|
145
198
|
expect(r.code).toBe(0);
|
|
146
199
|
});
|
|
147
200
|
test('a path is read relative to the working directory, from a subdirectory', () => {
|
|
148
|
-
const rel =
|
|
201
|
+
const rel = withRecord();
|
|
149
202
|
const r = (0, change_1.runChangeValidate)(path_1.default.join(repo.root, 'cli'), `..${path_1.default.sep}docs${path_1.default.sep}changes${path_1.default.sep}2026-10-17-rec.md`, { json: true });
|
|
150
203
|
expect(report(r).records[0].path).toBe(rel);
|
|
151
204
|
expect(r.code).toBe(0);
|
|
152
205
|
});
|
|
153
206
|
test('an absolute path inside the repository is accepted', () => {
|
|
154
|
-
const rel =
|
|
207
|
+
const rel = withRecord();
|
|
155
208
|
const r = (0, change_1.runChangeValidate)(repo.root, abs(rel), { json: true });
|
|
156
209
|
expect(report(r).records[0].path).toBe(rel);
|
|
157
210
|
expect(r.code).toBe(0);
|
|
158
211
|
});
|
|
159
212
|
test('with a path the record is read even when the branch range holds no commit (derivation failure is reported for it)', () => {
|
|
160
|
-
const rel =
|
|
213
|
+
const rel = withRecord();
|
|
161
214
|
repo.git('branch', '-f', 'main', 'HEAD');
|
|
162
215
|
const r = run(rel);
|
|
163
216
|
expect(r.code).toBe(2);
|
|
@@ -222,7 +275,7 @@ describe('target path safety', () => {
|
|
|
222
275
|
diag(r, 'record-missing', undefined);
|
|
223
276
|
});
|
|
224
277
|
(0, helpers_1.posixOnly)('a record that is a symlink is record-missing and is never read', () => {
|
|
225
|
-
|
|
278
|
+
withRecord();
|
|
226
279
|
const outside = path_1.default.join(fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'outside-')), 'outside.md');
|
|
227
280
|
fs_1.default.writeFileSync(outside, fs_1.default.readFileSync(abs(REL)));
|
|
228
281
|
fs_1.default.rmSync(abs(REL));
|
|
@@ -232,7 +285,7 @@ describe('target path safety', () => {
|
|
|
232
285
|
diag(r, 'record-missing', undefined);
|
|
233
286
|
});
|
|
234
287
|
(0, helpers_1.posixOnly)('a symlinked docs/changes directory is unsafe-path for every record', () => {
|
|
235
|
-
|
|
288
|
+
withRecord();
|
|
236
289
|
const moved = path_1.default.join(fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'moved-')), 'changes');
|
|
237
290
|
fs_1.default.renameSync(path_1.default.join(repo.root, 'docs', 'changes'), moved);
|
|
238
291
|
fs_1.default.symlinkSync(moved, path_1.default.join(repo.root, 'docs', 'changes'));
|
|
@@ -257,14 +310,14 @@ describe('target path safety', () => {
|
|
|
257
310
|
expect(codesOf(run())).toEqual(['frontmatter-missing']);
|
|
258
311
|
});
|
|
259
312
|
test('a record that is not valid UTF-8 is record-invalid (not record-too-large)', () => {
|
|
260
|
-
|
|
313
|
+
withRecord();
|
|
261
314
|
fs_1.default.writeFileSync(abs(REL), Buffer.from([0x2d, 0x2d, 0x2d, 0xff, 0xfe, 0x0a]));
|
|
262
315
|
expect(codesOf(run(REL))).toEqual(['record-invalid']);
|
|
263
316
|
});
|
|
264
317
|
});
|
|
265
318
|
describe('R4.2 missing record and the opt-out trailer', () => {
|
|
266
319
|
test('no record and no trailer fails with record-missing, state invalid, exit 2', () => {
|
|
267
|
-
const r =
|
|
320
|
+
const r = untouched('none', undefined, { json: true });
|
|
268
321
|
expect(r.code).toBe(2);
|
|
269
322
|
expect(report(r).state).toBe('invalid');
|
|
270
323
|
diag(r, 'record-missing', undefined);
|
|
@@ -324,7 +377,7 @@ describe('R4.2 missing record and the opt-out trailer', () => {
|
|
|
324
377
|
});
|
|
325
378
|
describe('base resolution (R3.1, R3.2, R3.5)', () => {
|
|
326
379
|
test('--base selects the base: a base at HEAD leaves no added record', () => {
|
|
327
|
-
|
|
380
|
+
withRecord();
|
|
328
381
|
repo.git('branch', 'tip', 'HEAD');
|
|
329
382
|
expect(run().code).toBe(0);
|
|
330
383
|
diag(run(undefined, { base: 'tip' }), 'record-missing', undefined);
|
|
@@ -356,13 +409,12 @@ describe('base resolution (R3.1, R3.2, R3.5)', () => {
|
|
|
356
409
|
});
|
|
357
410
|
describe('R4.3 base checks', () => {
|
|
358
411
|
test('a filled record that matches git passes', () => {
|
|
359
|
-
|
|
360
|
-
const r = run();
|
|
412
|
+
const r = untouched('record', undefined, { json: true });
|
|
361
413
|
expect(r.code).toBe(0);
|
|
362
414
|
expect(report(r)).toMatchObject({ state: 'valid', records: [{ path: REL, state: 'valid', diagnostics: [] }] });
|
|
363
415
|
});
|
|
364
416
|
test('R1 shape errors are reported with their own code and field', () => {
|
|
365
|
-
|
|
417
|
+
withRecord();
|
|
366
418
|
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
367
419
|
diag(run(REL), 'field-invalid', 'schema');
|
|
368
420
|
});
|
|
@@ -374,7 +426,7 @@ describe('R4.3 base checks', () => {
|
|
|
374
426
|
diag(run(), 'pending-content', undefined);
|
|
375
427
|
});
|
|
376
428
|
test('a record that fails R1 is not checked against git (no record to compare)', () => {
|
|
377
|
-
|
|
429
|
+
withRecord();
|
|
378
430
|
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('schema: awm.change-record/v1', 'schema: other'));
|
|
379
431
|
expect(codesOf(run(REL))).not.toContain('derived-mismatch');
|
|
380
432
|
});
|
|
@@ -385,25 +437,25 @@ describe('R4.3 base checks', () => {
|
|
|
385
437
|
['scope', 'scope: changes', 'scope: other'],
|
|
386
438
|
['modules', ' - cli/src', ' - cli/src\n - docs'],
|
|
387
439
|
])('derived-mismatch names the field %s', (field, from, to) => {
|
|
388
|
-
|
|
440
|
+
withRecord();
|
|
389
441
|
edit(REL, (t) => t.replace(from, to));
|
|
390
442
|
const r = run(REL);
|
|
391
443
|
expect(r.code).toBe(2);
|
|
392
444
|
expect(diagsOf(r).filter((d) => d.code === 'derived-mismatch').map((d) => d.field)).toEqual([field]);
|
|
393
445
|
});
|
|
394
446
|
test('every mismatching derived field gets its own diagnostic', () => {
|
|
395
|
-
|
|
447
|
+
withRecord();
|
|
396
448
|
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('semver: minor', 'semver: patch'));
|
|
397
449
|
expect(diagsOf(run(REL)).filter((d) => d.code === 'derived-mismatch').map((d) => d.field).sort()).toEqual(['semver', 'type']);
|
|
398
450
|
});
|
|
399
451
|
test('a derived-mismatch message states the recorded and the derived value on one line', () => {
|
|
400
|
-
|
|
452
|
+
withRecord();
|
|
401
453
|
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
402
454
|
const d = diag(run(REL), 'derived-mismatch', 'type');
|
|
403
455
|
expect(d.message).toBe('type is "fix" but git derives "feat"');
|
|
404
456
|
});
|
|
405
457
|
test('a git-derivation failure is reported once with the derive code (no-conventional-commits)', () => {
|
|
406
|
-
|
|
458
|
+
withRecord();
|
|
407
459
|
repo.git('reset', '-q', '--soft', 'main');
|
|
408
460
|
repo.git('commit', '-q', '-m', 'work in progress');
|
|
409
461
|
const r = run(REL);
|
|
@@ -411,12 +463,12 @@ describe('R4.3 base checks', () => {
|
|
|
411
463
|
expect(codesOf(r)).not.toContain('derived-mismatch');
|
|
412
464
|
});
|
|
413
465
|
test('a verification commit that is not in the repository is verification-commit-foreign', () => {
|
|
414
|
-
|
|
466
|
+
withRecord();
|
|
415
467
|
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
416
468
|
diag(run(REL), 'verification-commit-foreign', 'verification.commit');
|
|
417
469
|
});
|
|
418
470
|
test('a verification commit on another branch is verification-commit-foreign', () => {
|
|
419
|
-
|
|
471
|
+
withRecord();
|
|
420
472
|
const other = (() => {
|
|
421
473
|
repo.git('checkout', '-q', '-b', 'side', 'main');
|
|
422
474
|
const sha = repo.commit({ 'side.ts': '1' }, 'feat: side');
|
|
@@ -427,50 +479,51 @@ describe('R4.3 base checks', () => {
|
|
|
427
479
|
diag(run(REL), 'verification-commit-foreign', 'verification.commit');
|
|
428
480
|
});
|
|
429
481
|
test('a verification commit equal to HEAD is accepted', () => {
|
|
430
|
-
|
|
482
|
+
withRecord();
|
|
431
483
|
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${repo.git('rev-parse', 'HEAD')}`));
|
|
432
484
|
const codes = codesOf(run(REL));
|
|
433
485
|
expect(codes).not.toContain('verification-commit-foreign');
|
|
434
486
|
expect(codes).not.toContain('code-changed-after-verification');
|
|
435
487
|
});
|
|
436
488
|
test('a foreign verification commit does not also report code-changed-after-verification', () => {
|
|
437
|
-
|
|
489
|
+
withRecord();
|
|
438
490
|
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
439
491
|
expect(codesOf(run(REL))).not.toContain('code-changed-after-verification');
|
|
440
492
|
});
|
|
441
493
|
test('code changed after the verified commit is code-changed-after-verification', () => {
|
|
442
|
-
|
|
494
|
+
withRecord();
|
|
443
495
|
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
444
|
-
const
|
|
496
|
+
const r = run();
|
|
497
|
+
const d = diag(r, 'code-changed-after-verification', undefined);
|
|
445
498
|
expect(d.message).toContain('cli/src/late.ts');
|
|
446
|
-
expect(codesOf(
|
|
499
|
+
expect(codesOf(r)).not.toContain('derived-mismatch'); // same module, same type: only the late code is wrong
|
|
447
500
|
});
|
|
448
501
|
test('files under docs/changes changed after the verified commit are not code', () => {
|
|
449
|
-
|
|
502
|
+
withRecord();
|
|
450
503
|
repo.commit({ 'docs/changes/notes.md': 'n' }, 'docs: notes');
|
|
451
504
|
expect(codesOf(run())).not.toContain('code-changed-after-verification');
|
|
452
505
|
});
|
|
453
506
|
test('a path that merely starts like docs/changes is code', () => {
|
|
454
|
-
|
|
507
|
+
withRecord();
|
|
455
508
|
repo.commit({ 'docs/changes-extra/x.md': 'n' }, 'chore: extra');
|
|
456
509
|
expect(codesOf(run())).toContain('code-changed-after-verification');
|
|
457
510
|
});
|
|
458
511
|
test('late code and derived drift are both reported', () => {
|
|
459
|
-
|
|
512
|
+
withRecord();
|
|
460
513
|
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
461
514
|
const c = codesOf(run());
|
|
462
515
|
expect(c).toContain('code-changed-after-verification');
|
|
463
516
|
expect(c).toContain('derived-mismatch');
|
|
464
517
|
});
|
|
465
518
|
test('one late file is reported as "1 file" and two as "2 files"', () => {
|
|
466
|
-
|
|
519
|
+
withRecord();
|
|
467
520
|
repo.commit({ 'cli/src/one.ts': '1' }, 'chore: one');
|
|
468
521
|
expect(diag(run(), 'code-changed-after-verification', undefined).message).toMatch(/^1 file changed after [0-9a-f]{12}: cli\/src\/one\.ts$/);
|
|
469
522
|
repo.commit({ 'cli/src/two.ts': '1' }, 'chore: two');
|
|
470
523
|
expect(diag(run(), 'code-changed-after-verification', undefined).message).toMatch(/^2 files changed after [0-9a-f]{12}: cli\/src\/one\.ts, cli\/src\/two\.ts$/);
|
|
471
524
|
});
|
|
472
525
|
test('the late-file list names at most 20 paths and counts them all', () => {
|
|
473
|
-
|
|
526
|
+
withRecord();
|
|
474
527
|
repo.commit(Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`a${String(i).padStart(2, '0')}.ts`, '1'])), 'chore: late');
|
|
475
528
|
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
476
529
|
expect(d.message).toContain('25 files changed after');
|
|
@@ -479,20 +532,20 @@ describe('R4.3 base checks', () => {
|
|
|
479
532
|
expect(d.message).not.toContain('a20.ts');
|
|
480
533
|
});
|
|
481
534
|
test('exactly 20 late files are all listed with no ellipsis', () => {
|
|
482
|
-
|
|
535
|
+
withRecord();
|
|
483
536
|
repo.commit(Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`a${String(i).padStart(2, '0')}.ts`, '1'])), 'chore: late');
|
|
484
537
|
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
485
538
|
expect(d.message.endsWith('a19.ts')).toBe(true);
|
|
486
539
|
});
|
|
487
540
|
test('a long late-file list stays one line of at most 300 units', () => {
|
|
488
|
-
|
|
541
|
+
withRecord();
|
|
489
542
|
repo.commit(Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`cli/src/late/${'d'.repeat(60)}${i}.ts`, '1'])), 'chore: late');
|
|
490
543
|
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
491
544
|
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
492
545
|
expect(d.message).not.toContain('\n');
|
|
493
546
|
});
|
|
494
547
|
(0, helpers_1.posixOnly)('a late file named with an escape sequence and a newline never reaches the output raw', () => {
|
|
495
|
-
|
|
548
|
+
withRecord();
|
|
496
549
|
repo.commit({ 'cli/src/\u001b[31mred\nline.ts': '1' }, 'chore: hostile');
|
|
497
550
|
const text = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
498
551
|
const json = run();
|
|
@@ -503,7 +556,7 @@ describe('R4.3 base checks', () => {
|
|
|
503
556
|
expect(diag(json, 'code-changed-after-verification', undefined).message).toContain('\\u001b');
|
|
504
557
|
});
|
|
505
558
|
test('a git failure while listing the late files is reported, not thrown', () => {
|
|
506
|
-
|
|
559
|
+
withRecord();
|
|
507
560
|
const verified = repo.git('rev-parse', 'HEAD~1');
|
|
508
561
|
const real = gitModule.changedFiles;
|
|
509
562
|
// Only the late-file listing (which starts at the verified commit) fails; the derivation's own diff still works.
|
|
@@ -516,13 +569,13 @@ describe('R4.3 base checks', () => {
|
|
|
516
569
|
});
|
|
517
570
|
describe('R4.4 strict checks', () => {
|
|
518
571
|
test('strict passes with a matching, passing receipt', () => {
|
|
519
|
-
|
|
572
|
+
withRecord();
|
|
520
573
|
const r = run(undefined, { strict: true });
|
|
521
574
|
expect(r.code).toBe(0);
|
|
522
575
|
expect(report(r).state).toBe('valid');
|
|
523
576
|
});
|
|
524
577
|
test('R2.2c strict compares the sensors as sorted pairs: integer-like names and a record that lists them in another order still match', () => {
|
|
525
|
-
|
|
578
|
+
withRecord('rec', 'pass', ['tsc', '9', '10']);
|
|
526
579
|
expect(run(undefined, { strict: true }).code).toBe(0);
|
|
527
580
|
// The same sensors, listed in another order in the record's YAML: the verdict is the same.
|
|
528
581
|
edit(REL, (t) => t.replace(/ sensors:[\s\S]*?(?= receipt_digest)/, " sensors: { tsc: pass, '10': pass, '9': pass }\n"));
|
|
@@ -533,82 +586,82 @@ describe('R4.4 strict checks', () => {
|
|
|
533
586
|
expect(r.code).toBe(0);
|
|
534
587
|
});
|
|
535
588
|
test('base mode never reads the receipt', () => {
|
|
536
|
-
|
|
589
|
+
withRecord();
|
|
537
590
|
fs_1.default.rmSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs'), { recursive: true, force: true });
|
|
538
591
|
expect(run().code).toBe(0);
|
|
539
592
|
});
|
|
540
593
|
test('strict: a missing receipt is receipt-missing', () => {
|
|
541
|
-
|
|
594
|
+
withRecord();
|
|
542
595
|
fs_1.default.rmSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs'), { recursive: true, force: true });
|
|
543
596
|
diag(run(undefined, { strict: true }), 'receipt-missing', 'verification.commit');
|
|
544
597
|
});
|
|
545
598
|
test('strict: a receipt that is not valid JSON is receipt-invalid', () => {
|
|
546
|
-
|
|
599
|
+
withRecord();
|
|
547
600
|
const sha = /commit: ([0-9a-f]{40})/.exec(fs_1.default.readFileSync(abs(REL), 'utf8'))?.[1];
|
|
548
601
|
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${sha}.json`), '{ nope');
|
|
549
602
|
diag(run(undefined, { strict: true }), 'receipt-invalid', 'verification.commit');
|
|
550
603
|
});
|
|
551
604
|
test('strict: a receipt whose content was edited (digest broken) is receipt-invalid', () => {
|
|
552
|
-
|
|
605
|
+
withRecord();
|
|
553
606
|
const sha = /commit: ([0-9a-f]{40})/.exec(fs_1.default.readFileSync(abs(REL), 'utf8'))?.[1];
|
|
554
607
|
const file = path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${sha}.json`);
|
|
555
608
|
fs_1.default.writeFileSync(file, fs_1.default.readFileSync(file, 'utf8').replace('"pass"', '"fail"'));
|
|
556
609
|
diag(run(undefined, { strict: true }), 'receipt-invalid', 'verification.commit');
|
|
557
610
|
});
|
|
558
611
|
test('without --strict a missing or failing receipt is not reported', () => {
|
|
559
|
-
|
|
612
|
+
withRecord('rec', 'fail');
|
|
560
613
|
expect(run().code).toBe(0);
|
|
561
614
|
});
|
|
562
615
|
test('strict: a failing verdict is verification-not-passing', () => {
|
|
563
|
-
|
|
616
|
+
withRecord('rec', 'fail');
|
|
564
617
|
const r = run(undefined, { strict: true });
|
|
565
618
|
expect(r.code).toBe(2);
|
|
566
619
|
diag(r, 'verification-not-passing', 'verification.overall');
|
|
567
620
|
expect(codesOf(r)).not.toContain('verification-mismatch');
|
|
568
621
|
});
|
|
569
622
|
test('strict: a record digest that differs from the receipt is verification-mismatch on receipt_digest', () => {
|
|
570
|
-
|
|
623
|
+
withRecord();
|
|
571
624
|
edit(REL, (t) => t.replace(/receipt_digest: sha256:[0-9a-f]{64}/, `receipt_digest: sha256:${'0'.repeat(64)}`));
|
|
572
625
|
const r = run(REL, { strict: true });
|
|
573
626
|
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.receipt_digest']);
|
|
574
627
|
});
|
|
575
628
|
test('strict: a record verdict that differs from the receipt is verification-mismatch on overall', () => {
|
|
576
|
-
|
|
629
|
+
withRecord();
|
|
577
630
|
edit(REL, (t) => t.replace('overall: pass', 'overall: fail'));
|
|
578
631
|
const r = run(REL, { strict: true });
|
|
579
632
|
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.overall']);
|
|
580
633
|
});
|
|
581
634
|
test('strict: a record sensor status that differs from the receipt is verification-mismatch on sensors', () => {
|
|
582
|
-
|
|
635
|
+
withRecord();
|
|
583
636
|
edit(REL, (t) => t.replace('lint: pass', 'lint: fail'));
|
|
584
637
|
const r = run(REL, { strict: true });
|
|
585
638
|
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
586
639
|
});
|
|
587
640
|
test('strict: an extra sensor in the record is verification-mismatch on sensors', () => {
|
|
588
|
-
|
|
641
|
+
withRecord();
|
|
589
642
|
edit(REL, (t) => t.replace(' lint: pass', ' lint: pass\n extra: pass'));
|
|
590
643
|
const r = run(REL, { strict: true });
|
|
591
644
|
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
592
645
|
});
|
|
593
646
|
test('strict: a sensor the record omits is verification-mismatch on sensors', () => {
|
|
594
|
-
|
|
647
|
+
withRecord('rec', 'pass', ['lint', 'types']);
|
|
595
648
|
expect(run(undefined, { strict: true }).code).toBe(0); // two sensors in the receipt and the record ...
|
|
596
649
|
edit(REL, (t) => t.replace(' types: pass\n', '')); // ... and the record omits one
|
|
597
650
|
const r = run(REL, { strict: true });
|
|
598
651
|
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
599
652
|
});
|
|
600
653
|
test('strict: the sensor order in the record does not matter', () => {
|
|
601
|
-
|
|
654
|
+
withRecord('rec', 'pass', ['lint', 'types']);
|
|
602
655
|
edit(REL, (t) => t.replace(' lint: pass\n types: pass\n', ' types: pass\n lint: pass\n'));
|
|
603
656
|
expect(run(REL, { strict: true }).code).toBe(0);
|
|
604
657
|
});
|
|
605
658
|
test('without --strict a mismatching verification block is not reported', () => {
|
|
606
|
-
|
|
659
|
+
withRecord();
|
|
607
660
|
edit(REL, (t) => t.replace('lint: pass', 'lint: fail'));
|
|
608
661
|
expect(run(REL).code).toBe(0);
|
|
609
662
|
});
|
|
610
663
|
test('strict with a foreign verification commit asks for no receipt of it', () => {
|
|
611
|
-
|
|
664
|
+
withRecord();
|
|
612
665
|
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
613
666
|
const c = codesOf(run(REL, { strict: true }));
|
|
614
667
|
expect(c).toContain('verification-commit-foreign');
|
|
@@ -617,7 +670,7 @@ describe('R4.4 strict checks', () => {
|
|
|
617
670
|
});
|
|
618
671
|
describe('R4.5 output and exit codes', () => {
|
|
619
672
|
test('text report: header, one line per record, one indented line per diagnostic', () => {
|
|
620
|
-
|
|
673
|
+
withRecord();
|
|
621
674
|
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
622
675
|
repo.git('commit', '-q', '-am', 'docs: edit the record');
|
|
623
676
|
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
@@ -626,7 +679,7 @@ describe('R4.5 output and exit codes', () => {
|
|
|
626
679
|
expect(r.stdout).toBe(`Change Record validation: invalid\n- ${REL}: invalid\n - derived-mismatch (type): type is "fix" but git derives "feat"\n`);
|
|
627
680
|
});
|
|
628
681
|
test('text report of a valid run', () => {
|
|
629
|
-
|
|
682
|
+
withRecord();
|
|
630
683
|
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
631
684
|
expect(r.code).toBe(0);
|
|
632
685
|
expect(r.stdout).toBe(`Change Record validation: valid\n- ${REL}: valid\n`);
|
|
@@ -638,12 +691,12 @@ describe('R4.5 output and exit codes', () => {
|
|
|
638
691
|
expect(r.stdout).toBe('Change Record validation: exempt (Change-Record: none)\n');
|
|
639
692
|
});
|
|
640
693
|
test('text report of a missing record names docs/changes', () => {
|
|
641
|
-
const r = (
|
|
694
|
+
const r = untouched('none', undefined, {});
|
|
642
695
|
expect(r.code).toBe(2);
|
|
643
696
|
expect(r.stdout.startsWith('Change Record validation: invalid\n- docs/changes: invalid\n - record-missing: ')).toBe(true);
|
|
644
697
|
});
|
|
645
698
|
test('--json is one line with the documented shape, and a diagnostic without a field has no field key', () => {
|
|
646
|
-
const r =
|
|
699
|
+
const r = untouched('none', undefined, { json: true });
|
|
647
700
|
expect(r.stdout.endsWith('\n')).toBe(true);
|
|
648
701
|
expect(r.stdout.slice(0, -1)).not.toContain('\n');
|
|
649
702
|
const out = report(r);
|
|
@@ -652,13 +705,12 @@ describe('R4.5 output and exit codes', () => {
|
|
|
652
705
|
expect(Object.keys(out.records[0].diagnostics[0]).sort()).toEqual(['code', 'message']);
|
|
653
706
|
});
|
|
654
707
|
test('--json carries the field when a diagnostic names one', () => {
|
|
655
|
-
|
|
708
|
+
withRecord();
|
|
656
709
|
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
657
710
|
expect(Object.keys(diagsOf(run(REL))[0]).sort()).toEqual(['code', 'field', 'message']);
|
|
658
711
|
});
|
|
659
712
|
test('exit code 0 for a valid run', () => {
|
|
660
|
-
|
|
661
|
-
expect(run().code).toBe(0);
|
|
713
|
+
expect(untouched('record', undefined, { json: true }).code).toBe(0);
|
|
662
714
|
});
|
|
663
715
|
test('exit code 0 for an exempt run', () => {
|
|
664
716
|
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
@@ -666,20 +718,20 @@ describe('R4.5 output and exit codes', () => {
|
|
|
666
718
|
expect((0, change_1.runChangeValidate)(repo.root, undefined, {}).code).toBe(0);
|
|
667
719
|
});
|
|
668
720
|
test('exit code 2 for a missing record', () => {
|
|
669
|
-
expect(
|
|
721
|
+
expect(untouched('none', undefined, { json: true }).code).toBe(2);
|
|
670
722
|
});
|
|
671
723
|
test('exit code 2 for an invalid record', () => {
|
|
672
|
-
|
|
724
|
+
withRecord();
|
|
673
725
|
repo.commit({ 'cli/src/more.ts': '1' }, 'chore: more');
|
|
674
726
|
expect(run().code).toBe(2);
|
|
675
727
|
});
|
|
676
728
|
test('a report is never written to stderr', () => {
|
|
677
|
-
expect(
|
|
678
|
-
expect((
|
|
729
|
+
expect(untouched('none', undefined, { json: true }).stderr).toBe('');
|
|
730
|
+
expect(untouched('none', undefined, {}).stderr).toBe('');
|
|
679
731
|
});
|
|
680
732
|
/** A committed record plus `count` unknown top-level keys in the working tree: `count` unknown-field diagnostics. */
|
|
681
733
|
function withUnknownKeys(count) {
|
|
682
|
-
|
|
734
|
+
withRecord();
|
|
683
735
|
const extra = Array.from({ length: count }, (_, i) => `extra${i}: 1`).join('\n');
|
|
684
736
|
edit(REL, (t) => t.replace('schema: awm.change-record/v1', `schema: awm.change-record/v1\n${extra}`));
|
|
685
737
|
return diagsOf(run(REL));
|
|
@@ -705,14 +757,15 @@ describe('R4.5 output and exit codes', () => {
|
|
|
705
757
|
test('at most 50 records are validated; a 51st-and-later record yields too-many-records and an invalid run', () => {
|
|
706
758
|
const files = Object.fromEntries(Array.from({ length: 53 }, (_, i) => [`docs/changes/2026-10-17-r${String(i).padStart(2, '0')}.md`, 'x']));
|
|
707
759
|
repo.commit(files, 'docs: many');
|
|
708
|
-
const
|
|
760
|
+
const r = run();
|
|
761
|
+
const out = report(r);
|
|
709
762
|
expect(out.state).toBe('invalid');
|
|
710
763
|
expect(out.records).toHaveLength(51);
|
|
711
764
|
expect(out.records[0].path).toBe('docs/changes/2026-10-17-r00.md');
|
|
712
765
|
expect(out.records[49].path).toBe('docs/changes/2026-10-17-r49.md');
|
|
713
766
|
expect(out.records[50]).toEqual({ path: 'docs/changes', state: 'invalid', diagnostics: [{ code: 'too-many-records', message: '53 records were added; only the first 50 were validated' }] });
|
|
714
767
|
expect(out.records.slice(0, 50).every((x) => x.diagnostics.every((d) => d.code !== 'too-many-records'))).toBe(true);
|
|
715
|
-
expect(
|
|
768
|
+
expect(r.code).toBe(2);
|
|
716
769
|
});
|
|
717
770
|
test('exactly 50 records are all validated with no too-many-records marker', () => {
|
|
718
771
|
const files = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`docs/changes/2026-10-17-r${String(i).padStart(2, '0')}.md`, 'x']));
|
|
@@ -722,7 +775,7 @@ describe('R4.5 output and exit codes', () => {
|
|
|
722
775
|
expect(out.records.some((x) => x.diagnostics.some((d) => d.code === 'too-many-records'))).toBe(false);
|
|
723
776
|
});
|
|
724
777
|
test('every message is one line of at most 300 units, in both formats', () => {
|
|
725
|
-
|
|
778
|
+
withRecord();
|
|
726
779
|
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('semver: minor', 'semver: patch'));
|
|
727
780
|
repo.git('commit', '-q', '-am', 'docs: edit the record');
|
|
728
781
|
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
@@ -734,7 +787,7 @@ describe('R4.5 output and exit codes', () => {
|
|
|
734
787
|
expect(l.length).toBeLessThanOrEqual(300);
|
|
735
788
|
});
|
|
736
789
|
test('a long record value with quotes and backslashes in a derived-mismatch message stays one bounded, escaped line', () => {
|
|
737
|
-
|
|
790
|
+
withRecord();
|
|
738
791
|
edit(REL, (t) => t.replace('scope: changes', `scope: ${JSON.stringify('"\\'.repeat(300))}`));
|
|
739
792
|
const d = diag(run(REL), 'derived-mismatch', 'scope');
|
|
740
793
|
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
@@ -744,12 +797,12 @@ describe('R4.5 output and exit codes', () => {
|
|
|
744
797
|
expect(l.length).toBeLessThanOrEqual(300);
|
|
745
798
|
});
|
|
746
799
|
test('the modules of a derived-mismatch message are listed as a bracketed list', () => {
|
|
747
|
-
|
|
800
|
+
withRecord();
|
|
748
801
|
edit(REL, (t) => t.replace(' - cli/src', ' - cli/src\n - docs'));
|
|
749
802
|
expect(diag(run(REL), 'derived-mismatch', 'modules').message).toBe('modules is ["cli/src", "docs"] but git derives ["cli/src"]');
|
|
750
803
|
});
|
|
751
804
|
test('a long recorded modules list does not hide the derived value (both halves stay visible)', () => {
|
|
752
|
-
|
|
805
|
+
withRecord();
|
|
753
806
|
const many = Array.from({ length: 40 }, (_, i) => ` - mod/m${String(i).padStart(2, '0')}`).join('\n');
|
|
754
807
|
edit(REL, (t) => t.replace(' - cli/src', ` - cli/src\n${many}`));
|
|
755
808
|
const d = diag(run(REL), 'derived-mismatch', 'modules');
|
|
@@ -758,14 +811,14 @@ describe('R4.5 output and exit codes', () => {
|
|
|
758
811
|
expect(d.message.endsWith('but git derives ["cli/src"]')).toBe(true);
|
|
759
812
|
});
|
|
760
813
|
test('a long derived modules list does not hide the recorded value', () => {
|
|
761
|
-
|
|
814
|
+
withRecord();
|
|
762
815
|
repo.commit(Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`wide/w${String(i).padStart(2, '0')}/x.ts`, '1'])), 'chore: wide');
|
|
763
816
|
const d = diag(run(), 'derived-mismatch', 'modules');
|
|
764
817
|
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
765
818
|
expect(d.message.startsWith('modules is ["cli/src"] but git derives ["cli/src", "wide/w00"')).toBe(true);
|
|
766
819
|
});
|
|
767
820
|
test('when both the recorded and the derived list are long, each is cut to its own share and the whole message stays under the limit', () => {
|
|
768
|
-
|
|
821
|
+
withRecord();
|
|
769
822
|
repo.commit(Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`wide/w${String(i).padStart(2, '0')}/x.ts`, '1'])), 'chore: wide');
|
|
770
823
|
const many = Array.from({ length: 40 }, (_, i) => ` - mod/m${String(i).padStart(2, '0')}`).join('\n');
|
|
771
824
|
repo.commit({ [REL]: fs_1.default.readFileSync(abs(REL), 'utf8').replace(' - cli/src', ` - cli/src\n${many}`) }, 'docs: record');
|
|
@@ -774,7 +827,7 @@ describe('R4.5 output and exit codes', () => {
|
|
|
774
827
|
expect(d.message).toMatch(/^modules is \["cli\/src", "mod\/m00".*… but git derives \["cli\/src", "wide\/w00"/);
|
|
775
828
|
});
|
|
776
829
|
test('a null scope and a boolean are shown as themselves', () => {
|
|
777
|
-
|
|
830
|
+
withRecord();
|
|
778
831
|
edit(REL, (t) => t.replace('scope: changes', 'scope: null').replace('breaking: false', 'breaking: true'));
|
|
779
832
|
const ds = diagsOf(run(REL));
|
|
780
833
|
expect(ds.find((d) => d.field === 'scope')?.message).toBe('scope is null but git derives "changes"');
|
|
@@ -832,55 +885,54 @@ describe('R4.5 operational failures under --json', () => {
|
|
|
832
885
|
});
|
|
833
886
|
describe('R4.3 committed-record verdict (record-uncommitted)', () => {
|
|
834
887
|
test('a clean committed record has no record-uncommitted diagnostic', () => {
|
|
835
|
-
|
|
836
|
-
expect(codesOf(
|
|
837
|
-
expect(
|
|
888
|
+
const r = untouched('record', undefined, { json: true });
|
|
889
|
+
expect(codesOf(r)).not.toContain('record-uncommitted');
|
|
890
|
+
expect(r.code).toBe(0);
|
|
838
891
|
});
|
|
839
892
|
test('an unstaged edit of a committed record is record-uncommitted', () => {
|
|
840
|
-
|
|
893
|
+
withRecord();
|
|
841
894
|
edit(REL, (t) => `${t}\nmore\n`);
|
|
842
895
|
const r = run();
|
|
843
896
|
expect(r.code).toBe(2);
|
|
844
897
|
diag(r, 'record-uncommitted', undefined);
|
|
845
898
|
});
|
|
846
899
|
test('the verdict is not silently computed from the edit: an edit that keeps the record valid is still invalid', () => {
|
|
847
|
-
|
|
900
|
+
withRecord();
|
|
848
901
|
edit(REL, (t) => `${t}\nmore\n`);
|
|
849
902
|
expect(run(REL).code).toBe(0); // the edited file on its own is fine ...
|
|
850
903
|
expect(report(run()).state).toBe('invalid'); // ... but the branch's committed record is not what was validated
|
|
851
904
|
});
|
|
852
905
|
test('a staged but uncommitted edit is record-uncommitted', () => {
|
|
853
|
-
|
|
906
|
+
withRecord();
|
|
854
907
|
edit(REL, (t) => `${t}\nmore\n`);
|
|
855
908
|
repo.git('add', REL);
|
|
856
909
|
diag(run(), 'record-uncommitted', undefined);
|
|
857
910
|
});
|
|
858
911
|
test('an edit that also breaks the shape reports both', () => {
|
|
859
|
-
|
|
912
|
+
withRecord();
|
|
860
913
|
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
861
914
|
const c = codesOf(run());
|
|
862
915
|
expect(c).toContain('record-uncommitted');
|
|
863
916
|
expect(c).toContain('field-invalid');
|
|
864
917
|
});
|
|
865
918
|
test('uncommitted edit of another record does not taint a clean one', () => {
|
|
866
|
-
|
|
867
|
-
commitRecord('bbb');
|
|
919
|
+
withTwoRecords();
|
|
868
920
|
edit('docs/changes/2026-10-17-bbb.md', (t) => `${t}\nmore\n`);
|
|
869
921
|
const out = report(run());
|
|
870
922
|
expect(out.records.map((x) => x.state)).toEqual(['valid', 'invalid']);
|
|
871
923
|
});
|
|
872
924
|
test('an untracked unrelated file is not an uncommitted change of the record', () => {
|
|
873
|
-
|
|
925
|
+
withRecord();
|
|
874
926
|
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs', 'changes', 'notes.md'), 'x');
|
|
875
927
|
expect(run().code).toBe(0);
|
|
876
928
|
});
|
|
877
929
|
test('with an explicit path the working-tree file is validated and no record-uncommitted is reported', () => {
|
|
878
|
-
|
|
930
|
+
withRecord();
|
|
879
931
|
edit(REL, (t) => `${t}\nmore\n`);
|
|
880
932
|
expect(codesOf(run(REL))).not.toContain('record-uncommitted');
|
|
881
933
|
});
|
|
882
934
|
test('a git failure while checking the record fails closed as record-uncommitted', () => {
|
|
883
|
-
|
|
935
|
+
withRecord();
|
|
884
936
|
(0, helpers_1.spyOnGit)((args) => { if (args.includes('status'))
|
|
885
937
|
throw Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }); });
|
|
886
938
|
const r = run();
|
|
@@ -891,9 +943,13 @@ describe('R4.3 committed-record verdict (record-uncommitted)', () => {
|
|
|
891
943
|
describe('one derivation, one ancestry check and one late-file listing per verification commit', () => {
|
|
892
944
|
/** Two valid records that both verify the same commit (the second is a copy of the first under another name). */
|
|
893
945
|
function twoRecordsOneCommit() {
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
946
|
+
fixture('records aaa + copy bbb', () => {
|
|
947
|
+
commitRecord('aaa');
|
|
948
|
+
const copy = fs_1.default.readFileSync(abs('docs/changes/2026-10-17-aaa.md'), 'utf8').replace('id: 2026-10-17-aaa', 'id: 2026-10-17-bbb');
|
|
949
|
+
repo.commit({ 'docs/changes/2026-10-17-bbb.md': copy }, 'docs(changes): bbb');
|
|
950
|
+
});
|
|
951
|
+
const text = fs_1.default.readFileSync(abs('docs/changes/2026-10-17-bbb.md'), 'utf8');
|
|
952
|
+
expect(text).toContain('id: 2026-10-17-bbb');
|
|
897
953
|
return /commit: ([0-9a-f]{40})/.exec(text)?.[1];
|
|
898
954
|
}
|
|
899
955
|
function gitCalls(fn) {
|
|
@@ -929,7 +985,7 @@ describe('a path read through a symlinked working directory or repository root',
|
|
|
929
985
|
return l;
|
|
930
986
|
}
|
|
931
987
|
(0, helpers_1.posixOnly)('a relative path is read from a cwd that is a symlink to the repository', () => {
|
|
932
|
-
|
|
988
|
+
withRecord();
|
|
933
989
|
const r = (0, change_1.runChangeValidate)(link(repo.root), REL, { json: true });
|
|
934
990
|
expect(r.code).toBe(0);
|
|
935
991
|
expect(report(r).records[0].path).toBe(REL);
|
|
@@ -940,41 +996,41 @@ describe('a path read through a symlinked working directory or repository root',
|
|
|
940
996
|
expect(report(r).records[0].diagnostics.map((d) => d.code)).toEqual(['record-missing']);
|
|
941
997
|
});
|
|
942
998
|
(0, helpers_1.posixOnly)('an absolute path through a symlink to the repository is accepted', () => {
|
|
943
|
-
|
|
999
|
+
withRecord();
|
|
944
1000
|
const r = (0, change_1.runChangeValidate)(repo.root, path_1.default.join(link(repo.root), ...REL.split('/')), { json: true });
|
|
945
1001
|
expect(r.code).toBe(0);
|
|
946
1002
|
expect(report(r).records[0].path).toBe(REL);
|
|
947
1003
|
});
|
|
948
1004
|
(0, helpers_1.posixOnly)('a repository root reported through a symlink still resolves a path given from the real directory', () => {
|
|
949
|
-
|
|
1005
|
+
withRecord();
|
|
950
1006
|
jest.spyOn(gitModule, 'repoRoot').mockReturnValue(link(repo.root));
|
|
951
1007
|
const r = (0, change_1.runChangeValidate)(repo.root, abs(REL), { json: true });
|
|
952
1008
|
expect(report(r).records[0].path).toBe(REL);
|
|
953
1009
|
expect(r.code).toBe(0);
|
|
954
1010
|
});
|
|
955
1011
|
(0, helpers_1.posixOnly)('a symlink to somewhere else is still outside the repository', () => {
|
|
956
|
-
|
|
1012
|
+
withRecord();
|
|
957
1013
|
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'else-'));
|
|
958
1014
|
expectFailure((0, change_1.runChangeValidate)(repo.root, path_1.default.join(link(elsewhere), '2026-10-17-rec.md'), {}), 'path-invalid');
|
|
959
1015
|
});
|
|
960
1016
|
});
|
|
961
1017
|
describe('R4.3 shape first', () => {
|
|
962
1018
|
test('an invalid-shape committed record with late code changes reports only the shape diagnostics', () => {
|
|
963
|
-
|
|
1019
|
+
withRecord();
|
|
964
1020
|
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other').replace('type: feat', 'type: fix'));
|
|
965
1021
|
repo.git('commit', '-q', '-am', 'docs: break the record');
|
|
966
1022
|
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
967
1023
|
expect(codesOf(run(undefined, { strict: true }))).toEqual(['field-invalid']);
|
|
968
1024
|
});
|
|
969
1025
|
test('the same record with an explicit path reports only the shape diagnostics', () => {
|
|
970
|
-
|
|
1026
|
+
withRecord();
|
|
971
1027
|
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
972
1028
|
repo.git('commit', '-q', '-am', 'docs: break the record');
|
|
973
1029
|
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
974
1030
|
expect(codesOf(run(REL, { strict: true }))).toEqual(['field-invalid']);
|
|
975
1031
|
});
|
|
976
1032
|
test('once the shape is fixed the other diagnostics appear', () => {
|
|
977
|
-
|
|
1033
|
+
withRecord();
|
|
978
1034
|
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
979
1035
|
expect(codesOf(run())).toEqual(expect.arrayContaining(['code-changed-after-verification', 'derived-mismatch']));
|
|
980
1036
|
});
|
|
@@ -1005,8 +1061,8 @@ describe('R4.6 read-only', () => {
|
|
|
1005
1061
|
['json', undefined, { json: true }],
|
|
1006
1062
|
['a path', REL, { strict: true }],
|
|
1007
1063
|
])('%s run changes no file, no directory and no git state', (_n, target, opts) => {
|
|
1008
|
-
|
|
1009
|
-
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
1064
|
+
withRecord();
|
|
1065
|
+
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late'); // also leaves the copied index fresh, as a real checkout's would be
|
|
1010
1066
|
const before = snapshot();
|
|
1011
1067
|
const status = repo.git('status', '--porcelain', '--ignored');
|
|
1012
1068
|
const r = (0, change_1.runChangeValidate)(repo.root, target, opts);
|
|
@@ -1022,7 +1078,7 @@ describe('R4.6 read-only', () => {
|
|
|
1022
1078
|
expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
|
|
1023
1079
|
});
|
|
1024
1080
|
test('a dirty working tree is not an error', () => {
|
|
1025
|
-
|
|
1081
|
+
withRecord();
|
|
1026
1082
|
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'scratch.txt'), 'x');
|
|
1027
1083
|
expect(run().code).toBe(0);
|
|
1028
1084
|
});
|
|
@@ -1045,11 +1101,11 @@ describe('robustness: never throws, always a CommandResult', () => {
|
|
|
1045
1101
|
expect(r.stderr).toMatch(/^(invalid-input|flag-invalid|path-invalid): [^\n]*\n$/);
|
|
1046
1102
|
});
|
|
1047
1103
|
test('undefined opts are accepted as no flags', () => {
|
|
1048
|
-
|
|
1104
|
+
withRecord();
|
|
1049
1105
|
expect((0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)(undefined)).code).toBe(0);
|
|
1050
1106
|
});
|
|
1051
1107
|
test('an unexpected exception becomes internal-error carrying only the error name', () => {
|
|
1052
|
-
|
|
1108
|
+
withRecord();
|
|
1053
1109
|
jest.spyOn(deriveModule, 'deriveFields').mockImplementation(() => { throw new TypeError('secret\u001b detail'); });
|
|
1054
1110
|
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
1055
1111
|
expectFailure(r, 'internal-error');
|
|
@@ -1057,7 +1113,7 @@ describe('robustness: never throws, always a CommandResult', () => {
|
|
|
1057
1113
|
expect(r.stderr).not.toContain('secret');
|
|
1058
1114
|
});
|
|
1059
1115
|
test('a HEAD that moves during validation is head-moved, not a verdict', () => {
|
|
1060
|
-
|
|
1116
|
+
withRecord();
|
|
1061
1117
|
const real = gitModule.headSha;
|
|
1062
1118
|
let calls = 0;
|
|
1063
1119
|
jest.spyOn(gitModule, 'headSha').mockImplementation((root) => (++calls === 1 ? real(root) : 'f'.repeat(40)));
|
|
@@ -1073,12 +1129,12 @@ describe('robustness: never throws, always a CommandResult', () => {
|
|
|
1073
1129
|
expect(r.stderr).toContain('HEAD moved');
|
|
1074
1130
|
});
|
|
1075
1131
|
test('an unreadable HEAD is git-failed', () => {
|
|
1076
|
-
|
|
1132
|
+
withRecord();
|
|
1077
1133
|
jest.spyOn(gitModule, 'headSha').mockReturnValue(null);
|
|
1078
1134
|
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'git-failed');
|
|
1079
1135
|
});
|
|
1080
1136
|
test('a git failure while listing the added records is git-failed', () => {
|
|
1081
|
-
|
|
1137
|
+
withRecord();
|
|
1082
1138
|
jest.spyOn(gitModule, 'addedFilesUnder').mockReturnValue(null);
|
|
1083
1139
|
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'git-failed');
|
|
1084
1140
|
});
|
|
@@ -1106,13 +1162,13 @@ describe('registerChangeCommand: validate', () => {
|
|
|
1106
1162
|
expect(sub()?.registeredArguments.map((a) => [a.name(), a.required])).toEqual([['path', false]]);
|
|
1107
1163
|
});
|
|
1108
1164
|
test('a valid run prints the report and leaves the exit code alone', async () => {
|
|
1109
|
-
|
|
1165
|
+
withRecord();
|
|
1110
1166
|
await program().parseAsync(['node', 'awm', 'change', 'validate']);
|
|
1111
1167
|
expect(out).toHaveBeenCalledWith(`Change Record validation: valid\n- ${REL}: valid\n`);
|
|
1112
1168
|
expect(process.exitCode).toBe(savedExit);
|
|
1113
1169
|
});
|
|
1114
1170
|
test('flags and the path reach the command', async () => {
|
|
1115
|
-
|
|
1171
|
+
withRecord('rec', 'fail');
|
|
1116
1172
|
await program().parseAsync(['node', 'awm', 'change', 'validate', REL, '--strict', '--json']);
|
|
1117
1173
|
const printed = String(out.mock.calls[0][0]);
|
|
1118
1174
|
expect(JSON.parse(printed).records[0].diagnostics.map((d) => d.code)).toContain('verification-not-passing');
|