agentic-workflow-manager 9.19.0 → 9.20.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 +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/run.js +1 -0
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/core/process/model.js +153 -10
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/process.test.js +4 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/core/dashboard/write-html.test.js +2 -3
- package/dist/tests/core/evidence/store.test.js +2 -3
- package/dist/tests/core/ledger/store.test.js +3 -9
- package/dist/tests/core/process/discover.test.js +21 -0
- package/dist/tests/core/process/model.test.js +338 -0
- package/dist/tests/helpers/platform.js +27 -0
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +78 -0
- package/dist/tests/structural/process-model-description-contract.test.js +34 -0
- package/package.json +1 -1
- 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
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const crypto_1 = __importDefault(require("crypto"));
|
|
40
|
+
const fs_1 = __importDefault(require("fs"));
|
|
41
|
+
const path_1 = __importDefault(require("path"));
|
|
42
|
+
const commander_1 = require("commander");
|
|
43
|
+
const helpers_1 = require("../../core/changes/helpers");
|
|
44
|
+
const change_1 = require("../../../src/commands/change");
|
|
45
|
+
const deriveModule = __importStar(require("../../../src/core/changes/derive"));
|
|
46
|
+
const gitModule = __importStar(require("../../../src/core/changes/git"));
|
|
47
|
+
const receipt_1 = require("../../../src/core/changes/receipt");
|
|
48
|
+
const types_1 = require("../../../src/core/changes/types");
|
|
49
|
+
const REAL_CHANGES_DIR = path_1.default.resolve(__dirname, '..', '..', '..', '..', 'docs', 'changes');
|
|
50
|
+
const listReal = () => { try {
|
|
51
|
+
return fs_1.default.readdirSync(REAL_CHANGES_DIR).sort();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
} };
|
|
56
|
+
const realBefore = listReal();
|
|
57
|
+
// The command only reads the throwaway repository: the real checkout is never touched.
|
|
58
|
+
afterEach(() => expect(listReal()).toEqual(realBefore));
|
|
59
|
+
let repo;
|
|
60
|
+
let template;
|
|
61
|
+
const now = () => new Date('2026-10-17T12:00:00.000Z');
|
|
62
|
+
const REL = 'docs/changes/2026-10-17-rec.md';
|
|
63
|
+
const abs = (rel) => path_1.default.join(repo.root, ...rel.split('/'));
|
|
64
|
+
beforeAll(() => {
|
|
65
|
+
template = (0, helpers_1.makeRepo)();
|
|
66
|
+
template.git('checkout', '-q', '-b', 'feat/x');
|
|
67
|
+
template.commit({ 'cli/src/a.ts': '1' }, 'feat(changes): a');
|
|
68
|
+
});
|
|
69
|
+
afterAll(() => template.cleanup());
|
|
70
|
+
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
71
|
+
afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
|
|
72
|
+
/** A receipt for HEAD whose verdict and every sensor (default: `lint`) are `overall`. */
|
|
73
|
+
function receipt(overall = 'pass', sensors = ['lint']) {
|
|
74
|
+
(0, receipt_1.writeReceipt)(repo.root, (0, receipt_1.buildReceipt)({ commit: repo.git('rev-parse', 'HEAD'), ranAt: '2026-10-17T11:00:00.000Z', cliVersion: '9.20.0', output: { overall, sensors: sensors.map((name) => ({ name, status: overall, errors: [] })) } }));
|
|
75
|
+
}
|
|
76
|
+
/** Creates the record of `slug` for HEAD (receipt included), fills the narrative and commits it. */
|
|
77
|
+
function commitRecord(slug = 'rec', overall = 'pass', sensors = ['lint']) {
|
|
78
|
+
receipt(overall, sensors);
|
|
79
|
+
expect((0, change_1.runChangeNew)(repo.root, slug, {}, { now }).code).toBe(0);
|
|
80
|
+
const rel = `docs/changes/2026-10-17-${slug}.md`;
|
|
81
|
+
fs_1.default.writeFileSync(abs(rel), fs_1.default.readFileSync(abs(rel), 'utf8').split(types_1.PENDING_MARKER).join('filled'));
|
|
82
|
+
repo.git('add', '-A');
|
|
83
|
+
repo.git('commit', '-q', '-m', `docs(changes): ${slug}`);
|
|
84
|
+
return rel;
|
|
85
|
+
}
|
|
86
|
+
/** Edits the working-tree copy of a record (nothing is committed). */
|
|
87
|
+
function edit(rel, fn) {
|
|
88
|
+
const before = fs_1.default.readFileSync(abs(rel), 'utf8');
|
|
89
|
+
const after = fn(before);
|
|
90
|
+
expect(after).not.toBe(before);
|
|
91
|
+
fs_1.default.writeFileSync(abs(rel), after);
|
|
92
|
+
}
|
|
93
|
+
const run = (target = undefined, opts = {}) => (0, change_1.runChangeValidate)(repo.root, target, { json: true, ...opts });
|
|
94
|
+
const report = (r) => JSON.parse(r.stdout);
|
|
95
|
+
const diagsOf = (r, index = 0) => report(r).records[index].diagnostics;
|
|
96
|
+
const codesOf = (r, index = 0) => diagsOf(r, index).map((d) => d.code);
|
|
97
|
+
/** The one diagnostic with this code: asserts its field too (undefined when it names no field). */
|
|
98
|
+
function diag(r, code, field) {
|
|
99
|
+
const found = diagsOf(r).filter((d) => d.code === code);
|
|
100
|
+
expect(found).toHaveLength(1);
|
|
101
|
+
expect(found[0].field).toBe(field);
|
|
102
|
+
return found[0];
|
|
103
|
+
}
|
|
104
|
+
function expectFailure(r, code) {
|
|
105
|
+
expect(r.code).toBe(2);
|
|
106
|
+
expect(r.stdout).toBe('');
|
|
107
|
+
expect(r.stderr.startsWith(`${code}: `)).toBe(true);
|
|
108
|
+
const lines = r.stderr.split('\n');
|
|
109
|
+
expect(lines[0].length).toBeLessThanOrEqual(300);
|
|
110
|
+
expect(lines[lines.length - 1]).toBe('');
|
|
111
|
+
}
|
|
112
|
+
const rawUnsafe = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
113
|
+
describe('R4.1 target selection', () => {
|
|
114
|
+
test('without a path every record added in merge-base..HEAD is validated', () => {
|
|
115
|
+
commitRecord('aaa');
|
|
116
|
+
commitRecord('bbb');
|
|
117
|
+
const r = run();
|
|
118
|
+
expect(report(r).records.map((x) => x.path)).toEqual(['docs/changes/2026-10-17-aaa.md', 'docs/changes/2026-10-17-bbb.md']);
|
|
119
|
+
expect(r.code).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
test('one invalid record among several makes the run invalid and is the one reported as such', () => {
|
|
122
|
+
commitRecord('aaa');
|
|
123
|
+
commitRecord('bbb');
|
|
124
|
+
fs_1.default.writeFileSync(abs('docs/changes/2026-10-17-bbb.md'), 'not a record');
|
|
125
|
+
const out = report(run());
|
|
126
|
+
expect(out.state).toBe('invalid');
|
|
127
|
+
expect(out.records.map((x) => x.state)).toEqual(['valid', 'invalid']);
|
|
128
|
+
});
|
|
129
|
+
test('a record that already exists at the merge base and is only modified is not an added record', () => {
|
|
130
|
+
const rel = commitRecord();
|
|
131
|
+
repo.git('branch', '-f', 'main', 'HEAD');
|
|
132
|
+
repo.commit({ [rel]: fs_1.default.readFileSync(abs(rel), 'utf8') + '\nmore\n' }, 'docs: touch the record');
|
|
133
|
+
expect(codesOf(run())).toEqual(['record-missing']);
|
|
134
|
+
});
|
|
135
|
+
test('a file in docs/changes that is not named like a record is not a record', () => {
|
|
136
|
+
repo.commit({ 'docs/changes/notes.md': 'x', 'docs/changes/sub/2026-10-17-deep.md': 'x', 'other/2026-10-17-rec.md': 'x' }, 'docs: notes');
|
|
137
|
+
expect(codesOf(run())).toEqual(['record-missing']);
|
|
138
|
+
});
|
|
139
|
+
test('with a path only that file is validated', () => {
|
|
140
|
+
commitRecord('aaa');
|
|
141
|
+
commitRecord('bbb');
|
|
142
|
+
fs_1.default.writeFileSync(abs('docs/changes/2026-10-17-bbb.md'), 'not a record');
|
|
143
|
+
const r = run('docs/changes/2026-10-17-aaa.md');
|
|
144
|
+
expect(report(r).records.map((x) => x.path)).toEqual(['docs/changes/2026-10-17-aaa.md']);
|
|
145
|
+
expect(r.code).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
test('a path is read relative to the working directory, from a subdirectory', () => {
|
|
148
|
+
const rel = commitRecord();
|
|
149
|
+
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
|
+
expect(report(r).records[0].path).toBe(rel);
|
|
151
|
+
expect(r.code).toBe(0);
|
|
152
|
+
});
|
|
153
|
+
test('an absolute path inside the repository is accepted', () => {
|
|
154
|
+
const rel = commitRecord();
|
|
155
|
+
const r = (0, change_1.runChangeValidate)(repo.root, abs(rel), { json: true });
|
|
156
|
+
expect(report(r).records[0].path).toBe(rel);
|
|
157
|
+
expect(r.code).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
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 = commitRecord();
|
|
161
|
+
repo.git('branch', '-f', 'main', 'HEAD');
|
|
162
|
+
const r = run(rel);
|
|
163
|
+
expect(r.code).toBe(2);
|
|
164
|
+
expect(report(r).records[0].path).toBe(rel);
|
|
165
|
+
diag(r, 'no-commits', undefined);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('target path safety', () => {
|
|
169
|
+
test.each([
|
|
170
|
+
['outside the repository', '../x.md'],
|
|
171
|
+
['not in docs/changes', 'README.md'],
|
|
172
|
+
['in a subdirectory of docs/changes', 'docs/changes/sub/2026-10-17-rec.md'],
|
|
173
|
+
['not named like a record', 'docs/changes/notes.md'],
|
|
174
|
+
['an uppercase slug', 'docs/changes/2026-10-17-Rec.md'],
|
|
175
|
+
['the directory itself', 'docs/changes'],
|
|
176
|
+
['an empty string', ''],
|
|
177
|
+
['a NUL byte', 'docs/changes/2026-10-17-rec.md\u0000x'],
|
|
178
|
+
['an escape character', 'docs/changes/\u001b[31m.md'],
|
|
179
|
+
['a newline', 'docs/changes/a\nb.md'],
|
|
180
|
+
])('path-invalid: %s', (_name, target) => {
|
|
181
|
+
const r = (0, change_1.runChangeValidate)(repo.root, target, {});
|
|
182
|
+
expectFailure(r, 'path-invalid');
|
|
183
|
+
expect(r.stderr).not.toMatch(rawUnsafe);
|
|
184
|
+
});
|
|
185
|
+
test.each(['\u202e', '\u2066', '\u200f', '\u061c'])('path-invalid: a bidi control %j in the target is echoed escaped, on stderr and in --json', (c) => {
|
|
186
|
+
const target = `docs/changes/a${c}b.md`;
|
|
187
|
+
const escaped = `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
188
|
+
const plain = (0, change_1.runChangeValidate)(repo.root, target, {});
|
|
189
|
+
expectFailure(plain, 'path-invalid');
|
|
190
|
+
expect(plain.stderr).toContain(escaped);
|
|
191
|
+
expect(plain.stderr).not.toContain(c);
|
|
192
|
+
const json = (0, change_1.runChangeValidate)(repo.root, target, { json: true });
|
|
193
|
+
expect(json.stdout + json.stderr).toContain(escaped);
|
|
194
|
+
expect(json.stdout + json.stderr).not.toContain(c);
|
|
195
|
+
});
|
|
196
|
+
test('path-invalid: an absolute path outside the repository', () => {
|
|
197
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, path_1.default.join(repo.home, '2026-10-17-rec.md'), {}), 'path-invalid');
|
|
198
|
+
});
|
|
199
|
+
test('path-invalid: a hostile long path is clipped to one line of at most 300 units', () => {
|
|
200
|
+
const r = (0, change_1.runChangeValidate)(repo.root, `docs/changes/${'\u001b'.repeat(500)}.md`, {});
|
|
201
|
+
expectFailure(r, 'path-invalid');
|
|
202
|
+
expect(r.stderr.length).toBeLessThanOrEqual(301);
|
|
203
|
+
});
|
|
204
|
+
test('R1.1 a file with the record shape but an impossible date is a record candidate: read and reported as name-invalid', () => {
|
|
205
|
+
repo.commit({ 'docs/changes/2026-13-40-bad.md': 'x' }, 'docs: bad date');
|
|
206
|
+
const r = run();
|
|
207
|
+
expect(r.code).toBe(2);
|
|
208
|
+
expect(report(r).records[0].path).toBe('docs/changes/2026-13-40-bad.md');
|
|
209
|
+
diag(r, 'name-invalid', undefined);
|
|
210
|
+
});
|
|
211
|
+
test('R1.1 a file with the record shape but a slug over 64 characters is a record candidate: read and reported as name-invalid', () => {
|
|
212
|
+
const name = `docs/changes/2026-10-17-${'a'.repeat(65)}.md`;
|
|
213
|
+
repo.commit({ [name]: 'x' }, 'docs: long slug');
|
|
214
|
+
const r = run();
|
|
215
|
+
expect(r.code).toBe(2);
|
|
216
|
+
expect(report(r).records[0].path).toBe(name);
|
|
217
|
+
diag(r, 'name-invalid', undefined);
|
|
218
|
+
});
|
|
219
|
+
test('a missing record file is record-missing', () => {
|
|
220
|
+
const r = run('docs/changes/2026-10-17-nope.md');
|
|
221
|
+
expect(r.code).toBe(2);
|
|
222
|
+
diag(r, 'record-missing', undefined);
|
|
223
|
+
});
|
|
224
|
+
(0, helpers_1.posixOnly)('a record that is a symlink is record-missing and is never read', () => {
|
|
225
|
+
commitRecord();
|
|
226
|
+
const outside = path_1.default.join(fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'outside-')), 'outside.md');
|
|
227
|
+
fs_1.default.writeFileSync(outside, fs_1.default.readFileSync(abs(REL)));
|
|
228
|
+
fs_1.default.rmSync(abs(REL));
|
|
229
|
+
fs_1.default.symlinkSync(outside, abs(REL));
|
|
230
|
+
const r = run(REL);
|
|
231
|
+
expect(r.code).toBe(2);
|
|
232
|
+
diag(r, 'record-missing', undefined);
|
|
233
|
+
});
|
|
234
|
+
(0, helpers_1.posixOnly)('a symlinked docs/changes directory is unsafe-path for every record', () => {
|
|
235
|
+
commitRecord();
|
|
236
|
+
const moved = path_1.default.join(fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'moved-')), 'changes');
|
|
237
|
+
fs_1.default.renameSync(path_1.default.join(repo.root, 'docs', 'changes'), moved);
|
|
238
|
+
fs_1.default.symlinkSync(moved, path_1.default.join(repo.root, 'docs', 'changes'));
|
|
239
|
+
diag(run(REL), 'unsafe-path', undefined);
|
|
240
|
+
diag(run(), 'unsafe-path', undefined);
|
|
241
|
+
});
|
|
242
|
+
test('a missing docs/changes directory is record-missing for an explicit path', () => {
|
|
243
|
+
diag(run(REL), 'record-missing', undefined);
|
|
244
|
+
});
|
|
245
|
+
test('a record of 65537 bytes is record-too-large and its content is not parsed', () => {
|
|
246
|
+
repo.commit({ [REL]: 'x'.repeat(types_1.MAX_RECORD_BYTES + 1) }, 'docs: big');
|
|
247
|
+
const r = run();
|
|
248
|
+
expect(codesOf(r)).toEqual(['record-too-large']);
|
|
249
|
+
expect(r.code).toBe(2);
|
|
250
|
+
});
|
|
251
|
+
test('record-too-large also applies to an explicit path', () => {
|
|
252
|
+
repo.commit({ [REL]: 'x'.repeat(types_1.MAX_RECORD_BYTES + 1) }, 'docs: big');
|
|
253
|
+
diag(run(REL), 'record-too-large', undefined);
|
|
254
|
+
});
|
|
255
|
+
test('a record of exactly 65536 bytes is read and judged on its content', () => {
|
|
256
|
+
repo.commit({ [REL]: 'x'.repeat(types_1.MAX_RECORD_BYTES) }, 'docs: limit');
|
|
257
|
+
expect(codesOf(run())).toEqual(['frontmatter-missing']);
|
|
258
|
+
});
|
|
259
|
+
test('a record that is not valid UTF-8 is record-invalid (not record-too-large)', () => {
|
|
260
|
+
commitRecord();
|
|
261
|
+
fs_1.default.writeFileSync(abs(REL), Buffer.from([0x2d, 0x2d, 0x2d, 0xff, 0xfe, 0x0a]));
|
|
262
|
+
expect(codesOf(run(REL))).toEqual(['record-invalid']);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
describe('R4.2 missing record and the opt-out trailer', () => {
|
|
266
|
+
test('no record and no trailer fails with record-missing, state invalid, exit 2', () => {
|
|
267
|
+
const r = run();
|
|
268
|
+
expect(r.code).toBe(2);
|
|
269
|
+
expect(report(r).state).toBe('invalid');
|
|
270
|
+
diag(r, 'record-missing', undefined);
|
|
271
|
+
});
|
|
272
|
+
test('a Change-Record: none trailer in the range passes as exempt with no records', () => {
|
|
273
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
274
|
+
const r = run();
|
|
275
|
+
expect(r.code).toBe(0);
|
|
276
|
+
expect(report(r)).toEqual({ state: 'exempt', records: [] });
|
|
277
|
+
});
|
|
278
|
+
test('the trailer of a commit at or below the merge base does not exempt the branch', () => {
|
|
279
|
+
repo.git('checkout', '-q', 'main');
|
|
280
|
+
repo.commit({ 'm.ts': '1' }, 'docs: m\n\nChange-Record: none');
|
|
281
|
+
repo.git('checkout', '-q', '-b', 'feat/y');
|
|
282
|
+
repo.commit({ 'cli/src/b.ts': '1' }, 'feat(changes): b');
|
|
283
|
+
const r = run();
|
|
284
|
+
expect(r.code).toBe(2);
|
|
285
|
+
diag(r, 'record-missing', undefined);
|
|
286
|
+
});
|
|
287
|
+
test('R4.2 the trailer counts on ANY commit of the range: only in the first one, with later commits that carry none', () => {
|
|
288
|
+
repo.git('checkout', '-q', 'main');
|
|
289
|
+
repo.git('checkout', '-q', '-b', 'feat/first');
|
|
290
|
+
repo.commit({ 'a1.ts': '1' }, 'docs: a1\n\nChange-Record: none');
|
|
291
|
+
repo.commit({ 'a2.ts': '1' }, 'docs: a2');
|
|
292
|
+
repo.commit({ 'a3.ts': '1' }, 'docs: a3');
|
|
293
|
+
const r = run();
|
|
294
|
+
expect(r.code).toBe(0);
|
|
295
|
+
expect(report(r)).toEqual({ state: 'exempt', records: [] });
|
|
296
|
+
});
|
|
297
|
+
test('R4.2 the trailer counts on a commit in the middle of the range, not the tip', () => {
|
|
298
|
+
repo.git('checkout', '-q', 'main');
|
|
299
|
+
repo.git('checkout', '-q', '-b', 'feat/middle');
|
|
300
|
+
repo.commit({ 'a1.ts': '1' }, 'docs: a1');
|
|
301
|
+
repo.commit({ 'a2.ts': '1' }, 'docs: a2\n\nChange-Record: none');
|
|
302
|
+
repo.commit({ 'a3.ts': '1' }, 'docs: a3');
|
|
303
|
+
expect(report(run())).toEqual({ state: 'exempt', records: [] });
|
|
304
|
+
});
|
|
305
|
+
test('trailer-like text in a subject does not exempt', () => {
|
|
306
|
+
repo.commit({ 'b.ts': '1' }, 'docs: Change-Record: none');
|
|
307
|
+
diag(run(), 'record-missing', undefined);
|
|
308
|
+
});
|
|
309
|
+
test('a trailer outside the final paragraph does not exempt', () => {
|
|
310
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c\n\nChange-Record: none\n\nSigned-off-by: someone');
|
|
311
|
+
diag(run(), 'record-missing', undefined);
|
|
312
|
+
});
|
|
313
|
+
test('an added record is validated (and reported) even when a trailer is present', () => {
|
|
314
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
315
|
+
commitRecord();
|
|
316
|
+
const r = run();
|
|
317
|
+
expect(report(r).state).toBe('valid');
|
|
318
|
+
expect(report(r).records).toHaveLength(1);
|
|
319
|
+
});
|
|
320
|
+
test('a path run never consults the trailer', () => {
|
|
321
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
322
|
+
diag(run(REL), 'record-missing', undefined);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
describe('base resolution (R3.1, R3.2, R3.5)', () => {
|
|
326
|
+
test('--base selects the base: a base at HEAD leaves no added record', () => {
|
|
327
|
+
commitRecord();
|
|
328
|
+
repo.git('branch', 'tip', 'HEAD');
|
|
329
|
+
expect(run().code).toBe(0);
|
|
330
|
+
diag(run(undefined, { base: 'tip' }), 'record-missing', undefined);
|
|
331
|
+
});
|
|
332
|
+
test('an unresolvable --base is base-unresolved with its remedy', () => {
|
|
333
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, { base: 'no-such-branch' });
|
|
334
|
+
expectFailure(r, 'base-unresolved');
|
|
335
|
+
expect(r.stderr).toContain('remedy: pass --base <ref>');
|
|
336
|
+
});
|
|
337
|
+
test('no main, master or origin/HEAD is base-unresolved', () => {
|
|
338
|
+
repo.git('branch', '-m', 'main', 'trunk');
|
|
339
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'base-unresolved');
|
|
340
|
+
});
|
|
341
|
+
test.each(['--output=x', 'a..b', '-x', 'a b', 'main^', 'x\ny', '\u001b[31m'])('an unsafe --base %p is invalid-input and git is never given it', (base) => {
|
|
342
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, { base });
|
|
343
|
+
expectFailure(r, 'invalid-input');
|
|
344
|
+
expect(r.stderr).not.toMatch(rawUnsafe);
|
|
345
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'x'))).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
test('a repository without a common ancestor with the base is base-unresolved', () => {
|
|
348
|
+
repo.git('checkout', '-q', '--orphan', 'island');
|
|
349
|
+
repo.git('rm', '-rfq', '.');
|
|
350
|
+
repo.commit({ 'island.ts': '1' }, 'feat: island');
|
|
351
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'base-unresolved');
|
|
352
|
+
});
|
|
353
|
+
test('outside a git repository is not-a-git-repo', () => {
|
|
354
|
+
expectFailure((0, change_1.runChangeValidate)((0, helpers_1.nonRepoDir)(repo), undefined, {}), 'not-a-git-repo');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
describe('R4.3 base checks', () => {
|
|
358
|
+
test('a filled record that matches git passes', () => {
|
|
359
|
+
commitRecord();
|
|
360
|
+
const r = run();
|
|
361
|
+
expect(r.code).toBe(0);
|
|
362
|
+
expect(report(r)).toMatchObject({ state: 'valid', records: [{ path: REL, state: 'valid', diagnostics: [] }] });
|
|
363
|
+
});
|
|
364
|
+
test('R1 shape errors are reported with their own code and field', () => {
|
|
365
|
+
commitRecord();
|
|
366
|
+
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
367
|
+
diag(run(REL), 'field-invalid', 'schema');
|
|
368
|
+
});
|
|
369
|
+
test('an unfilled record reports pending-content', () => {
|
|
370
|
+
receipt();
|
|
371
|
+
expect((0, change_1.runChangeNew)(repo.root, 'rec', {}, { now }).code).toBe(0);
|
|
372
|
+
repo.git('add', '-A');
|
|
373
|
+
repo.git('commit', '-q', '-m', 'docs(changes): rec');
|
|
374
|
+
diag(run(), 'pending-content', undefined);
|
|
375
|
+
});
|
|
376
|
+
test('a record that fails R1 is not checked against git (no record to compare)', () => {
|
|
377
|
+
commitRecord();
|
|
378
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('schema: awm.change-record/v1', 'schema: other'));
|
|
379
|
+
expect(codesOf(run(REL))).not.toContain('derived-mismatch');
|
|
380
|
+
});
|
|
381
|
+
test.each([
|
|
382
|
+
['type', 'type: feat', 'type: fix'],
|
|
383
|
+
['semver', 'semver: minor', 'semver: patch'],
|
|
384
|
+
['breaking', 'breaking: false', 'breaking: true'],
|
|
385
|
+
['scope', 'scope: changes', 'scope: other'],
|
|
386
|
+
['modules', ' - cli/src', ' - cli/src\n - docs'],
|
|
387
|
+
])('derived-mismatch names the field %s', (field, from, to) => {
|
|
388
|
+
commitRecord();
|
|
389
|
+
edit(REL, (t) => t.replace(from, to));
|
|
390
|
+
const r = run(REL);
|
|
391
|
+
expect(r.code).toBe(2);
|
|
392
|
+
expect(diagsOf(r).filter((d) => d.code === 'derived-mismatch').map((d) => d.field)).toEqual([field]);
|
|
393
|
+
});
|
|
394
|
+
test('every mismatching derived field gets its own diagnostic', () => {
|
|
395
|
+
commitRecord();
|
|
396
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('semver: minor', 'semver: patch'));
|
|
397
|
+
expect(diagsOf(run(REL)).filter((d) => d.code === 'derived-mismatch').map((d) => d.field).sort()).toEqual(['semver', 'type']);
|
|
398
|
+
});
|
|
399
|
+
test('a derived-mismatch message states the recorded and the derived value on one line', () => {
|
|
400
|
+
commitRecord();
|
|
401
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
402
|
+
const d = diag(run(REL), 'derived-mismatch', 'type');
|
|
403
|
+
expect(d.message).toBe('type is "fix" but git derives "feat"');
|
|
404
|
+
});
|
|
405
|
+
test('a git-derivation failure is reported once with the derive code (no-conventional-commits)', () => {
|
|
406
|
+
commitRecord();
|
|
407
|
+
repo.git('reset', '-q', '--soft', 'main');
|
|
408
|
+
repo.git('commit', '-q', '-m', 'work in progress');
|
|
409
|
+
const r = run(REL);
|
|
410
|
+
expect(codesOf(r)).toContain('no-conventional-commits');
|
|
411
|
+
expect(codesOf(r)).not.toContain('derived-mismatch');
|
|
412
|
+
});
|
|
413
|
+
test('a verification commit that is not in the repository is verification-commit-foreign', () => {
|
|
414
|
+
commitRecord();
|
|
415
|
+
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
416
|
+
diag(run(REL), 'verification-commit-foreign', 'verification.commit');
|
|
417
|
+
});
|
|
418
|
+
test('a verification commit on another branch is verification-commit-foreign', () => {
|
|
419
|
+
commitRecord();
|
|
420
|
+
const other = (() => {
|
|
421
|
+
repo.git('checkout', '-q', '-b', 'side', 'main');
|
|
422
|
+
const sha = repo.commit({ 'side.ts': '1' }, 'feat: side');
|
|
423
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
424
|
+
return sha;
|
|
425
|
+
})();
|
|
426
|
+
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${other}`));
|
|
427
|
+
diag(run(REL), 'verification-commit-foreign', 'verification.commit');
|
|
428
|
+
});
|
|
429
|
+
test('a verification commit equal to HEAD is accepted', () => {
|
|
430
|
+
commitRecord();
|
|
431
|
+
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${repo.git('rev-parse', 'HEAD')}`));
|
|
432
|
+
const codes = codesOf(run(REL));
|
|
433
|
+
expect(codes).not.toContain('verification-commit-foreign');
|
|
434
|
+
expect(codes).not.toContain('code-changed-after-verification');
|
|
435
|
+
});
|
|
436
|
+
test('a foreign verification commit does not also report code-changed-after-verification', () => {
|
|
437
|
+
commitRecord();
|
|
438
|
+
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
439
|
+
expect(codesOf(run(REL))).not.toContain('code-changed-after-verification');
|
|
440
|
+
});
|
|
441
|
+
test('code changed after the verified commit is code-changed-after-verification', () => {
|
|
442
|
+
commitRecord();
|
|
443
|
+
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
444
|
+
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
445
|
+
expect(d.message).toContain('cli/src/late.ts');
|
|
446
|
+
expect(codesOf(run())).not.toContain('derived-mismatch'); // same module, same type: only the late code is wrong
|
|
447
|
+
});
|
|
448
|
+
test('files under docs/changes changed after the verified commit are not code', () => {
|
|
449
|
+
commitRecord();
|
|
450
|
+
repo.commit({ 'docs/changes/notes.md': 'n' }, 'docs: notes');
|
|
451
|
+
expect(codesOf(run())).not.toContain('code-changed-after-verification');
|
|
452
|
+
});
|
|
453
|
+
test('a path that merely starts like docs/changes is code', () => {
|
|
454
|
+
commitRecord();
|
|
455
|
+
repo.commit({ 'docs/changes-extra/x.md': 'n' }, 'chore: extra');
|
|
456
|
+
expect(codesOf(run())).toContain('code-changed-after-verification');
|
|
457
|
+
});
|
|
458
|
+
test('late code and derived drift are both reported', () => {
|
|
459
|
+
commitRecord();
|
|
460
|
+
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
461
|
+
const c = codesOf(run());
|
|
462
|
+
expect(c).toContain('code-changed-after-verification');
|
|
463
|
+
expect(c).toContain('derived-mismatch');
|
|
464
|
+
});
|
|
465
|
+
test('one late file is reported as "1 file" and two as "2 files"', () => {
|
|
466
|
+
commitRecord();
|
|
467
|
+
repo.commit({ 'cli/src/one.ts': '1' }, 'chore: one');
|
|
468
|
+
expect(diag(run(), 'code-changed-after-verification', undefined).message).toMatch(/^1 file changed after [0-9a-f]{12}: cli\/src\/one\.ts$/);
|
|
469
|
+
repo.commit({ 'cli/src/two.ts': '1' }, 'chore: two');
|
|
470
|
+
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
|
+
});
|
|
472
|
+
test('the late-file list names at most 20 paths and counts them all', () => {
|
|
473
|
+
commitRecord();
|
|
474
|
+
repo.commit(Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`a${String(i).padStart(2, '0')}.ts`, '1'])), 'chore: late');
|
|
475
|
+
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
476
|
+
expect(d.message).toContain('25 files changed after');
|
|
477
|
+
expect(d.message).toContain('a00.ts');
|
|
478
|
+
expect(d.message).toContain('a19.ts, …');
|
|
479
|
+
expect(d.message).not.toContain('a20.ts');
|
|
480
|
+
});
|
|
481
|
+
test('exactly 20 late files are all listed with no ellipsis', () => {
|
|
482
|
+
commitRecord();
|
|
483
|
+
repo.commit(Object.fromEntries(Array.from({ length: 20 }, (_, i) => [`a${String(i).padStart(2, '0')}.ts`, '1'])), 'chore: late');
|
|
484
|
+
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
485
|
+
expect(d.message.endsWith('a19.ts')).toBe(true);
|
|
486
|
+
});
|
|
487
|
+
test('a long late-file list stays one line of at most 300 units', () => {
|
|
488
|
+
commitRecord();
|
|
489
|
+
repo.commit(Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`cli/src/late/${'d'.repeat(60)}${i}.ts`, '1'])), 'chore: late');
|
|
490
|
+
const d = diag(run(), 'code-changed-after-verification', undefined);
|
|
491
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
492
|
+
expect(d.message).not.toContain('\n');
|
|
493
|
+
});
|
|
494
|
+
(0, helpers_1.posixOnly)('a late file named with an escape sequence and a newline never reaches the output raw', () => {
|
|
495
|
+
commitRecord();
|
|
496
|
+
repo.commit({ 'cli/src/\u001b[31mred\nline.ts': '1' }, 'chore: hostile');
|
|
497
|
+
const text = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
498
|
+
const json = run();
|
|
499
|
+
for (const r of [text, json]) {
|
|
500
|
+
expect(r.stdout).not.toMatch(/\u001b/);
|
|
501
|
+
expect(r.stdout.replace(/\n$/, '')).not.toMatch(/[\u0000-\u0008\u000b-\u001f]/);
|
|
502
|
+
}
|
|
503
|
+
expect(diag(json, 'code-changed-after-verification', undefined).message).toContain('\\u001b');
|
|
504
|
+
});
|
|
505
|
+
test('a git failure while listing the late files is reported, not thrown', () => {
|
|
506
|
+
commitRecord();
|
|
507
|
+
const verified = repo.git('rev-parse', 'HEAD~1');
|
|
508
|
+
const real = gitModule.changedFiles;
|
|
509
|
+
// Only the late-file listing (which starts at the verified commit) fails; the derivation's own diff still works.
|
|
510
|
+
jest.spyOn(gitModule, 'changedFiles').mockImplementation((root, from, to) => (from === verified ? null : real(root, from, to)));
|
|
511
|
+
const r = run();
|
|
512
|
+
expect(r.code).toBe(2);
|
|
513
|
+
expect(diag(r, 'git-failed', undefined).message).toContain('git could not list the files changed since');
|
|
514
|
+
expect(codesOf(r)).not.toContain('derived-mismatch');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
describe('R4.4 strict checks', () => {
|
|
518
|
+
test('strict passes with a matching, passing receipt', () => {
|
|
519
|
+
commitRecord();
|
|
520
|
+
const r = run(undefined, { strict: true });
|
|
521
|
+
expect(r.code).toBe(0);
|
|
522
|
+
expect(report(r).state).toBe('valid');
|
|
523
|
+
});
|
|
524
|
+
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
|
+
commitRecord('rec', 'pass', ['tsc', '9', '10']);
|
|
526
|
+
expect(run(undefined, { strict: true }).code).toBe(0);
|
|
527
|
+
// The same sensors, listed in another order in the record's YAML: the verdict is the same.
|
|
528
|
+
edit(REL, (t) => t.replace(/ sensors:[\s\S]*?(?= receipt_digest)/, " sensors: { tsc: pass, '10': pass, '9': pass }\n"));
|
|
529
|
+
repo.git('add', '-A');
|
|
530
|
+
repo.git('commit', '-q', '-m', 'docs(changes): reorder sensors');
|
|
531
|
+
const r = run(undefined, { strict: true });
|
|
532
|
+
expect(report(r).state).toBe('valid');
|
|
533
|
+
expect(r.code).toBe(0);
|
|
534
|
+
});
|
|
535
|
+
test('base mode never reads the receipt', () => {
|
|
536
|
+
commitRecord();
|
|
537
|
+
fs_1.default.rmSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs'), { recursive: true, force: true });
|
|
538
|
+
expect(run().code).toBe(0);
|
|
539
|
+
});
|
|
540
|
+
test('strict: a missing receipt is receipt-missing', () => {
|
|
541
|
+
commitRecord();
|
|
542
|
+
fs_1.default.rmSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs'), { recursive: true, force: true });
|
|
543
|
+
diag(run(undefined, { strict: true }), 'receipt-missing', 'verification.commit');
|
|
544
|
+
});
|
|
545
|
+
test('strict: a receipt that is not valid JSON is receipt-invalid', () => {
|
|
546
|
+
commitRecord();
|
|
547
|
+
const sha = /commit: ([0-9a-f]{40})/.exec(fs_1.default.readFileSync(abs(REL), 'utf8'))?.[1];
|
|
548
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${sha}.json`), '{ nope');
|
|
549
|
+
diag(run(undefined, { strict: true }), 'receipt-invalid', 'verification.commit');
|
|
550
|
+
});
|
|
551
|
+
test('strict: a receipt whose content was edited (digest broken) is receipt-invalid', () => {
|
|
552
|
+
commitRecord();
|
|
553
|
+
const sha = /commit: ([0-9a-f]{40})/.exec(fs_1.default.readFileSync(abs(REL), 'utf8'))?.[1];
|
|
554
|
+
const file = path_1.default.join(repo.root, '.awm', 'sensors', 'runs', `${sha}.json`);
|
|
555
|
+
fs_1.default.writeFileSync(file, fs_1.default.readFileSync(file, 'utf8').replace('"pass"', '"fail"'));
|
|
556
|
+
diag(run(undefined, { strict: true }), 'receipt-invalid', 'verification.commit');
|
|
557
|
+
});
|
|
558
|
+
test('without --strict a missing or failing receipt is not reported', () => {
|
|
559
|
+
commitRecord('rec', 'fail');
|
|
560
|
+
expect(run().code).toBe(0);
|
|
561
|
+
});
|
|
562
|
+
test('strict: a failing verdict is verification-not-passing', () => {
|
|
563
|
+
commitRecord('rec', 'fail');
|
|
564
|
+
const r = run(undefined, { strict: true });
|
|
565
|
+
expect(r.code).toBe(2);
|
|
566
|
+
diag(r, 'verification-not-passing', 'verification.overall');
|
|
567
|
+
expect(codesOf(r)).not.toContain('verification-mismatch');
|
|
568
|
+
});
|
|
569
|
+
test('strict: a record digest that differs from the receipt is verification-mismatch on receipt_digest', () => {
|
|
570
|
+
commitRecord();
|
|
571
|
+
edit(REL, (t) => t.replace(/receipt_digest: sha256:[0-9a-f]{64}/, `receipt_digest: sha256:${'0'.repeat(64)}`));
|
|
572
|
+
const r = run(REL, { strict: true });
|
|
573
|
+
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.receipt_digest']);
|
|
574
|
+
});
|
|
575
|
+
test('strict: a record verdict that differs from the receipt is verification-mismatch on overall', () => {
|
|
576
|
+
commitRecord();
|
|
577
|
+
edit(REL, (t) => t.replace('overall: pass', 'overall: fail'));
|
|
578
|
+
const r = run(REL, { strict: true });
|
|
579
|
+
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.overall']);
|
|
580
|
+
});
|
|
581
|
+
test('strict: a record sensor status that differs from the receipt is verification-mismatch on sensors', () => {
|
|
582
|
+
commitRecord();
|
|
583
|
+
edit(REL, (t) => t.replace('lint: pass', 'lint: fail'));
|
|
584
|
+
const r = run(REL, { strict: true });
|
|
585
|
+
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
586
|
+
});
|
|
587
|
+
test('strict: an extra sensor in the record is verification-mismatch on sensors', () => {
|
|
588
|
+
commitRecord();
|
|
589
|
+
edit(REL, (t) => t.replace(' lint: pass', ' lint: pass\n extra: pass'));
|
|
590
|
+
const r = run(REL, { strict: true });
|
|
591
|
+
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
592
|
+
});
|
|
593
|
+
test('strict: a sensor the record omits is verification-mismatch on sensors', () => {
|
|
594
|
+
commitRecord('rec', 'pass', ['lint', 'types']);
|
|
595
|
+
expect(run(undefined, { strict: true }).code).toBe(0); // two sensors in the receipt and the record ...
|
|
596
|
+
edit(REL, (t) => t.replace(' types: pass\n', '')); // ... and the record omits one
|
|
597
|
+
const r = run(REL, { strict: true });
|
|
598
|
+
expect(diagsOf(r).filter((d) => d.code === 'verification-mismatch').map((d) => d.field)).toEqual(['verification.sensors']);
|
|
599
|
+
});
|
|
600
|
+
test('strict: the sensor order in the record does not matter', () => {
|
|
601
|
+
commitRecord('rec', 'pass', ['lint', 'types']);
|
|
602
|
+
edit(REL, (t) => t.replace(' lint: pass\n types: pass\n', ' types: pass\n lint: pass\n'));
|
|
603
|
+
expect(run(REL, { strict: true }).code).toBe(0);
|
|
604
|
+
});
|
|
605
|
+
test('without --strict a mismatching verification block is not reported', () => {
|
|
606
|
+
commitRecord();
|
|
607
|
+
edit(REL, (t) => t.replace('lint: pass', 'lint: fail'));
|
|
608
|
+
expect(run(REL).code).toBe(0);
|
|
609
|
+
});
|
|
610
|
+
test('strict with a foreign verification commit asks for no receipt of it', () => {
|
|
611
|
+
commitRecord();
|
|
612
|
+
edit(REL, (t) => t.replace(/commit: [0-9a-f]{40}/, `commit: ${'e'.repeat(40)}`));
|
|
613
|
+
const c = codesOf(run(REL, { strict: true }));
|
|
614
|
+
expect(c).toContain('verification-commit-foreign');
|
|
615
|
+
expect(c).toContain('receipt-missing');
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
describe('R4.5 output and exit codes', () => {
|
|
619
|
+
test('text report: header, one line per record, one indented line per diagnostic', () => {
|
|
620
|
+
commitRecord();
|
|
621
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
622
|
+
repo.git('commit', '-q', '-am', 'docs: edit the record');
|
|
623
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
624
|
+
expect(r.code).toBe(2);
|
|
625
|
+
expect(r.stderr).toBe('');
|
|
626
|
+
expect(r.stdout).toBe(`Change Record validation: invalid\n- ${REL}: invalid\n - derived-mismatch (type): type is "fix" but git derives "feat"\n`);
|
|
627
|
+
});
|
|
628
|
+
test('text report of a valid run', () => {
|
|
629
|
+
commitRecord();
|
|
630
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
631
|
+
expect(r.code).toBe(0);
|
|
632
|
+
expect(r.stdout).toBe(`Change Record validation: valid\n- ${REL}: valid\n`);
|
|
633
|
+
});
|
|
634
|
+
test('text report of an exempt run', () => {
|
|
635
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
636
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
637
|
+
expect(r.code).toBe(0);
|
|
638
|
+
expect(r.stdout).toBe('Change Record validation: exempt (Change-Record: none)\n');
|
|
639
|
+
});
|
|
640
|
+
test('text report of a missing record names docs/changes', () => {
|
|
641
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
642
|
+
expect(r.code).toBe(2);
|
|
643
|
+
expect(r.stdout.startsWith('Change Record validation: invalid\n- docs/changes: invalid\n - record-missing: ')).toBe(true);
|
|
644
|
+
});
|
|
645
|
+
test('--json is one line with the documented shape, and a diagnostic without a field has no field key', () => {
|
|
646
|
+
const r = run();
|
|
647
|
+
expect(r.stdout.endsWith('\n')).toBe(true);
|
|
648
|
+
expect(r.stdout.slice(0, -1)).not.toContain('\n');
|
|
649
|
+
const out = report(r);
|
|
650
|
+
expect(Object.keys(out).sort()).toEqual(['records', 'state']);
|
|
651
|
+
expect(Object.keys(out.records[0]).sort()).toEqual(['diagnostics', 'path', 'state']);
|
|
652
|
+
expect(Object.keys(out.records[0].diagnostics[0]).sort()).toEqual(['code', 'message']);
|
|
653
|
+
});
|
|
654
|
+
test('--json carries the field when a diagnostic names one', () => {
|
|
655
|
+
commitRecord();
|
|
656
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix'));
|
|
657
|
+
expect(Object.keys(diagsOf(run(REL))[0]).sort()).toEqual(['code', 'field', 'message']);
|
|
658
|
+
});
|
|
659
|
+
test('exit code 0 for a valid run', () => {
|
|
660
|
+
commitRecord();
|
|
661
|
+
expect(run().code).toBe(0);
|
|
662
|
+
});
|
|
663
|
+
test('exit code 0 for an exempt run', () => {
|
|
664
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
665
|
+
expect(run().code).toBe(0);
|
|
666
|
+
expect((0, change_1.runChangeValidate)(repo.root, undefined, {}).code).toBe(0);
|
|
667
|
+
});
|
|
668
|
+
test('exit code 2 for a missing record', () => {
|
|
669
|
+
expect(run().code).toBe(2);
|
|
670
|
+
});
|
|
671
|
+
test('exit code 2 for an invalid record', () => {
|
|
672
|
+
commitRecord();
|
|
673
|
+
repo.commit({ 'cli/src/more.ts': '1' }, 'chore: more');
|
|
674
|
+
expect(run().code).toBe(2);
|
|
675
|
+
});
|
|
676
|
+
test('a report is never written to stderr', () => {
|
|
677
|
+
expect(run().stderr).toBe('');
|
|
678
|
+
expect((0, change_1.runChangeValidate)(repo.root, undefined, {}).stderr).toBe('');
|
|
679
|
+
});
|
|
680
|
+
/** A committed record plus `count` unknown top-level keys in the working tree: `count` unknown-field diagnostics. */
|
|
681
|
+
function withUnknownKeys(count) {
|
|
682
|
+
commitRecord();
|
|
683
|
+
const extra = Array.from({ length: count }, (_, i) => `extra${i}: 1`).join('\n');
|
|
684
|
+
edit(REL, (t) => t.replace('schema: awm.change-record/v1', `schema: awm.change-record/v1\n${extra}`));
|
|
685
|
+
return diagsOf(run(REL));
|
|
686
|
+
}
|
|
687
|
+
test.each([
|
|
688
|
+
[21, '2'], // just past the per-record cap
|
|
689
|
+
[30, '11'],
|
|
690
|
+
[50, '31'], // the shape validator's own cap is reached exactly
|
|
691
|
+
[51, '32'], // one diagnostic dropped by the shape validator itself
|
|
692
|
+
[80, '61'], // many dropped by the shape validator itself
|
|
693
|
+
])('%i diagnostics: 19 kept and one marker counting %s omitted', (count, omitted) => {
|
|
694
|
+
const ds = withUnknownKeys(count);
|
|
695
|
+
expect(ds).toHaveLength(20);
|
|
696
|
+
expect(ds.slice(0, 19).every((d) => d.code === 'unknown-field')).toBe(true);
|
|
697
|
+
expect(ds.filter((d) => d.code === 'diagnostics-truncated')).toHaveLength(1);
|
|
698
|
+
expect(ds[19]).toMatchObject({ code: 'diagnostics-truncated', message: `${omitted} more diagnostics omitted` });
|
|
699
|
+
});
|
|
700
|
+
test('exactly 20 diagnostics are all kept, with no marker', () => {
|
|
701
|
+
const ds = withUnknownKeys(20);
|
|
702
|
+
expect(ds).toHaveLength(20);
|
|
703
|
+
expect(ds.every((d) => d.code === 'unknown-field')).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
test('at most 50 records are validated; a 51st-and-later record yields too-many-records and an invalid run', () => {
|
|
706
|
+
const files = Object.fromEntries(Array.from({ length: 53 }, (_, i) => [`docs/changes/2026-10-17-r${String(i).padStart(2, '0')}.md`, 'x']));
|
|
707
|
+
repo.commit(files, 'docs: many');
|
|
708
|
+
const out = report(run());
|
|
709
|
+
expect(out.state).toBe('invalid');
|
|
710
|
+
expect(out.records).toHaveLength(51);
|
|
711
|
+
expect(out.records[0].path).toBe('docs/changes/2026-10-17-r00.md');
|
|
712
|
+
expect(out.records[49].path).toBe('docs/changes/2026-10-17-r49.md');
|
|
713
|
+
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
|
+
expect(out.records.slice(0, 50).every((x) => x.diagnostics.every((d) => d.code !== 'too-many-records'))).toBe(true);
|
|
715
|
+
expect(run().code).toBe(2);
|
|
716
|
+
});
|
|
717
|
+
test('exactly 50 records are all validated with no too-many-records marker', () => {
|
|
718
|
+
const files = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`docs/changes/2026-10-17-r${String(i).padStart(2, '0')}.md`, 'x']));
|
|
719
|
+
repo.commit(files, 'docs: many');
|
|
720
|
+
const out = report(run());
|
|
721
|
+
expect(out.records).toHaveLength(50);
|
|
722
|
+
expect(out.records.some((x) => x.diagnostics.some((d) => d.code === 'too-many-records'))).toBe(false);
|
|
723
|
+
});
|
|
724
|
+
test('every message is one line of at most 300 units, in both formats', () => {
|
|
725
|
+
commitRecord();
|
|
726
|
+
edit(REL, (t) => t.replace('type: feat', 'type: fix').replace('semver: minor', 'semver: patch'));
|
|
727
|
+
repo.git('commit', '-q', '-am', 'docs: edit the record');
|
|
728
|
+
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
729
|
+
for (const d of diagsOf(run())) {
|
|
730
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
731
|
+
expect(d.message).not.toMatch(rawUnsafe);
|
|
732
|
+
}
|
|
733
|
+
for (const l of (0, change_1.runChangeValidate)(repo.root, undefined, {}).stdout.split('\n'))
|
|
734
|
+
expect(l.length).toBeLessThanOrEqual(300);
|
|
735
|
+
});
|
|
736
|
+
test('a long record value with quotes and backslashes in a derived-mismatch message stays one bounded, escaped line', () => {
|
|
737
|
+
commitRecord();
|
|
738
|
+
edit(REL, (t) => t.replace('scope: changes', `scope: ${JSON.stringify('"\\'.repeat(300))}`));
|
|
739
|
+
const d = diag(run(REL), 'derived-mismatch', 'scope');
|
|
740
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
741
|
+
expect(d.message).not.toMatch(rawUnsafe);
|
|
742
|
+
expect(d.message.startsWith('scope is "')).toBe(true);
|
|
743
|
+
for (const l of (0, change_1.runChangeValidate)(repo.root, REL, {}).stdout.split('\n'))
|
|
744
|
+
expect(l.length).toBeLessThanOrEqual(300);
|
|
745
|
+
});
|
|
746
|
+
test('the modules of a derived-mismatch message are listed as a bracketed list', () => {
|
|
747
|
+
commitRecord();
|
|
748
|
+
edit(REL, (t) => t.replace(' - cli/src', ' - cli/src\n - docs'));
|
|
749
|
+
expect(diag(run(REL), 'derived-mismatch', 'modules').message).toBe('modules is ["cli/src", "docs"] but git derives ["cli/src"]');
|
|
750
|
+
});
|
|
751
|
+
test('a long recorded modules list does not hide the derived value (both halves stay visible)', () => {
|
|
752
|
+
commitRecord();
|
|
753
|
+
const many = Array.from({ length: 40 }, (_, i) => ` - mod/m${String(i).padStart(2, '0')}`).join('\n');
|
|
754
|
+
edit(REL, (t) => t.replace(' - cli/src', ` - cli/src\n${many}`));
|
|
755
|
+
const d = diag(run(REL), 'derived-mismatch', 'modules');
|
|
756
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
757
|
+
expect(d.message.startsWith('modules is ["cli/src", "mod/m00"')).toBe(true);
|
|
758
|
+
expect(d.message.endsWith('but git derives ["cli/src"]')).toBe(true);
|
|
759
|
+
});
|
|
760
|
+
test('a long derived modules list does not hide the recorded value', () => {
|
|
761
|
+
commitRecord();
|
|
762
|
+
repo.commit(Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`wide/w${String(i).padStart(2, '0')}/x.ts`, '1'])), 'chore: wide');
|
|
763
|
+
const d = diag(run(), 'derived-mismatch', 'modules');
|
|
764
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
765
|
+
expect(d.message.startsWith('modules is ["cli/src"] but git derives ["cli/src", "wide/w00"')).toBe(true);
|
|
766
|
+
});
|
|
767
|
+
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
|
+
commitRecord();
|
|
769
|
+
repo.commit(Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`wide/w${String(i).padStart(2, '0')}/x.ts`, '1'])), 'chore: wide');
|
|
770
|
+
const many = Array.from({ length: 40 }, (_, i) => ` - mod/m${String(i).padStart(2, '0')}`).join('\n');
|
|
771
|
+
repo.commit({ [REL]: fs_1.default.readFileSync(abs(REL), 'utf8').replace(' - cli/src', ` - cli/src\n${many}`) }, 'docs: record');
|
|
772
|
+
const d = diag(run(), 'derived-mismatch', 'modules');
|
|
773
|
+
expect(d.message.length).toBeLessThan(300); // not clipped by the 300-unit limit: each half was cut on its own
|
|
774
|
+
expect(d.message).toMatch(/^modules is \["cli\/src", "mod\/m00".*… but git derives \["cli\/src", "wide\/w00"/);
|
|
775
|
+
});
|
|
776
|
+
test('a null scope and a boolean are shown as themselves', () => {
|
|
777
|
+
commitRecord();
|
|
778
|
+
edit(REL, (t) => t.replace('scope: changes', 'scope: null').replace('breaking: false', 'breaking: true'));
|
|
779
|
+
const ds = diagsOf(run(REL));
|
|
780
|
+
expect(ds.find((d) => d.field === 'scope')?.message).toBe('scope is null but git derives "changes"');
|
|
781
|
+
expect(ds.find((d) => d.field === 'breaking')?.message).toBe('breaking is true but git derives false');
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
describe('R4.5 operational failures under --json', () => {
|
|
785
|
+
const J = { json: true };
|
|
786
|
+
const cases = [
|
|
787
|
+
['not-a-git-repo', 'not-a-git-repo', () => (0, change_1.runChangeValidate)((0, helpers_1.nonRepoDir)(repo), undefined, J)],
|
|
788
|
+
['base-unresolved (unknown --base)', 'base-unresolved', () => (0, change_1.runChangeValidate)(repo.root, undefined, { ...J, base: 'no-such-branch' })],
|
|
789
|
+
['base-unresolved (no default base)', 'base-unresolved', () => { repo.git('branch', '-m', 'main', 'trunk'); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
790
|
+
['invalid-input (unsafe --base)', 'invalid-input', () => (0, change_1.runChangeValidate)(repo.root, undefined, { ...J, base: '--output=x' })],
|
|
791
|
+
['invalid-input (hostile --base)', 'invalid-input', () => (0, change_1.runChangeValidate)(repo.root, undefined, { ...J, base: `\u001b[31m${'y\n'.repeat(400)}` })],
|
|
792
|
+
['invalid-input (cwd)', 'invalid-input', () => (0, change_1.runChangeValidate)((0, helpers_1.bad)(undefined), undefined, J)],
|
|
793
|
+
['path-invalid', 'path-invalid', () => (0, change_1.runChangeValidate)(repo.root, '../x.md', J)],
|
|
794
|
+
['path-invalid (hostile path)', 'path-invalid', () => (0, change_1.runChangeValidate)(repo.root, `docs/changes/\u001b${'z\n'.repeat(400)}.md`, J)],
|
|
795
|
+
['git-failed', 'git-failed', () => { jest.spyOn(gitModule, 'headSha').mockReturnValue(null); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
796
|
+
['flag-invalid', 'flag-invalid', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ ...J, base: 7 }))],
|
|
797
|
+
['internal-error', 'internal-error', () => { jest.spyOn(gitModule, 'resolveBaseCommit').mockImplementation(() => { throw new RangeError('x'); }); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
798
|
+
];
|
|
799
|
+
test.each(cases)('%s prints one JSON line of the report shape on stdout and exits 2', (_name, code, call) => {
|
|
800
|
+
const r = call();
|
|
801
|
+
expect(r.code).toBe(2);
|
|
802
|
+
expect(r.stderr).toBe('');
|
|
803
|
+
expect(r.stdout.endsWith('\n')).toBe(true);
|
|
804
|
+
expect(r.stdout.slice(0, -1)).not.toContain('\n');
|
|
805
|
+
const out = JSON.parse(r.stdout);
|
|
806
|
+
expect(Object.keys(out).sort()).toEqual(['records', 'state']);
|
|
807
|
+
expect(out.state).toBe('invalid');
|
|
808
|
+
expect(out.records).toHaveLength(1);
|
|
809
|
+
expect(out.records[0]).toMatchObject({ path: 'docs/changes', state: 'invalid' });
|
|
810
|
+
expect(out.records[0].diagnostics).toHaveLength(1);
|
|
811
|
+
const d = out.records[0].diagnostics[0];
|
|
812
|
+
expect(d.code).toBe(code);
|
|
813
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
814
|
+
expect(d.message).not.toMatch(rawUnsafe);
|
|
815
|
+
expect(d.field).toBeUndefined();
|
|
816
|
+
});
|
|
817
|
+
test('the base-unresolved remedy travels in the JSON message', () => {
|
|
818
|
+
const out = JSON.parse((0, change_1.runChangeValidate)(repo.root, undefined, { ...J, base: 'nope' }).stdout);
|
|
819
|
+
expect(out.records[0].diagnostics[0].message).toContain('pass --base <ref>');
|
|
820
|
+
});
|
|
821
|
+
test('the untrusted --base is escaped in the JSON message (no raw escape character, bounded)', () => {
|
|
822
|
+
const out = JSON.parse((0, change_1.runChangeValidate)(repo.root, undefined, { ...J, base: '\u001b[31mred' }).stdout);
|
|
823
|
+
expect(out.records[0].diagnostics[0].message).toContain('\\u001b');
|
|
824
|
+
});
|
|
825
|
+
test('without --json the failure stays on stderr with an empty stdout', () => {
|
|
826
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, { base: 'nope' });
|
|
827
|
+
expectFailure(r, 'base-unresolved');
|
|
828
|
+
});
|
|
829
|
+
test('json: false keeps the stderr form', () => {
|
|
830
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, { json: false, base: 'nope' }), 'base-unresolved');
|
|
831
|
+
});
|
|
832
|
+
});
|
|
833
|
+
describe('R4.3 committed-record verdict (record-uncommitted)', () => {
|
|
834
|
+
test('a clean committed record has no record-uncommitted diagnostic', () => {
|
|
835
|
+
commitRecord();
|
|
836
|
+
expect(codesOf(run())).not.toContain('record-uncommitted');
|
|
837
|
+
expect(run().code).toBe(0);
|
|
838
|
+
});
|
|
839
|
+
test('an unstaged edit of a committed record is record-uncommitted', () => {
|
|
840
|
+
commitRecord();
|
|
841
|
+
edit(REL, (t) => `${t}\nmore\n`);
|
|
842
|
+
const r = run();
|
|
843
|
+
expect(r.code).toBe(2);
|
|
844
|
+
diag(r, 'record-uncommitted', undefined);
|
|
845
|
+
});
|
|
846
|
+
test('the verdict is not silently computed from the edit: an edit that keeps the record valid is still invalid', () => {
|
|
847
|
+
commitRecord();
|
|
848
|
+
edit(REL, (t) => `${t}\nmore\n`);
|
|
849
|
+
expect(run(REL).code).toBe(0); // the edited file on its own is fine ...
|
|
850
|
+
expect(report(run()).state).toBe('invalid'); // ... but the branch's committed record is not what was validated
|
|
851
|
+
});
|
|
852
|
+
test('a staged but uncommitted edit is record-uncommitted', () => {
|
|
853
|
+
commitRecord();
|
|
854
|
+
edit(REL, (t) => `${t}\nmore\n`);
|
|
855
|
+
repo.git('add', REL);
|
|
856
|
+
diag(run(), 'record-uncommitted', undefined);
|
|
857
|
+
});
|
|
858
|
+
test('an edit that also breaks the shape reports both', () => {
|
|
859
|
+
commitRecord();
|
|
860
|
+
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
861
|
+
const c = codesOf(run());
|
|
862
|
+
expect(c).toContain('record-uncommitted');
|
|
863
|
+
expect(c).toContain('field-invalid');
|
|
864
|
+
});
|
|
865
|
+
test('uncommitted edit of another record does not taint a clean one', () => {
|
|
866
|
+
commitRecord('aaa');
|
|
867
|
+
commitRecord('bbb');
|
|
868
|
+
edit('docs/changes/2026-10-17-bbb.md', (t) => `${t}\nmore\n`);
|
|
869
|
+
const out = report(run());
|
|
870
|
+
expect(out.records.map((x) => x.state)).toEqual(['valid', 'invalid']);
|
|
871
|
+
});
|
|
872
|
+
test('an untracked unrelated file is not an uncommitted change of the record', () => {
|
|
873
|
+
commitRecord();
|
|
874
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'docs', 'changes', 'notes.md'), 'x');
|
|
875
|
+
expect(run().code).toBe(0);
|
|
876
|
+
});
|
|
877
|
+
test('with an explicit path the working-tree file is validated and no record-uncommitted is reported', () => {
|
|
878
|
+
commitRecord();
|
|
879
|
+
edit(REL, (t) => `${t}\nmore\n`);
|
|
880
|
+
expect(codesOf(run(REL))).not.toContain('record-uncommitted');
|
|
881
|
+
});
|
|
882
|
+
test('a git failure while checking the record fails closed as record-uncommitted', () => {
|
|
883
|
+
commitRecord();
|
|
884
|
+
(0, helpers_1.spyOnGit)((args) => { if (args.includes('status'))
|
|
885
|
+
throw Object.assign(new Error('boom'), { code: 'ETIMEDOUT' }); });
|
|
886
|
+
const r = run();
|
|
887
|
+
expect(r.code).toBe(2);
|
|
888
|
+
expect(diag(r, 'record-uncommitted', undefined).message).toContain('could not');
|
|
889
|
+
});
|
|
890
|
+
});
|
|
891
|
+
describe('one derivation, one ancestry check and one late-file listing per verification commit', () => {
|
|
892
|
+
/** Two valid records that both verify the same commit (the second is a copy of the first under another name). */
|
|
893
|
+
function twoRecordsOneCommit() {
|
|
894
|
+
commitRecord('aaa');
|
|
895
|
+
const text = fs_1.default.readFileSync(abs('docs/changes/2026-10-17-aaa.md'), 'utf8').replace('id: 2026-10-17-aaa', 'id: 2026-10-17-bbb');
|
|
896
|
+
repo.commit({ 'docs/changes/2026-10-17-bbb.md': text }, 'docs(changes): bbb');
|
|
897
|
+
return /commit: ([0-9a-f]{40})/.exec(text)?.[1];
|
|
898
|
+
}
|
|
899
|
+
function gitCalls(fn) {
|
|
900
|
+
const git = (0, helpers_1.spyOnGit)();
|
|
901
|
+
try {
|
|
902
|
+
fn();
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
git.restore();
|
|
906
|
+
}
|
|
907
|
+
return git.calls;
|
|
908
|
+
}
|
|
909
|
+
test('both records are valid, and git derives, checks ancestry and lists late files once', () => {
|
|
910
|
+
const verified = twoRecordsOneCommit();
|
|
911
|
+
let result;
|
|
912
|
+
const calls = gitCalls(() => { result = run(); });
|
|
913
|
+
expect(report(result)).toMatchObject({ state: 'valid', records: [{ state: 'valid' }, { state: 'valid' }] });
|
|
914
|
+
expect(calls.filter((a) => a.includes('--no-merges'))).toHaveLength(1); // the derivation's git log
|
|
915
|
+
expect(calls.filter((a) => a.includes('--is-ancestor'))).toHaveLength(1);
|
|
916
|
+
expect(calls.filter((a) => a.includes('diff') && a.includes(verified))).toHaveLength(1); // the late-file listing
|
|
917
|
+
});
|
|
918
|
+
test('the committed-state check still runs once per record', () => {
|
|
919
|
+
twoRecordsOneCommit();
|
|
920
|
+
const calls = gitCalls(() => { run(); });
|
|
921
|
+
expect(calls.filter((a) => a.includes('status'))).toHaveLength(2);
|
|
922
|
+
});
|
|
923
|
+
});
|
|
924
|
+
describe('a path read through a symlinked working directory or repository root', () => {
|
|
925
|
+
function link(target) {
|
|
926
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'link-'));
|
|
927
|
+
const l = path_1.default.join(dir, 'repo-link');
|
|
928
|
+
fs_1.default.symlinkSync(target, l);
|
|
929
|
+
return l;
|
|
930
|
+
}
|
|
931
|
+
(0, helpers_1.posixOnly)('a relative path is read from a cwd that is a symlink to the repository', () => {
|
|
932
|
+
commitRecord();
|
|
933
|
+
const r = (0, change_1.runChangeValidate)(link(repo.root), REL, { json: true });
|
|
934
|
+
expect(r.code).toBe(0);
|
|
935
|
+
expect(report(r).records[0].path).toBe(REL);
|
|
936
|
+
});
|
|
937
|
+
(0, helpers_1.posixOnly)('a path to a record that does not exist, from a symlinked cwd, is record-missing (not outside the repository)', () => {
|
|
938
|
+
const r = (0, change_1.runChangeValidate)(link(repo.root), REL, { json: true });
|
|
939
|
+
expect(r.code).toBe(2);
|
|
940
|
+
expect(report(r).records[0].diagnostics.map((d) => d.code)).toEqual(['record-missing']);
|
|
941
|
+
});
|
|
942
|
+
(0, helpers_1.posixOnly)('an absolute path through a symlink to the repository is accepted', () => {
|
|
943
|
+
commitRecord();
|
|
944
|
+
const r = (0, change_1.runChangeValidate)(repo.root, path_1.default.join(link(repo.root), ...REL.split('/')), { json: true });
|
|
945
|
+
expect(r.code).toBe(0);
|
|
946
|
+
expect(report(r).records[0].path).toBe(REL);
|
|
947
|
+
});
|
|
948
|
+
(0, helpers_1.posixOnly)('a repository root reported through a symlink still resolves a path given from the real directory', () => {
|
|
949
|
+
commitRecord();
|
|
950
|
+
jest.spyOn(gitModule, 'repoRoot').mockReturnValue(link(repo.root));
|
|
951
|
+
const r = (0, change_1.runChangeValidate)(repo.root, abs(REL), { json: true });
|
|
952
|
+
expect(report(r).records[0].path).toBe(REL);
|
|
953
|
+
expect(r.code).toBe(0);
|
|
954
|
+
});
|
|
955
|
+
(0, helpers_1.posixOnly)('a symlink to somewhere else is still outside the repository', () => {
|
|
956
|
+
commitRecord();
|
|
957
|
+
const elsewhere = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'else-'));
|
|
958
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, path_1.default.join(link(elsewhere), '2026-10-17-rec.md'), {}), 'path-invalid');
|
|
959
|
+
});
|
|
960
|
+
});
|
|
961
|
+
describe('R4.3 shape first', () => {
|
|
962
|
+
test('an invalid-shape committed record with late code changes reports only the shape diagnostics', () => {
|
|
963
|
+
commitRecord();
|
|
964
|
+
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other').replace('type: feat', 'type: fix'));
|
|
965
|
+
repo.git('commit', '-q', '-am', 'docs: break the record');
|
|
966
|
+
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
967
|
+
expect(codesOf(run(undefined, { strict: true }))).toEqual(['field-invalid']);
|
|
968
|
+
});
|
|
969
|
+
test('the same record with an explicit path reports only the shape diagnostics', () => {
|
|
970
|
+
commitRecord();
|
|
971
|
+
edit(REL, (t) => t.replace('schema: awm.change-record/v1', 'schema: other'));
|
|
972
|
+
repo.git('commit', '-q', '-am', 'docs: break the record');
|
|
973
|
+
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
974
|
+
expect(codesOf(run(REL, { strict: true }))).toEqual(['field-invalid']);
|
|
975
|
+
});
|
|
976
|
+
test('once the shape is fixed the other diagnostics appear', () => {
|
|
977
|
+
commitRecord();
|
|
978
|
+
repo.commit({ 'cli/src/core/late.ts': '1' }, 'feat(core): late');
|
|
979
|
+
expect(codesOf(run())).toEqual(expect.arrayContaining(['code-changed-after-verification', 'derived-mismatch']));
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
describe('R4.6 read-only', () => {
|
|
983
|
+
function snapshot() {
|
|
984
|
+
const out = {};
|
|
985
|
+
const walk = (dir) => {
|
|
986
|
+
for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
|
|
987
|
+
const full = path_1.default.join(dir, entry.name);
|
|
988
|
+
const key = path_1.default.relative(repo.root, full);
|
|
989
|
+
if (entry.isDirectory()) {
|
|
990
|
+
out[key] = 'dir';
|
|
991
|
+
walk(full);
|
|
992
|
+
}
|
|
993
|
+
else if (entry.isSymbolicLink())
|
|
994
|
+
out[key] = `link:${fs_1.default.readlinkSync(full)}`;
|
|
995
|
+
else
|
|
996
|
+
out[key] = crypto_1.default.createHash('sha1').update(fs_1.default.readFileSync(full)).digest('hex');
|
|
997
|
+
}
|
|
998
|
+
};
|
|
999
|
+
walk(repo.root);
|
|
1000
|
+
return out;
|
|
1001
|
+
}
|
|
1002
|
+
test.each([
|
|
1003
|
+
['base', undefined, {}],
|
|
1004
|
+
['strict', undefined, { strict: true }],
|
|
1005
|
+
['json', undefined, { json: true }],
|
|
1006
|
+
['a path', REL, { strict: true }],
|
|
1007
|
+
])('%s run changes no file, no directory and no git state', (_n, target, opts) => {
|
|
1008
|
+
commitRecord();
|
|
1009
|
+
repo.commit({ 'cli/src/late.ts': '1' }, 'chore: late');
|
|
1010
|
+
const before = snapshot();
|
|
1011
|
+
const status = repo.git('status', '--porcelain', '--ignored');
|
|
1012
|
+
const r = (0, change_1.runChangeValidate)(repo.root, target, opts);
|
|
1013
|
+
expect(r.code).toBe(2);
|
|
1014
|
+
expect(snapshot()).toEqual(before);
|
|
1015
|
+
expect(repo.git('status', '--porcelain', '--ignored')).toBe(status);
|
|
1016
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.home, '.awm'))).toBe(false);
|
|
1017
|
+
});
|
|
1018
|
+
test('a run that finds no record creates no docs/changes directory', () => {
|
|
1019
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
|
|
1020
|
+
expect(run().code).toBe(2);
|
|
1021
|
+
expect(run(REL).code).toBe(2);
|
|
1022
|
+
expect(fs_1.default.existsSync(path_1.default.join(repo.root, 'docs'))).toBe(false);
|
|
1023
|
+
});
|
|
1024
|
+
test('a dirty working tree is not an error', () => {
|
|
1025
|
+
commitRecord();
|
|
1026
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'scratch.txt'), 'x');
|
|
1027
|
+
expect(run().code).toBe(0);
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
describe('robustness: never throws, always a CommandResult', () => {
|
|
1031
|
+
test.each([
|
|
1032
|
+
['cwd', () => (0, change_1.runChangeValidate)((0, helpers_1.bad)(undefined), undefined, {})],
|
|
1033
|
+
['empty cwd', () => (0, change_1.runChangeValidate)('', undefined, {})],
|
|
1034
|
+
['target', () => (0, change_1.runChangeValidate)(repo.root, (0, helpers_1.bad)(7), {})],
|
|
1035
|
+
['opts', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)('x'))],
|
|
1036
|
+
['null opts', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)(null))],
|
|
1037
|
+
['array opts', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)([]))],
|
|
1038
|
+
['base', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ base: 7 }))],
|
|
1039
|
+
['strict', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ strict: 'yes' }))],
|
|
1040
|
+
['json', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ json: 1 }))],
|
|
1041
|
+
])('invalid %s is a one-line failure', (_n, call) => {
|
|
1042
|
+
const r = call();
|
|
1043
|
+
expect(r.code).toBe(2);
|
|
1044
|
+
expect(r.stdout).toBe('');
|
|
1045
|
+
expect(r.stderr).toMatch(/^(invalid-input|flag-invalid|path-invalid): [^\n]*\n$/);
|
|
1046
|
+
});
|
|
1047
|
+
test('undefined opts are accepted as no flags', () => {
|
|
1048
|
+
commitRecord();
|
|
1049
|
+
expect((0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)(undefined)).code).toBe(0);
|
|
1050
|
+
});
|
|
1051
|
+
test('an unexpected exception becomes internal-error carrying only the error name', () => {
|
|
1052
|
+
commitRecord();
|
|
1053
|
+
jest.spyOn(deriveModule, 'deriveFields').mockImplementation(() => { throw new TypeError('secret\u001b detail'); });
|
|
1054
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
1055
|
+
expectFailure(r, 'internal-error');
|
|
1056
|
+
expect(r.stderr).toContain('TypeError');
|
|
1057
|
+
expect(r.stderr).not.toContain('secret');
|
|
1058
|
+
});
|
|
1059
|
+
test('a HEAD that moves during validation is head-moved, not a verdict', () => {
|
|
1060
|
+
commitRecord();
|
|
1061
|
+
const real = gitModule.headSha;
|
|
1062
|
+
let calls = 0;
|
|
1063
|
+
jest.spyOn(gitModule, 'headSha').mockImplementation((root) => (++calls === 1 ? real(root) : 'f'.repeat(40)));
|
|
1064
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'head-moved');
|
|
1065
|
+
});
|
|
1066
|
+
test('a HEAD that moves during an exempt run is head-moved, not an exemption', () => {
|
|
1067
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none');
|
|
1068
|
+
const real = gitModule.headSha;
|
|
1069
|
+
let calls = 0;
|
|
1070
|
+
jest.spyOn(gitModule, 'headSha').mockImplementation((root) => (++calls === 1 ? real(root) : 'f'.repeat(40)));
|
|
1071
|
+
const r = (0, change_1.runChangeValidate)(repo.root, undefined, {});
|
|
1072
|
+
expectFailure(r, 'head-moved');
|
|
1073
|
+
expect(r.stderr).toContain('HEAD moved');
|
|
1074
|
+
});
|
|
1075
|
+
test('an unreadable HEAD is git-failed', () => {
|
|
1076
|
+
commitRecord();
|
|
1077
|
+
jest.spyOn(gitModule, 'headSha').mockReturnValue(null);
|
|
1078
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'git-failed');
|
|
1079
|
+
});
|
|
1080
|
+
test('a git failure while listing the added records is git-failed', () => {
|
|
1081
|
+
commitRecord();
|
|
1082
|
+
jest.spyOn(gitModule, 'addedFilesUnder').mockReturnValue(null);
|
|
1083
|
+
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'git-failed');
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
describe('registerChangeCommand: validate', () => {
|
|
1087
|
+
function program() {
|
|
1088
|
+
const p = new commander_1.Command();
|
|
1089
|
+
p.exitOverride();
|
|
1090
|
+
(0, change_1.registerChangeCommand)(p, { now });
|
|
1091
|
+
return p;
|
|
1092
|
+
}
|
|
1093
|
+
let savedExit;
|
|
1094
|
+
let out;
|
|
1095
|
+
let err;
|
|
1096
|
+
beforeEach(() => {
|
|
1097
|
+
savedExit = process.exitCode;
|
|
1098
|
+
out = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
1099
|
+
err = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
1100
|
+
jest.spyOn(process, 'cwd').mockImplementation(() => repo.root);
|
|
1101
|
+
});
|
|
1102
|
+
afterEach(() => { process.exitCode = savedExit; });
|
|
1103
|
+
const sub = () => program().commands.find((c) => c.name() === 'change')?.commands.find((c) => c.name() === 'validate');
|
|
1104
|
+
test('registers change validate with its documented flags and optional path', () => {
|
|
1105
|
+
expect(sub()?.options.map((o) => o.long).sort()).toEqual(['--base', '--json', '--strict']);
|
|
1106
|
+
expect(sub()?.registeredArguments.map((a) => [a.name(), a.required])).toEqual([['path', false]]);
|
|
1107
|
+
});
|
|
1108
|
+
test('a valid run prints the report and leaves the exit code alone', async () => {
|
|
1109
|
+
commitRecord();
|
|
1110
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate']);
|
|
1111
|
+
expect(out).toHaveBeenCalledWith(`Change Record validation: valid\n- ${REL}: valid\n`);
|
|
1112
|
+
expect(process.exitCode).toBe(savedExit);
|
|
1113
|
+
});
|
|
1114
|
+
test('flags and the path reach the command', async () => {
|
|
1115
|
+
commitRecord('rec', 'fail');
|
|
1116
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate', REL, '--strict', '--json']);
|
|
1117
|
+
const printed = String(out.mock.calls[0][0]);
|
|
1118
|
+
expect(JSON.parse(printed).records[0].diagnostics.map((d) => d.code)).toContain('verification-not-passing');
|
|
1119
|
+
expect(process.exitCode).toBe(2);
|
|
1120
|
+
});
|
|
1121
|
+
test('--base reaches the command', async () => {
|
|
1122
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate', '--base', 'nope']);
|
|
1123
|
+
expect(err).toHaveBeenCalledWith(expect.stringContaining('base-unresolved: '));
|
|
1124
|
+
expect(process.exitCode).toBe(2);
|
|
1125
|
+
});
|
|
1126
|
+
test('an invalid run exits 2 with the report on stdout', async () => {
|
|
1127
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate']);
|
|
1128
|
+
expect(out).toHaveBeenCalledWith(expect.stringContaining('record-missing'));
|
|
1129
|
+
expect(process.exitCode).toBe(2);
|
|
1130
|
+
});
|
|
1131
|
+
test('a cwd that is not accessible is reported, not thrown', async () => {
|
|
1132
|
+
jest.spyOn(process, 'cwd').mockImplementation(() => { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); });
|
|
1133
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate']);
|
|
1134
|
+
expect(process.exitCode).toBe(2);
|
|
1135
|
+
expect(err).toHaveBeenCalledWith(expect.stringMatching(/^[a-z-]+: /));
|
|
1136
|
+
});
|
|
1137
|
+
test('a cwd that is not accessible is reported as a JSON line under --json', async () => {
|
|
1138
|
+
jest.spyOn(process, 'cwd').mockImplementation(() => { throw Object.assign(new Error('gone'), { code: 'ENOENT' }); });
|
|
1139
|
+
await program().parseAsync(['node', 'awm', 'change', 'validate', '--json']);
|
|
1140
|
+
expect(process.exitCode).toBe(2);
|
|
1141
|
+
expect(err).not.toHaveBeenCalled();
|
|
1142
|
+
const printed = JSON.parse(String(out.mock.calls[0][0]));
|
|
1143
|
+
expect(printed.records[0]).toMatchObject({ path: 'docs/changes', diagnostics: [{ code: 'not-a-git-repo' }] });
|
|
1144
|
+
});
|
|
1145
|
+
});
|