agentic-workflow-manager 9.18.0 → 9.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/src/commands/change/index.js +492 -0
  2. package/dist/src/commands/changes/index.js +84 -0
  3. package/dist/src/commands/plan/index.js +1 -1
  4. package/dist/src/commands/sensors/index.js +9 -0
  5. package/dist/src/commands/sensors/read-only-observation.js +305 -0
  6. package/dist/src/commands/sensors/run.js +24 -60
  7. package/dist/src/core/admission/index.js +11 -1
  8. package/dist/src/core/changes/command.js +38 -0
  9. package/dist/src/core/changes/derive.js +120 -0
  10. package/dist/src/core/changes/fs-safe.js +47 -0
  11. package/dist/src/core/changes/git-env.js +68 -0
  12. package/dist/src/core/changes/git.js +216 -0
  13. package/dist/src/core/changes/list-parse.js +67 -0
  14. package/dist/src/core/changes/list.js +408 -0
  15. package/dist/src/core/changes/parse.js +72 -0
  16. package/dist/src/core/changes/range.js +53 -0
  17. package/dist/src/core/changes/receipt.js +274 -0
  18. package/dist/src/core/changes/record-io.js +149 -0
  19. package/dist/src/core/changes/render-list.js +109 -0
  20. package/dist/src/core/changes/render.js +178 -0
  21. package/dist/src/core/changes/text-safety.js +116 -0
  22. package/dist/src/core/changes/trailers.js +76 -0
  23. package/dist/src/core/changes/types.js +26 -0
  24. package/dist/src/core/changes/validate-shape.js +219 -0
  25. package/dist/src/core/changes/worktree.js +53 -0
  26. package/dist/src/core/currentness/check.js +5 -2
  27. package/dist/src/index.js +4 -0
  28. package/dist/src/release/orchestrator.js +3 -1
  29. package/dist/tests/commands/change/new.test.js +1126 -0
  30. package/dist/tests/commands/change/validate.test.js +1145 -0
  31. package/dist/tests/commands/changes/list.test.js +423 -0
  32. package/dist/tests/commands/sensors/index.test.js +1 -1
  33. package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
  34. package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
  35. package/dist/tests/core/admission/index.test.js +60 -0
  36. package/dist/tests/core/changes/derive.test.js +455 -0
  37. package/dist/tests/core/changes/fs-safe.test.js +82 -0
  38. package/dist/tests/core/changes/git-env.test.js +143 -0
  39. package/dist/tests/core/changes/git.test.js +442 -0
  40. package/dist/tests/core/changes/helpers.js +269 -0
  41. package/dist/tests/core/changes/list-fixtures.js +122 -0
  42. package/dist/tests/core/changes/list-long-history.test.js +52 -0
  43. package/dist/tests/core/changes/list-paging.test.js +511 -0
  44. package/dist/tests/core/changes/list-parse.test.js +120 -0
  45. package/dist/tests/core/changes/list-tree.test.js +44 -0
  46. package/dist/tests/core/changes/list.test.js +821 -0
  47. package/dist/tests/core/changes/parse.test.js +142 -0
  48. package/dist/tests/core/changes/predicates.test.js +83 -0
  49. package/dist/tests/core/changes/range.test.js +92 -0
  50. package/dist/tests/core/changes/receipt.test.js +947 -0
  51. package/dist/tests/core/changes/record-io.test.js +307 -0
  52. package/dist/tests/core/changes/record.test.js +549 -0
  53. package/dist/tests/core/changes/render-list.test.js +212 -0
  54. package/dist/tests/core/changes/render.test.js +202 -0
  55. package/dist/tests/core/changes/text-safety.test.js +226 -0
  56. package/dist/tests/core/changes/trailers.test.js +181 -0
  57. package/dist/tests/core/changes/worktree.test.js +259 -0
  58. package/dist/tests/core/currentness/check-insteadof.test.js +120 -0
  59. package/dist/tests/core/currentness/check.test.js +75 -14
  60. package/dist/tests/release/orchestrator-trailer.test.js +72 -0
  61. package/dist/tests/release/orchestrator.test.js +1 -0
  62. package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
  63. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +77 -0
  64. package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -0
  65. package/package.json +1 -1
  66. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  67. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  68. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  69. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,821 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const url_1 = require("url");
9
+ const helpers_1 = require("./helpers");
10
+ const list_fixtures_1 = require("./list-fixtures");
11
+ const list_1 = require("../../../src/core/changes/list");
12
+ const git_1 = require("../../../src/core/changes/git");
13
+ const range_1 = require("../../../src/core/changes/range");
14
+ const change_1 = require("../../../src/commands/change");
15
+ const types_1 = require("../../../src/core/changes/types");
16
+ let repo;
17
+ let template;
18
+ beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
19
+ afterAll(() => template.cleanup());
20
+ beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
21
+ afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
22
+ const list = (opts = {}) => (0, list_fixtures_1.listIn)(repo, opts);
23
+ const listed = (opts = {}) => (0, list_fixtures_1.ok)(list(opts));
24
+ const adopt = (id) => (0, list_fixtures_1.adoptIn)(repo, id);
25
+ const history = () => (0, list_fixtures_1.historyIn)(repo);
26
+ describe('listChanges: input and base (R5.1, R3.1, R3.2, R3.5)', () => {
27
+ test('resolves the local main by default and reports it', () => {
28
+ adopt();
29
+ expect(listed().base).toBe('main');
30
+ });
31
+ test('the default base costs one rev-parse per candidate tried, not a second one to pin it', () => {
32
+ adopt();
33
+ const spy = (0, helpers_1.spyOnGit)();
34
+ listed();
35
+ spy.restore();
36
+ const asked = (ref) => spy.calls.filter((a) => a.includes(`${ref}^{commit}`)).length;
37
+ expect(asked('refs/remotes/origin/HEAD')).toBe(1);
38
+ expect(asked('main')).toBe(1);
39
+ });
40
+ test('an explicit base is used and reported', () => {
41
+ adopt();
42
+ repo.git('branch', 'trunk');
43
+ expect(listed({ base: 'trunk' }).base).toBe('trunk');
44
+ });
45
+ test('an explicit base that does not resolve is base-unresolved', () => {
46
+ expect(list({ base: 'nope' })).toMatchObject({ ok: false, failure: { code: 'base-unresolved' } });
47
+ });
48
+ test('no resolvable default base is base-unresolved with the remedy', () => {
49
+ repo.git('branch', '-m', 'trunk');
50
+ expect(list()).toMatchObject({ ok: false, failure: { code: 'base-unresolved', remedy: 'pass --base <ref>' } });
51
+ });
52
+ test.each(['--upload-pack=x', '-x', 'a..b', 'a...b', 'main^', 'main~1', 'main@{1}', 'a b', 'main\n', ':/x', '', 'x'.repeat(300)])('an unsafe base %j is invalid-input and never reaches git', (base) => {
53
+ const spy = (0, helpers_1.spyOnGit)();
54
+ const outcome = list({ base });
55
+ spy.restore();
56
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
57
+ expect(spy.calls).toEqual([]);
58
+ });
59
+ test('an unsafe base is echoed escaped and clipped, never raw', () => {
60
+ const outcome = list({ base: `-x\u001b[31m${'y'.repeat(500)}` });
61
+ expect(outcome.ok).toBe(false);
62
+ if (!outcome.ok) {
63
+ expect(outcome.failure.message).not.toMatch(list_fixtures_1.UNSAFE);
64
+ expect(outcome.failure.message.length).toBeLessThan(300);
65
+ }
66
+ });
67
+ test.each([undefined, null, 5, {}, ''])('the repository root %j is invalid-input', (root) => {
68
+ expect((0, list_1.listChanges)((0, helpers_1.bad)(root), { since: list_fixtures_1.SINCE_48H })).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
69
+ });
70
+ test.each([undefined, null, 'x', 5, new Date(Number.NaN)])('the window start %j is invalid-input', (since) => {
71
+ expect((0, list_1.listChanges)(repo.root, (0, helpers_1.bad)({ since }))).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
72
+ });
73
+ test('options that are not an object are invalid-input', () => {
74
+ expect((0, list_1.listChanges)(repo.root, (0, helpers_1.bad)(undefined))).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
75
+ });
76
+ test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '5', null])('the limit %j is invalid-input', (limit) => {
77
+ expect(list({ limit })).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
78
+ });
79
+ test.each([['maxRecordsPerEntry', 0], ['maxRecords', -3], ['maxRecords', 1.5]])('%s %j is invalid-input', (key, value) => {
80
+ expect(list({ [key]: value })).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
81
+ });
82
+ test('a directory that is not a repository is git-failed, not a throw', () => {
83
+ const plain = fs_1.default.mkdtempSync(path_1.default.join(repo.home, 'plain-'));
84
+ expect((0, list_1.listChanges)(plain, { since: list_fixtures_1.SINCE_48H })).toMatchObject({ ok: false });
85
+ });
86
+ test('does not need a clean tree or HEAD on the base: it reads the base ref, not HEAD or the working tree', () => {
87
+ history();
88
+ repo.git('checkout', '-q', '-b', 'feat/x');
89
+ repo.commit({ 'x.ts': '1' }, 'feat: only on the branch', '2026-10-17T05:00:00Z');
90
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)), 'garbage in the working tree');
91
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'untracked.txt'), 'u');
92
+ const r = listed();
93
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['feat: bad record', 'chore(release): v1.0.0 [skip ci]', 'fix: no record', 'feat: first (#10)']);
94
+ expect(r.changes.find((c) => c.subject === 'feat: first (#10)')?.classification).toBe('recorded');
95
+ });
96
+ test('a base that moves during the run cannot change the answer: every git call after the pin uses commit ids', () => {
97
+ history();
98
+ const spy = (0, helpers_1.spyOnGit)();
99
+ listed();
100
+ spy.restore();
101
+ const afterPin = spy.calls.filter((a) => a.includes('log') || a.includes('diff') || a.includes('ls-tree') || a.includes('for-each-ref'));
102
+ expect(afterPin.length).toBeGreaterThan(0);
103
+ for (const call of afterPin)
104
+ expect(call).not.toContain('main');
105
+ });
106
+ });
107
+ describe('listChanges: adoption point (R5.4)', () => {
108
+ test('a repo where no file was ever added under docs/changes is not adopted and lists nothing', () => {
109
+ repo.commit({ 'a.ts': '1' }, 'feat: a', '2026-10-16T09:00:00Z');
110
+ expect(listed()).toMatchObject({ adopted: false, changes: [], truncated: false, omitted: 0 });
111
+ });
112
+ test('R5.4 a docs/changes/README.md alone does not count: the repo has not adopted Change Records', () => {
113
+ repo.commit({ 'docs/changes/README.md': 'hello' }, 'docs: readme', '2026-10-16T09:00:00Z');
114
+ repo.commit({ 'a.ts': '1' }, 'feat: a', '2026-10-16T10:00:00Z');
115
+ expect(listed()).toMatchObject({ adopted: false, changes: [], truncated: false, omitted: 0 });
116
+ });
117
+ test('R5.4 a README first and a record later: the adoption is the commit that added the record', () => {
118
+ repo.commit({ 'docs/changes/README.md': 'hello' }, 'docs: readme', '2026-10-16T09:00:00Z');
119
+ repo.commit({ 'a.ts': '1' }, 'feat: before the first record', '2026-10-16T10:00:00Z');
120
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: first record', '2026-10-16T11:00:00Z');
121
+ repo.commit({ 'b.ts': '1' }, 'fix: after', '2026-10-16T12:00:00Z');
122
+ const r = listed();
123
+ expect(r.adopted).toBe(true);
124
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['fix: after', 'feat: first record']);
125
+ });
126
+ test.each([
127
+ ['a record-shaped file in a subdirectory', 'docs/changes/sub/2026-10-10-x.md'],
128
+ ['a file with a record-like name but an uppercase slug', 'docs/changes/2026-10-10-Rec.md'],
129
+ ['a non-markdown file', 'docs/changes/2026-10-10-x.txt'],
130
+ ['a record-shaped name outside docs/changes', 'docs/other/2026-10-10-x.md'],
131
+ ])('R5.4 %s does not mark the adoption', (_label, file) => {
132
+ repo.commit({ [file]: 'x' }, 'docs: not a record', '2026-10-16T09:00:00Z');
133
+ repo.commit({ 'a.ts': '1' }, 'feat: a', '2026-10-16T10:00:00Z');
134
+ expect(listed()).toMatchObject({ adopted: false, changes: [] });
135
+ });
136
+ test('a record that was added and later deleted still marks the adoption', () => {
137
+ adopt();
138
+ repo.git('rm', '-q', (0, list_fixtures_1.recordPath)('2026-10-10-adopt'));
139
+ repo.git('commit', '-q', '-m', 'chore: drop');
140
+ expect(listed().adopted).toBe(true);
141
+ });
142
+ test('commits older than the first one that added a file under docs/changes are omitted; the adopting commit itself is kept', () => {
143
+ history();
144
+ expect((0, list_fixtures_1.subjects)(listed())).not.toContain('feat: before adoption');
145
+ expect((0, list_fixtures_1.subjects)(listed())).not.toContain('chore: init');
146
+ expect((0, list_fixtures_1.subjects)(listed())).toContain('feat: first (#10)');
147
+ });
148
+ test('modifying a file under docs/changes is not an adoption: only the commit that added the first file is', () => {
149
+ repo.commit({ 'a.ts': '1' }, 'feat: before', '2026-10-16T09:00:00Z');
150
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'docs: first file', '2026-10-16T10:00:00Z');
151
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST, { summary: 'Edit.' }) }, 'docs: edit', '2026-10-16T11:00:00Z');
152
+ expect((0, list_fixtures_1.subjects)(listed())).toEqual(['docs: edit', 'docs: first file']);
153
+ });
154
+ test('an adopted repo whose window holds no commit lists nothing', () => {
155
+ adopt();
156
+ expect(listed()).toMatchObject({ adopted: true, changes: [], truncated: false, omitted: 0 });
157
+ });
158
+ test('the adoption can be a root commit', () => {
159
+ repo.git('checkout', '-q', '--orphan', 'orph');
160
+ repo.git('rm', '-rfq', '.');
161
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: root record', '2026-10-16T20:00:00Z');
162
+ repo.commit({ 'z.ts': '1' }, 'fix: after root', '2026-10-16T21:00:00Z');
163
+ const r = listed({ base: 'orph' });
164
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'fix: after root': 'missing', 'feat: root record': 'recorded' });
165
+ expect(r.changes[1].records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST), id: list_fixtures_1.FIRST });
166
+ });
167
+ test('the adoption is decided on the first-parent history: a record merged in from a side branch adopts at the merge', () => {
168
+ repo.commit({ 'm0.ts': '1' }, 'feat: before merge', '2026-10-16T08:00:00Z');
169
+ repo.git('checkout', '-q', '-b', 'side');
170
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: side work', '2026-10-16T09:00:00Z');
171
+ repo.git('checkout', '-q', 'main');
172
+ repo.commit({ 'm1.ts': '1' }, 'fix: main work', '2026-10-16T10:00:00Z');
173
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side', '-m', 'Merge side (#7)');
174
+ const r = listed();
175
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['Merge side (#7)']);
176
+ expect(r.changes[0]).toMatchObject({ classification: 'recorded', pr: 7 });
177
+ expect(r.changes[0].records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST) });
178
+ });
179
+ });
180
+ describe('listChanges: classification (R5.3)', () => {
181
+ test('recorded, missing, exempt and invalid-record', () => {
182
+ history();
183
+ expect((0, list_fixtures_1.byClass)(listed())).toEqual({ 'feat: first (#10)': 'recorded', 'fix: no record': 'missing', 'chore(release): v1.0.0 [skip ci]': 'exempt', 'feat: bad record': 'invalid-record' });
184
+ });
185
+ test('entries come newest first, in first-parent order', () => {
186
+ history();
187
+ expect((0, list_fixtures_1.subjects)(listed())).toEqual(['feat: bad record', 'chore(release): v1.0.0 [skip ci]', 'fix: no record', 'feat: first (#10)']);
188
+ });
189
+ test('a recorded entry holds the path and the full frontmatter, nothing else', () => {
190
+ history();
191
+ const first = listed().changes.find((c) => c.subject === 'feat: first (#10)');
192
+ expect(first?.records).toHaveLength(1);
193
+ expect(first?.records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST), schema: 'awm.change-record/v1', id: list_fixtures_1.FIRST, title: `T ${list_fixtures_1.FIRST}`, summary_short: 'Primer cambio.', type: 'feat', modules: ['cli'] });
194
+ expect(first?.records[0]).not.toHaveProperty('diagnostics');
195
+ expect(first?.records[0]).toHaveProperty('verification.overall', 'pass');
196
+ });
197
+ test('an invalid-record entry holds the path and its diagnostics, no frontmatter', () => {
198
+ history();
199
+ const bad = listed().changes.find((c) => c.subject === 'feat: bad record');
200
+ expect(bad?.records).toHaveLength(1);
201
+ expect(bad?.records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)('2026-10-17-bad') });
202
+ expect(bad?.records[0]).not.toHaveProperty('schema');
203
+ expect((bad?.records[0]).diagnostics.map((d) => d.code)).toContain('body-section-missing');
204
+ });
205
+ test('a record that is not valid UTF-8 gets the verdict validate gives it: record-invalid, so invalid-record (never a lossy read)', () => {
206
+ adopt();
207
+ const rel = (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST);
208
+ fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(repo.root, rel)), { recursive: true });
209
+ // A valid record followed by a byte sequence that is not UTF-8: a lossy decode would turn it into U+FFFD and say nothing.
210
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, rel), Buffer.concat([Buffer.from((0, list_fixtures_1.record)(list_fixtures_1.FIRST)), Buffer.from([0xc3, 0x28, 0x0a])]));
211
+ repo.gitAt('2026-10-16T20:00:00Z', 'add', '-A');
212
+ repo.gitAt('2026-10-16T20:00:00Z', 'commit', '-q', '-m', 'feat: latin one');
213
+ const entry = listed().changes[0];
214
+ expect(entry.classification).toBe('invalid-record');
215
+ expect(entry.records).toHaveLength(1);
216
+ const diagnostics = entry.records[0].diagnostics;
217
+ expect(diagnostics.map((d) => d.code)).toEqual(['record-invalid']);
218
+ expect(diagnostics[0].message).toMatch(/is not valid UTF-8/);
219
+ const validated = (0, change_1.runChangeValidate)(repo.root, rel, { json: true });
220
+ const report = JSON.parse(validated.stdout);
221
+ expect(report.records[0].diagnostics.map((d) => d.code)).toEqual(['record-invalid']);
222
+ });
223
+ test('a record with a byte order mark is read as validate reads it (the BOM is kept, the record then fails as invalid)', () => {
224
+ adopt();
225
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: `\ufeff${(0, list_fixtures_1.record)(list_fixtures_1.FIRST)}` }, 'feat: bom', '2026-10-16T20:00:00Z');
226
+ expect(listed().changes[0].classification).toBe('invalid-record');
227
+ });
228
+ test('R1.4 a record whose links.issue carries credentials is invalid-record, and list never prints the credentials', () => {
229
+ adopt();
230
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST, { extra: 'links:\n issue: "https://user:SECRET@example.com/issues/1"\n' }) }, 'feat: leaky link', '2026-10-16T20:00:00Z');
231
+ const r = listed();
232
+ expect(r.changes[0].classification).toBe('invalid-record');
233
+ expect(JSON.stringify(r)).not.toContain('SECRET');
234
+ expect(JSON.stringify(r)).toContain('links.issue');
235
+ });
236
+ test('R5.3 a record committed with the executable bit (mode 100755) is read like any other', () => {
237
+ adopt();
238
+ const rel = (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST);
239
+ fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(repo.root, rel)), { recursive: true });
240
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, rel), (0, list_fixtures_1.record)(list_fixtures_1.FIRST));
241
+ repo.git('add', '--', rel);
242
+ repo.git('update-index', '--chmod=+x', '--', rel);
243
+ expect(repo.git('ls-files', '-s', '--', rel)).toMatch(/^100755 /);
244
+ repo.gitAt('2026-10-16T20:00:00Z', 'commit', '-q', '-m', 'feat: executable record');
245
+ expect(listed().changes[0]).toMatchObject({ classification: 'recorded', records: [expect.objectContaining({ path: rel, id: list_fixtures_1.FIRST })] });
246
+ });
247
+ (0, helpers_1.posixOnly)('R5.3 a record that is a symlink in the commit (mode 120000) is invalid-record, never read through', () => {
248
+ adopt();
249
+ const rel = (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST);
250
+ fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(repo.root, rel)), { recursive: true });
251
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'real.md'), (0, list_fixtures_1.record)(list_fixtures_1.FIRST));
252
+ fs_1.default.symlinkSync('../../real.md', path_1.default.join(repo.root, rel));
253
+ repo.git('add', '-A');
254
+ repo.gitAt('2026-10-16T20:00:00Z', 'commit', '-q', '-m', 'feat: linked record');
255
+ const entry = listed().changes[0];
256
+ expect(entry.classification).toBe('invalid-record');
257
+ expect(entry.records[0].diagnostics[0].message).toMatch(/not a regular file/);
258
+ });
259
+ test('exempt and missing entries have no records', () => {
260
+ history();
261
+ const r = listed();
262
+ expect(r.changes.find((c) => c.classification === 'exempt')?.records).toEqual([]);
263
+ expect(r.changes.find((c) => c.classification === 'missing')?.records).toEqual([]);
264
+ });
265
+ test('a record plus the opt-out trailer is recorded: the first matching rule wins', () => {
266
+ adopt();
267
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: both\n\nChange-Record: none', '2026-10-16T20:00:00Z');
268
+ expect((0, list_fixtures_1.byClass)(listed())['feat: both']).toBe('recorded');
269
+ });
270
+ test('an invalid record plus the opt-out trailer is invalid-record, not exempt', () => {
271
+ adopt();
272
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: 'nope' }, 'feat: both\n\nChange-Record: none', '2026-10-16T20:00:00Z');
273
+ expect((0, list_fixtures_1.byClass)(listed())['feat: both']).toBe('invalid-record');
274
+ });
275
+ test('the opt-out must be a trailer of the final paragraph: a mention in the subject or the body does not exempt', () => {
276
+ adopt();
277
+ repo.commit({ 'a.ts': '1' }, 'feat: Change-Record: none', '2026-10-16T20:00:00Z');
278
+ repo.commit({ 'b.ts': '1' }, 'feat: b\n\nChange-Record: none\n\nand more text', '2026-10-16T21:00:00Z');
279
+ expect((0, list_fixtures_1.byClass)(listed())).toEqual({ 'feat: Change-Record: none': 'missing', 'feat: b': 'missing' });
280
+ });
281
+ test('a commit adding two records, one of them invalid, is invalid-record and lists both', () => {
282
+ adopt();
283
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-good')]: (0, list_fixtures_1.record)('2026-10-16-good'), [(0, list_fixtures_1.recordPath)('2026-10-16-broken')]: (0, list_fixtures_1.record)('2026-10-16-other') }, 'feat: two', '2026-10-16T20:00:00Z');
284
+ const entry = listed().changes[0];
285
+ expect(entry.classification).toBe('invalid-record');
286
+ expect(entry.records.map((r) => r.path).sort()).toEqual([(0, list_fixtures_1.recordPath)('2026-10-16-broken'), (0, list_fixtures_1.recordPath)('2026-10-16-good')]);
287
+ const diagnostics = Object.fromEntries(entry.records.map((r) => [r.path, r.diagnostics.length]));
288
+ expect(diagnostics[(0, list_fixtures_1.recordPath)('2026-10-16-good')]).toBe(0);
289
+ expect(diagnostics[(0, list_fixtures_1.recordPath)('2026-10-16-broken')]).toBeGreaterThan(0);
290
+ expect(entry.records.every((r) => !('schema' in r))).toBe(true);
291
+ });
292
+ test('a commit adding two valid records is recorded and lists both', () => {
293
+ adopt();
294
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-one')]: (0, list_fixtures_1.record)('2026-10-16-one'), [(0, list_fixtures_1.recordPath)('2026-10-16-two')]: (0, list_fixtures_1.record)('2026-10-16-two') }, 'feat: two', '2026-10-16T20:00:00Z');
295
+ const entry = listed().changes[0];
296
+ expect(entry.classification).toBe('recorded');
297
+ expect(entry.records).toHaveLength(2);
298
+ });
299
+ test('modifying an existing record is not adding one: the commit is missing', () => {
300
+ adopt();
301
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-10-adopt')]: (0, list_fixtures_1.record)('2026-10-10-adopt', { summary: 'Edited.' }) }, 'fix: edit the record', '2026-10-16T20:00:00Z');
302
+ expect((0, list_fixtures_1.byClass)(listed())['fix: edit the record']).toBe('missing');
303
+ });
304
+ test('renames are not followed: the new name of a renamed record is an added record', () => {
305
+ adopt();
306
+ repo.git('mv', (0, list_fixtures_1.recordPath)('2026-10-10-adopt'), (0, list_fixtures_1.recordPath)('2026-10-16-moved'));
307
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, (0, list_fixtures_1.recordPath)('2026-10-16-moved')), (0, list_fixtures_1.record)('2026-10-16-moved'));
308
+ repo.git('add', '-A');
309
+ repo.gitAt('2026-10-16T20:00:00Z', 'commit', '-q', '-m', 'refactor: rename the record');
310
+ const entry = listed().changes[0];
311
+ expect(entry.classification).toBe('recorded');
312
+ expect(entry.records.map((r) => r.path)).toEqual([(0, list_fixtures_1.recordPath)('2026-10-16-moved')]);
313
+ });
314
+ test.each([
315
+ ['a README', 'docs/changes/README.md'],
316
+ ['a file in a subdirectory', 'docs/changes/sub/2026-10-16-x.md'],
317
+ ['a name that does not match the record pattern', 'docs/changes/2026-10-16-X.md'],
318
+ ['a record outside the directory', 'docs/2026-10-16-x.md'],
319
+ ['a lookalike directory', 'docs/changes2/2026-10-16-x.md'],
320
+ ])('%s is not a record: the commit is missing', (_label, file) => {
321
+ adopt();
322
+ repo.commit({ [file]: (0, list_fixtures_1.record)('2026-10-16-x') }, 'feat: not a record', '2026-10-16T20:00:00Z');
323
+ expect((0, list_fixtures_1.byClass)(listed())['feat: not a record']).toBe('missing');
324
+ });
325
+ test('R1.1 a name that has the record shape but an impossible date is a record candidate: invalid-record with name-invalid', () => {
326
+ adopt();
327
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-13-45-x')]: (0, list_fixtures_1.record)('2026-13-45-x') }, 'feat: bad name', '2026-10-16T20:00:00Z');
328
+ const entry = listed().changes[0];
329
+ expect(entry.classification).toBe('invalid-record');
330
+ expect(entry.records[0].diagnostics.map((d) => d.code)).toContain('name-invalid');
331
+ });
332
+ test('R1.1 a name that has the record shape but a slug over 64 characters is a record candidate: invalid-record with name-invalid', () => {
333
+ adopt();
334
+ const id = `2026-10-16-${'a'.repeat(65)}`;
335
+ repo.commit({ [(0, list_fixtures_1.recordPath)(id)]: (0, list_fixtures_1.record)(id) }, 'feat: long slug', '2026-10-16T20:00:00Z');
336
+ const entry = listed().changes[0];
337
+ expect(entry.classification).toBe('invalid-record');
338
+ expect(entry.records[0].diagnostics.map((d) => d.code)).toContain('name-invalid');
339
+ });
340
+ test('link files are not checked: a record whose links.plan does not exist is still recorded', () => {
341
+ adopt();
342
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST, { extra: 'links:\n plan: docs/plans/missing.md\n' }) }, 'feat: links', '2026-10-16T20:00:00Z');
343
+ expect((0, list_fixtures_1.byClass)(listed())['feat: links']).toBe('recorded');
344
+ });
345
+ test('the record is read from history: deleting it later does not change the entry', () => {
346
+ adopt();
347
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: first', '2026-10-16T20:00:00Z');
348
+ repo.git('rm', '-q', (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST));
349
+ repo.gitAt('2026-10-16T21:00:00Z', 'commit', '-q', '-m', 'chore: delete it');
350
+ const r = listed();
351
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'feat: first': 'recorded', 'chore: delete it': 'missing' });
352
+ });
353
+ test('the record is read from history, not the working tree', () => {
354
+ adopt();
355
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST, { summary: 'Del historial.' }) }, 'feat: first', '2026-10-16T20:00:00Z');
356
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)), 'not a record any more');
357
+ expect(listed().changes[0].records[0]).toMatchObject({ summary_short: 'Del historial.' });
358
+ });
359
+ test('a merge commit whose side branch adds the record is recorded; the side commits are not listed', () => {
360
+ adopt();
361
+ repo.git('checkout', '-q', '-b', 'side');
362
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: side work', '2026-10-16T09:00:00Z');
363
+ repo.commit({ 's.ts': '1' }, 'fix: more side work', '2026-10-16T09:30:00Z');
364
+ repo.git('checkout', '-q', 'main');
365
+ repo.commit({ 'm.ts': '1' }, 'fix: main work', '2026-10-16T10:00:00Z');
366
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side', '-m', 'Merge side');
367
+ const r = listed();
368
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'Merge side': 'recorded', 'fix: main work': 'missing' });
369
+ });
370
+ test('an opt-out trailer only on a commit of the merged side branch exempts the merge commit', () => {
371
+ adopt();
372
+ repo.git('checkout', '-q', '-b', 'side');
373
+ repo.commit({ 's.ts': '1' }, 'chore: side\n\nChange-Record: none', '2026-10-16T09:00:00Z');
374
+ repo.git('checkout', '-q', 'main');
375
+ repo.commit({ 'm.ts': '1' }, 'fix: main work', '2026-10-16T10:00:00Z');
376
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side', '-m', 'Merge side');
377
+ expect((0, list_fixtures_1.byClass)(listed())).toEqual({ 'Merge side': 'exempt', 'fix: main work': 'missing' });
378
+ });
379
+ test('a merge commit whose side branch has no record and no opt-out is missing', () => {
380
+ adopt();
381
+ repo.git('checkout', '-q', '-b', 'side');
382
+ repo.commit({ 's.ts': '1' }, 'fix: side', '2026-10-16T09:00:00Z');
383
+ repo.git('checkout', '-q', 'main');
384
+ repo.commit({ 'm.ts': '1' }, 'fix: main work', '2026-10-16T10:00:00Z');
385
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side', '-m', 'Merge side');
386
+ expect((0, list_fixtures_1.byClass)(listed())['Merge side']).toBe('missing');
387
+ });
388
+ test('a record that the side branch added and the first parent already had is not added by the merge', () => {
389
+ adopt();
390
+ repo.git('checkout', '-q', '-b', 'side');
391
+ repo.commit({ 's.ts': '1' }, 'fix: side', '2026-10-16T09:00:00Z');
392
+ repo.git('checkout', '-q', 'main');
393
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: first', '2026-10-16T10:00:00Z');
394
+ repo.git('checkout', '-q', 'side');
395
+ repo.git('merge', '-q', '--no-edit', 'main');
396
+ repo.git('checkout', '-q', 'main');
397
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side', '-m', 'Merge side');
398
+ expect((0, list_fixtures_1.byClass)(listed())).toEqual({ 'Merge side': 'missing', 'feat: first': 'recorded' });
399
+ });
400
+ });
401
+ describe('listChanges: size and paths of what git prints (R5.7, R1.8)', () => {
402
+ test('an oversize record in history is invalid-record with record-too-large and is never read', () => {
403
+ adopt();
404
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) + 'x'.repeat(types_1.MAX_RECORD_BYTES + 10) }, 'feat: huge', '2026-10-16T20:00:00Z');
405
+ const spy = (0, helpers_1.spyOnGit)();
406
+ const entry = listed().changes[0];
407
+ spy.restore();
408
+ expect(entry.classification).toBe('invalid-record');
409
+ expect(entry.records).toEqual([{ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST), diagnostics: [expect.objectContaining({ code: 'record-too-large' })] }]);
410
+ expect(spy.calls.filter((a) => a.includes('show') || a.includes('cat-file'))).toEqual([]);
411
+ });
412
+ test('a record of exactly the size limit is read', () => {
413
+ adopt();
414
+ const head = (0, list_fixtures_1.record)(list_fixtures_1.FIRST);
415
+ const padded = head + '\n'.repeat(types_1.MAX_RECORD_BYTES - Buffer.byteLength(head));
416
+ expect(Buffer.byteLength(padded)).toBe(types_1.MAX_RECORD_BYTES);
417
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: padded }, 'feat: at the limit', '2026-10-16T20:00:00Z');
418
+ expect(listed().changes[0].classification).toBe('recorded');
419
+ });
420
+ test('the size is checked before the blob is read: the one oversize record of an entry does not hide the others', () => {
421
+ adopt();
422
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-big')]: (0, list_fixtures_1.record)('2026-10-16-big') + 'x'.repeat(types_1.MAX_RECORD_BYTES), [(0, list_fixtures_1.recordPath)('2026-10-16-ok')]: (0, list_fixtures_1.record)('2026-10-16-ok') }, 'feat: both', '2026-10-16T20:00:00Z');
423
+ const entry = listed().changes[0];
424
+ expect(entry.records.map((r) => [r.path, r.diagnostics.length > 0])).toEqual([[(0, list_fixtures_1.recordPath)('2026-10-16-big'), true], [(0, list_fixtures_1.recordPath)('2026-10-16-ok'), false]]);
425
+ });
426
+ (0, helpers_1.posixOnly)('a record that is a symlink in history is invalid-record and its target is never read', () => {
427
+ adopt();
428
+ fs_1.default.mkdirSync(path_1.default.join(repo.root, 'docs', 'changes'), { recursive: true });
429
+ fs_1.default.symlinkSync('../../README.md', path_1.default.join(repo.root, (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)));
430
+ repo.git('add', '-A');
431
+ repo.gitAt('2026-10-16T20:00:00Z', 'commit', '-q', '-m', 'feat: link');
432
+ const spy = (0, helpers_1.spyOnGit)();
433
+ const entry = listed().changes[0];
434
+ spy.restore();
435
+ expect(entry.classification).toBe('invalid-record');
436
+ expect(entry.records[0]).toMatchObject({ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST), diagnostics: [{ code: 'record-invalid' }] });
437
+ expect(spy.calls.filter((a) => a.includes('show'))).toEqual([]);
438
+ });
439
+ (0, helpers_1.posixOnly)('a path that git prints is validated: a record name with a newline is not a record', () => {
440
+ adopt();
441
+ repo.commit({ [`docs/changes/2026-10-16-a\nb.md`]: (0, list_fixtures_1.record)('2026-10-16-a') }, 'feat: odd name', '2026-10-16T20:00:00Z');
442
+ expect((0, list_fixtures_1.byClass)(listed())['feat: odd name']).toBe('missing');
443
+ });
444
+ test('a failing ls-tree makes the entry invalid-record with git-failed, and the others are still read', () => {
445
+ adopt();
446
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-a')]: (0, list_fixtures_1.record)('2026-10-16-a') }, 'feat: a', '2026-10-16T10:00:00Z');
447
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-b')]: (0, list_fixtures_1.record)('2026-10-16-b') }, 'feat: b', '2026-10-16T11:00:00Z');
448
+ let fails = 1;
449
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('ls-tree') && fails-- > 0)
450
+ throw new Error('boom'); });
451
+ const r = listed();
452
+ spy.restore();
453
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'feat: b': 'invalid-record', 'feat: a': 'recorded' });
454
+ expect(r.changes[0].records).toEqual([{ path: (0, list_fixtures_1.recordPath)('2026-10-16-b'), diagnostics: [expect.objectContaining({ code: 'git-failed' })] }]);
455
+ });
456
+ test('a failing read of one blob makes that record git-failed only', () => {
457
+ adopt();
458
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-a')]: (0, list_fixtures_1.record)('2026-10-16-a') }, 'feat: a', '2026-10-16T10:00:00Z');
459
+ repo.commit({ [(0, list_fixtures_1.recordPath)('2026-10-16-b')]: (0, list_fixtures_1.record)('2026-10-16-b') }, 'feat: b', '2026-10-16T11:00:00Z');
460
+ let fails = 1;
461
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('show') && fails-- > 0)
462
+ throw new Error('boom'); });
463
+ const r = listed();
464
+ spy.restore();
465
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'feat: b': 'invalid-record', 'feat: a': 'recorded' });
466
+ expect(r.changes[0].records[0]).toMatchObject({ diagnostics: [{ code: 'git-failed' }] });
467
+ });
468
+ test('a failing listing of the added files makes that entry invalid-record with git-failed, not missing', () => {
469
+ adopt();
470
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
471
+ let diffs = 0; // the first diff is the adoption check (R5.4), which must succeed
472
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('diff') && ++diffs > 1)
473
+ throw new Error('boom'); });
474
+ const r = listed();
475
+ spy.restore();
476
+ expect(r.changes[0].classification).toBe('invalid-record');
477
+ expect(r.changes[0].records).toEqual([{ path: 'docs/changes', diagnostics: [expect.objectContaining({ code: 'git-failed' })] }]);
478
+ });
479
+ test('a failing scan of the history is a git-failed failure, not a throw', () => {
480
+ adopt();
481
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('log') && args.includes('-z') && args.some((a) => a.startsWith('--format=%H%x1f')))
482
+ throw new Error('boom'); });
483
+ const outcome = list();
484
+ spy.restore();
485
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
486
+ });
487
+ test('a failing adoption lookup is a git-failed failure', () => {
488
+ adopt();
489
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('log') && args.some((a) => a.startsWith(':(top,glob)docs/changes/')))
490
+ throw new Error('boom'); });
491
+ const outcome = list();
492
+ spy.restore();
493
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
494
+ });
495
+ });
496
+ describe('listChanges: derived fields (R5.5)', () => {
497
+ test('the PR number comes from a trailing (#n)', () => {
498
+ adopt();
499
+ repo.commit({ 'a.ts': '1' }, 'feat: squash style (#123)', '2026-10-16T10:00:00Z');
500
+ expect(listed().changes[0].pr).toBe(123);
501
+ });
502
+ test('release is the tag that contains the commit, null when none does', () => {
503
+ adopt();
504
+ repo.commit({ 'a.ts': '1' }, 'fix: tagged', '2026-10-16T10:00:00Z');
505
+ repo.gitAt('2026-10-16T11:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'v1.0.0');
506
+ repo.commit({ 'b.ts': '1' }, 'fix: untagged', '2026-10-16T12:00:00Z');
507
+ const r = listed();
508
+ expect(r.changes.map((c) => [c.subject, c.release])).toEqual([['fix: untagged', null], ['fix: tagged', 'v1.0.0']]);
509
+ });
510
+ test('a tag on a later commit contains the earlier ones', () => {
511
+ adopt();
512
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
513
+ repo.commit({ 'b.ts': '1' }, 'fix: b', '2026-10-16T11:00:00Z');
514
+ repo.gitAt('2026-10-16T12:00:00Z', 'tag', '-a', 'v2.0.0', '-m', 'v2.0.0');
515
+ expect(listed().changes.map((c) => c.release)).toEqual(['v2.0.0', 'v2.0.0']);
516
+ });
517
+ test('the earliest-created tag wins, whatever its name', () => {
518
+ adopt();
519
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
520
+ repo.gitAt('2026-10-16T13:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'late');
521
+ repo.gitAt('2026-10-16T11:00:00Z', 'tag', '-a', 'v9.9.9', '-m', 'early');
522
+ expect(listed().changes[0].release).toBe('v9.9.9');
523
+ });
524
+ test('equal creation dates are broken by tag name ascending', () => {
525
+ adopt();
526
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
527
+ for (const name of ['v1.0.2', 'v1.0.0', 'v1.0.1'])
528
+ repo.gitAt('2026-10-16T11:00:00Z', 'tag', '-a', name, '-m', name);
529
+ expect(listed().changes[0].release).toBe('v1.0.0');
530
+ });
531
+ test('R5.5 an annotated tag takes precedence over a lightweight one, even when the lightweight one is dated earlier', () => {
532
+ adopt();
533
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
534
+ repo.git('tag', 'v0.0.1'); // dated by its commit, 10:00
535
+ repo.gitAt('2026-10-16T13:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'annotated, later');
536
+ expect(listed().changes[0].release).toBe('v1.0.0');
537
+ });
538
+ test('R5.5 among annotated tags the earliest tagger date wins, a lightweight tag never competes', () => {
539
+ adopt();
540
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
541
+ repo.git('tag', 'aaa-light');
542
+ repo.gitAt('2026-10-16T14:00:00Z', 'tag', '-a', 'v2.0.0', '-m', 'late');
543
+ repo.gitAt('2026-10-16T12:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'early');
544
+ expect(listed().changes[0].release).toBe('v1.0.0');
545
+ });
546
+ test('R5.5 a lightweight tag is chosen only when no annotated tag contains the commit; ties go by name', () => {
547
+ adopt();
548
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
549
+ repo.git('tag', 'zeta');
550
+ repo.git('tag', 'alpha');
551
+ repo.git('tag', 'mid');
552
+ expect(listed().changes[0].release).toBe('alpha');
553
+ // An annotated tag on a LATER commit also contains it, and now takes precedence.
554
+ repo.commit({ 'b.ts': '1' }, 'fix: b', '2026-10-16T11:00:00Z');
555
+ repo.gitAt('2026-10-16T12:00:00Z', 'tag', '-a', 'v3.0.0', '-m', 'v3');
556
+ expect(listed().changes.map((c) => c.release)).toEqual(['v3.0.0', 'v3.0.0']);
557
+ });
558
+ describe('R5.5 the tag lookup is bounded: only the 500 earliest-dated containing tags are examined per entry', () => {
559
+ /** Lightweight tags pointing at `sha`, created with one fast-import process. */
560
+ const lightTags = (sha, names) => {
561
+ repo.gitInput(names.map((n) => `reset refs/tags/${n}\nfrom ${sha}\n`).join('\n'), 'fast-import', '--quiet');
562
+ };
563
+ const NAMES = Array.from({ length: 600 }, (_, i) => `t${String(i).padStart(4, '0')}`);
564
+ test('the for-each-ref call asks for at most 500 tags', () => {
565
+ adopt();
566
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
567
+ lightTags(repo.git('rev-parse', 'HEAD'), NAMES);
568
+ const spy = (0, helpers_1.spyOnGit)();
569
+ listed();
570
+ spy.restore();
571
+ const lookups = spy.calls.filter((a) => a.includes('for-each-ref'));
572
+ expect(lookups).toHaveLength(1);
573
+ expect(lookups[0]).toContain('--count=500');
574
+ });
575
+ test('600 lightweight tags on the commit: the earliest by date then name is the release', () => {
576
+ adopt();
577
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
578
+ lightTags(repo.git('rev-parse', 'HEAD'), NAMES);
579
+ expect(listed().changes[0].release).toBe('t0000');
580
+ });
581
+ test('the earliest annotated tag among the first 500 still wins over 600 lightweight tags', () => {
582
+ adopt();
583
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
584
+ lightTags(repo.git('rev-parse', 'HEAD'), NAMES);
585
+ repo.gitAt('2026-10-16T09:00:00Z', 'tag', '-a', 'v1.0.0', '-m', 'early annotated');
586
+ expect(listed().changes[0].release).toBe('v1.0.0');
587
+ });
588
+ test('documented limit: an annotated tag created after 500 earlier-dated tags is not examined, so the lightweight one is reported', () => {
589
+ adopt();
590
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
591
+ lightTags(repo.git('rev-parse', 'HEAD'), NAMES);
592
+ repo.gitAt('2026-10-16T13:00:00Z', 'tag', '-a', 'v9.9.9', '-m', 'late annotated');
593
+ expect(listed().changes[0].release).toBe('t0000');
594
+ });
595
+ });
596
+ test('only tags count: a branch that contains the commit is not a release', () => {
597
+ adopt();
598
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
599
+ repo.git('branch', 'release/1.0');
600
+ expect(listed().changes[0].release).toBeNull();
601
+ });
602
+ (0, helpers_1.posixOnly)('a tag name with a bidi override is escaped, never raw', () => {
603
+ adopt();
604
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
605
+ repo.git('tag', 'v1\u202e0');
606
+ const release = listed().changes[0].release;
607
+ expect(release).toBe('v1\\u202e0');
608
+ expect(release).not.toMatch(list_fixtures_1.UNSAFE);
609
+ });
610
+ test('a git failure while looking for the tags is git-failed, never a release of null', () => {
611
+ adopt();
612
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
613
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref'))
614
+ throw new Error('boom'); });
615
+ const outcome = list();
616
+ spy.restore();
617
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
618
+ });
619
+ test('repo is host/owner/name with credentials stripped (https)', () => {
620
+ adopt();
621
+ repo.git('remote', 'add', 'origin', 'https://user:tok@github.com/Kodria/belanz.git');
622
+ const r = listed();
623
+ expect(r.repo).toBe('github.com/Kodria/belanz');
624
+ expect(JSON.stringify(r)).not.toContain('tok');
625
+ expect(JSON.stringify(r)).not.toContain('user');
626
+ });
627
+ test('repo is host/owner/name for an scp-style remote', () => {
628
+ adopt();
629
+ repo.git('remote', 'add', 'origin', 'git@GitHub.com:Kodria/belanz.git');
630
+ expect(listed().repo).toBe('github.com/Kodria/belanz');
631
+ });
632
+ test('repo is null without a remote', () => {
633
+ adopt();
634
+ expect(listed().repo).toBeNull();
635
+ });
636
+ test.each([
637
+ ['an scp-style user:password containing @', 'user:p@ss@host.example:o/n.git'],
638
+ ['a password in an ssh URL', 'ssh://git:secretpw@host.example/o/n.git'],
639
+ ['a token as the user', 'https://ghp_secrettoken@github.com/o/n'],
640
+ ])('no part of the credentials appears for %s', (_label, url) => {
641
+ adopt();
642
+ repo.git('remote', 'add', 'origin', url);
643
+ const out = JSON.stringify(listed());
644
+ expect(out).not.toMatch(/secret|ss@|p@ss|ghp_/);
645
+ });
646
+ test('a remote with control characters in it is not echoed raw', () => {
647
+ adopt();
648
+ repo.git('config', 'remote.origin.url', 'https://host.example/o/n\u001b[31m');
649
+ const r = listed();
650
+ expect(r.repo === null || !list_fixtures_1.UNSAFE.test(r.repo)).toBe(true);
651
+ });
652
+ });
653
+ describe('listChanges: a committer date that cannot be one (R5.5)', () => {
654
+ test.each([
655
+ ['empty', ''],
656
+ ['negative', '-5 +0000'],
657
+ ['not a number', 'abc +0000'],
658
+ ['without a time zone', '1700000000'],
659
+ ['beyond what a Date holds', '9000000000000 +0000'],
660
+ ['far beyond', '99999999999999 +0000'],
661
+ ['the largest 64-bit number', '18446744073709551615 +0000'],
662
+ ])('a %s date gives merged_at null, the commit is listed normally and the run does not fail', (_label, date) => {
663
+ adopt();
664
+ repo.commit({ 'a.ts': '1' }, 'fix: before', '2026-10-16T10:00:00Z');
665
+ const sha = (0, list_fixtures_1.commitLiterally)(repo, date);
666
+ repo.commit({ 'b.ts': '1' }, 'fix: after', '2026-10-16T12:00:00Z');
667
+ const r = listed();
668
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['fix: after', 'fix: odd date', 'fix: before']);
669
+ expect(r.changes[1]).toMatchObject({ sha, merged_at: null, classification: 'missing', pr: null });
670
+ expect(r.changes[0].merged_at).toBe('2026-10-16T12:00:00.000Z');
671
+ });
672
+ test('such a commit is inside any window, even a narrow one, since it cannot be shown to be outside it', () => {
673
+ adopt();
674
+ (0, list_fixtures_1.commitLiterally)(repo, 'abc +0000');
675
+ expect((0, list_fixtures_1.subjects)(listed({ since: new Date('2026-10-17T11:59:00Z') }))).toEqual(['fix: odd date']);
676
+ });
677
+ test('it counts against the limit like any other entry', () => {
678
+ adopt();
679
+ (0, list_fixtures_1.commitLiterally)(repo, '');
680
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
681
+ expect(listed({ limit: 1 })).toMatchObject({ truncated: true, omitted: 1 });
682
+ });
683
+ test('a record added by such a commit is read like any other', () => {
684
+ adopt();
685
+ const files = { [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) };
686
+ for (const [rel, content] of Object.entries(files)) {
687
+ fs_1.default.mkdirSync(path_1.default.dirname(path_1.default.join(repo.root, rel)), { recursive: true });
688
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, rel), content);
689
+ }
690
+ repo.git('add', '-A');
691
+ (0, list_fixtures_1.commitLiterally)(repo, '', 'feat: odd and recorded');
692
+ expect((0, list_fixtures_1.byClass)(listed())).toEqual({ 'feat: odd and recorded': 'recorded' });
693
+ });
694
+ test('the largest instant a Date can hold is representable: a future-dated commit that is included', () => {
695
+ adopt();
696
+ (0, list_fixtures_1.commitLiterally)(repo, '8640000000000 +0000');
697
+ expect(listed().changes[0].merged_at).toBe('+275760-09-13T00:00:00.000Z');
698
+ });
699
+ test('the epoch itself is a date: such a commit is older than the window', () => {
700
+ adopt();
701
+ (0, list_fixtures_1.commitLiterally)(repo, '0 +0000');
702
+ expect(listed().changes).toEqual([]);
703
+ });
704
+ });
705
+ describe('listChanges: shallow clones and the default base (R5.1)', () => {
706
+ function clone(depth) {
707
+ const dest = path_1.default.join(repo.home, `clone-${depth ?? 'full'}`);
708
+ repo.git('clone', '-q', ...(depth === undefined ? [] : ['--depth', String(depth)]), (0, url_1.pathToFileURL)(repo.root).href, dest);
709
+ return dest;
710
+ }
711
+ test('a shallow clone fails with shallow-history and the remedy', () => {
712
+ adopt();
713
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
714
+ repo.commit({ 'b.ts': '1' }, 'fix: b', '2026-10-16T11:00:00Z');
715
+ const dest = clone(1);
716
+ expect(repo.git('-C', dest, 'rev-parse', '--is-shallow-repository')).toBe('true');
717
+ expect((0, list_1.listChanges)(dest, { since: list_fixtures_1.SINCE_48H })).toMatchObject({ ok: false, failure: { code: 'shallow-history', remedy: 'git fetch --unshallow' } });
718
+ });
719
+ test('a full clone of the same repository is listed', () => {
720
+ adopt();
721
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
722
+ const dest = clone();
723
+ expect(repo.git('-C', dest, 'rev-parse', '--is-shallow-repository')).toBe('false');
724
+ expect((0, list_fixtures_1.subjects)((0, list_fixtures_1.ok)((0, list_1.listChanges)(dest, { since: list_fixtures_1.SINCE_48H })))).toEqual(['fix: a']);
725
+ });
726
+ test('a git that cannot say whether the repository is shallow fails closed', () => {
727
+ adopt();
728
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('--is-shallow-repository'))
729
+ throw new Error('old git'); });
730
+ const outcome = list();
731
+ spy.restore();
732
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
733
+ });
734
+ test('list and change new resolve the same default base: origin/HEAD first', () => {
735
+ adopt();
736
+ repo.git('update-ref', 'refs/remotes/origin/main', repo.git('rev-parse', 'HEAD'));
737
+ repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
738
+ repo.commit({ 'a.ts': '1' }, 'fix: ahead of origin', '2026-10-16T10:00:00Z');
739
+ const r = listed();
740
+ expect(r.base).toBe('refs/remotes/origin/HEAD');
741
+ expect(r.base).toBe((0, git_1.resolveBaseCommit)(repo.root)?.base);
742
+ expect(r.changes).toEqual([]);
743
+ const range = (0, range_1.resolveRange)(repo.root, undefined);
744
+ expect(range.ok && range.mergeBase).toBe(repo.git('merge-base', 'refs/remotes/origin/HEAD', 'HEAD'));
745
+ });
746
+ test('without origin/HEAD both fall back to the local main', () => {
747
+ adopt();
748
+ expect(listed().base).toBe('main');
749
+ expect((0, git_1.resolveBaseCommit)(repo.root)?.base).toBe('main');
750
+ });
751
+ });
752
+ describe('listChanges: hostile text from git is escaped and clipped', () => {
753
+ test('control characters, line separators and bidi marks in a subject are escaped', () => {
754
+ adopt();
755
+ repo.commit({ 'a.ts': '1' }, 'feat: \u001b[31mred\u001b[0m \u202eevil\u202c \u2028 \u0085 \u007f end (#5)', '2026-10-16T10:00:00Z');
756
+ const entry = listed().changes[0];
757
+ expect(entry.subject).not.toMatch(list_fixtures_1.UNSAFE);
758
+ expect(entry.subject).toContain('\\u001b[31mred');
759
+ expect(entry.subject).toContain('\\u202e');
760
+ expect(entry.pr).toBe(5);
761
+ });
762
+ test('a subject is clipped to the subject cap with an ellipsis', () => {
763
+ adopt();
764
+ repo.commit({ 'a.ts': '1' }, `feat: ${'x'.repeat(5000)}`, '2026-10-16T10:00:00Z');
765
+ const subject = listed().changes[0].subject;
766
+ expect(list_1.MAX_SUBJECT).toBe(1024);
767
+ expect(subject.length).toBe(list_1.MAX_SUBJECT);
768
+ expect(subject.endsWith('…')).toBe(true);
769
+ });
770
+ test('a subject exactly at the cap is not clipped', () => {
771
+ adopt();
772
+ const subject = `feat: ${'x'.repeat(list_1.MAX_SUBJECT - 6)}`;
773
+ repo.commit({ 'a.ts': '1' }, subject, '2026-10-16T10:00:00Z');
774
+ expect(listed().changes[0].subject).toBe(subject);
775
+ });
776
+ test('the cut never splits a surrogate pair', () => {
777
+ adopt();
778
+ repo.commit({ 'a.ts': '1' }, `feat: ${'x'.repeat(list_1.MAX_SUBJECT - 7)}\u{1F600}\u{1F600}`, '2026-10-16T10:00:00Z');
779
+ const subject = listed().changes[0].subject;
780
+ expect(Buffer.from(subject, 'utf8').toString('utf8')).toBe(subject);
781
+ expect(subject.length).toBeLessThanOrEqual(list_1.MAX_SUBJECT);
782
+ });
783
+ test('a subject made only of controls is still one clean line', () => {
784
+ adopt();
785
+ repo.gitAt('2026-10-16T10:00:00Z', 'commit', '--allow-empty', '-q', '--allow-empty-message', '-m', '\u001b\u001b');
786
+ const subject = listed().changes[0].subject;
787
+ expect(subject).not.toMatch(list_fixtures_1.UNSAFE);
788
+ });
789
+ test('a commit with an empty message has an empty subject', () => {
790
+ adopt();
791
+ repo.gitAt('2026-10-16T10:00:00Z', 'commit', '--allow-empty', '-q', '--allow-empty-message', '-m', '');
792
+ expect(listed().changes[0].subject).toBe('');
793
+ });
794
+ test('bidi marks inside a valid record are escaped in what list returns', () => {
795
+ adopt();
796
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST, { summary: 'Uno \u202edos' }) }, 'feat: bidi', '2026-10-16T10:00:00Z');
797
+ const rec = listed().changes[0].records[0];
798
+ expect(rec.summary_short).toBe('Uno \\u202edos');
799
+ });
800
+ test('a sensor named __proto__ stays an own key of the returned record', () => {
801
+ adopt();
802
+ const text = (0, list_fixtures_1.record)(list_fixtures_1.FIRST).replace('sensors: { lint: pass }', 'sensors: { "__proto__": pass }');
803
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: text }, 'feat: proto', '2026-10-16T10:00:00Z');
804
+ const rec = listed().changes[0].records[0];
805
+ expect(Object.keys(rec.verification.sensors)).toEqual(['__proto__']);
806
+ expect({}.polluted).toBeUndefined();
807
+ });
808
+ test('a signed commit and log.showSignature do not change what is parsed', () => {
809
+ adopt();
810
+ (0, helpers_1.showSignaturesInLog)(repo);
811
+ (0, helpers_1.commitForgedSigned)(repo, 'fix: signed (#9)');
812
+ const r = listed({ since: new Date('2000-01-01T00:00:00Z') });
813
+ expect(r.changes[0]).toMatchObject({ subject: 'fix: signed (#9)', pr: 9, classification: 'missing' });
814
+ expect(r.changes[0].sha).toMatch(/^[0-9a-f]{40}$/);
815
+ });
816
+ test('GIT_LITERAL_PATHSPECS and GIT_GLOB_PATHSPECS in the environment do not change the answer', () => {
817
+ history();
818
+ const plain = (0, list_fixtures_1.byClass)(listed());
819
+ expect((0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1', GIT_GLOB_PATHSPECS: '1' }, () => (0, list_fixtures_1.byClass)(listed()))).toEqual(plain);
820
+ });
821
+ });