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.
Files changed (68) 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/sensors/index.js +9 -0
  4. package/dist/src/commands/sensors/run.js +1 -0
  5. package/dist/src/core/changes/command.js +38 -0
  6. package/dist/src/core/changes/derive.js +120 -0
  7. package/dist/src/core/changes/fs-safe.js +47 -0
  8. package/dist/src/core/changes/git-env.js +68 -0
  9. package/dist/src/core/changes/git.js +216 -0
  10. package/dist/src/core/changes/list-parse.js +67 -0
  11. package/dist/src/core/changes/list.js +408 -0
  12. package/dist/src/core/changes/parse.js +72 -0
  13. package/dist/src/core/changes/range.js +53 -0
  14. package/dist/src/core/changes/receipt.js +274 -0
  15. package/dist/src/core/changes/record-io.js +149 -0
  16. package/dist/src/core/changes/render-list.js +109 -0
  17. package/dist/src/core/changes/render.js +178 -0
  18. package/dist/src/core/changes/text-safety.js +116 -0
  19. package/dist/src/core/changes/trailers.js +76 -0
  20. package/dist/src/core/changes/types.js +26 -0
  21. package/dist/src/core/changes/validate-shape.js +219 -0
  22. package/dist/src/core/changes/worktree.js +53 -0
  23. package/dist/src/core/process/model.js +153 -10
  24. package/dist/src/index.js +4 -0
  25. package/dist/src/release/orchestrator.js +3 -1
  26. package/dist/tests/commands/change/new.test.js +1126 -0
  27. package/dist/tests/commands/change/validate.test.js +1145 -0
  28. package/dist/tests/commands/changes/list.test.js +423 -0
  29. package/dist/tests/commands/process.test.js +4 -0
  30. package/dist/tests/commands/sensors/index.test.js +1 -1
  31. package/dist/tests/core/changes/derive.test.js +455 -0
  32. package/dist/tests/core/changes/fs-safe.test.js +82 -0
  33. package/dist/tests/core/changes/git-env.test.js +143 -0
  34. package/dist/tests/core/changes/git.test.js +442 -0
  35. package/dist/tests/core/changes/helpers.js +269 -0
  36. package/dist/tests/core/changes/list-fixtures.js +122 -0
  37. package/dist/tests/core/changes/list-long-history.test.js +52 -0
  38. package/dist/tests/core/changes/list-paging.test.js +511 -0
  39. package/dist/tests/core/changes/list-parse.test.js +120 -0
  40. package/dist/tests/core/changes/list-tree.test.js +44 -0
  41. package/dist/tests/core/changes/list.test.js +821 -0
  42. package/dist/tests/core/changes/parse.test.js +142 -0
  43. package/dist/tests/core/changes/predicates.test.js +83 -0
  44. package/dist/tests/core/changes/range.test.js +92 -0
  45. package/dist/tests/core/changes/receipt.test.js +947 -0
  46. package/dist/tests/core/changes/record-io.test.js +307 -0
  47. package/dist/tests/core/changes/record.test.js +549 -0
  48. package/dist/tests/core/changes/render-list.test.js +212 -0
  49. package/dist/tests/core/changes/render.test.js +202 -0
  50. package/dist/tests/core/changes/text-safety.test.js +226 -0
  51. package/dist/tests/core/changes/trailers.test.js +181 -0
  52. package/dist/tests/core/changes/worktree.test.js +259 -0
  53. package/dist/tests/core/dashboard/write-html.test.js +2 -3
  54. package/dist/tests/core/evidence/store.test.js +2 -3
  55. package/dist/tests/core/ledger/store.test.js +3 -9
  56. package/dist/tests/core/process/discover.test.js +21 -0
  57. package/dist/tests/core/process/model.test.js +338 -0
  58. package/dist/tests/helpers/platform.js +27 -0
  59. package/dist/tests/release/orchestrator-trailer.test.js +72 -0
  60. package/dist/tests/release/orchestrator.test.js +1 -0
  61. package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
  62. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +78 -0
  63. package/dist/tests/structural/process-model-description-contract.test.js +34 -0
  64. package/package.json +1 -1
  65. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  66. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  67. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  68. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -0,0 +1,511 @@
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
+ let repo;
13
+ let template;
14
+ beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
15
+ afterAll(() => template.cleanup());
16
+ beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
17
+ afterEach(() => { jest.restoreAllMocks(); repo.cleanup(); });
18
+ const list = (opts = {}) => (0, list_fixtures_1.listIn)(repo, opts);
19
+ const listed = (opts = {}) => (0, list_fixtures_1.ok)(list(opts));
20
+ const adopt = (id) => (0, list_fixtures_1.adoptIn)(repo, id);
21
+ const history = () => (0, list_fixtures_1.historyIn)(repo);
22
+ describe('listChanges: window and bounds (R5.2, R5.5, R5.7)', () => {
23
+ test('the window start is inclusive and a commit one second older is out', () => {
24
+ adopt();
25
+ repo.commit({ 'a.ts': '1' }, 'fix: before', '2026-10-15T11:59:59Z');
26
+ repo.commit({ 'b.ts': '1' }, 'fix: at start', '2026-10-15T12:00:00Z');
27
+ repo.commit({ 'c.ts': '1' }, 'fix: after', '2026-10-15T12:00:01Z');
28
+ expect((0, list_fixtures_1.subjects)(listed())).toEqual(['fix: after', 'fix: at start']);
29
+ });
30
+ test('a future-dated commit is included', () => {
31
+ adopt();
32
+ repo.commit({ 'a.ts': '1' }, 'fix: future', '2030-01-01T00:00:00Z');
33
+ const r = listed();
34
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['fix: future']);
35
+ expect(r.changes[0].merged_at).toBe('2030-01-01T00:00:00.000Z');
36
+ });
37
+ test('each commit is tested individually: an old-dated commit between two in-window ones does not stop the walk', () => {
38
+ adopt();
39
+ repo.commit({ 'a.ts': '1' }, 'fix: x', '2026-10-16T10:00:00Z');
40
+ repo.commit({ 'b.ts': '1' }, 'fix: y old', '2026-10-01T10:00:00Z');
41
+ repo.commit({ 'c.ts': '1' }, 'fix: z', '2026-10-16T12:00:00Z');
42
+ expect((0, list_fixtures_1.subjects)(listed())).toEqual(['fix: z', 'fix: x']);
43
+ });
44
+ test('merged_at is the committer date, not the author date', () => {
45
+ adopt();
46
+ repo.gitAt('2026-10-16T12:00:00Z', 'commit', '--allow-empty', '-q', '-m', 'fix: dates', '--date=2020-01-01T00:00:00Z');
47
+ expect(repo.git('log', '-1', '--format=%at')).toBe(String(Date.parse('2020-01-01T00:00:00Z') / 1000));
48
+ const r = listed();
49
+ expect(r.changes[0].merged_at).toBe('2026-10-16T12:00:00.000Z');
50
+ });
51
+ test('the window start is reported as an ISO instant', () => {
52
+ adopt();
53
+ expect(listed().since).toBe('2026-10-15T12:00:00.000Z');
54
+ });
55
+ test('beyond the limit the newest entries are kept and the rest are counted', () => {
56
+ adopt();
57
+ for (let i = 1; i <= 5; i++)
58
+ repo.commit({ [`f${i}.ts`]: '1' }, `fix: n${i}`, `2026-10-16T0${i}:00:00Z`);
59
+ const r = listed({ limit: 3 });
60
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['fix: n5', 'fix: n4', 'fix: n3']);
61
+ expect(r).toMatchObject({ truncated: true, omitted: 2 });
62
+ });
63
+ test('exactly at the limit is not truncated', () => {
64
+ adopt();
65
+ for (let i = 1; i <= 3; i++)
66
+ repo.commit({ [`f${i}.ts`]: '1' }, `fix: n${i}`, `2026-10-16T0${i}:00:00Z`);
67
+ expect(listed({ limit: 3 })).toMatchObject({ truncated: false, omitted: 0 });
68
+ });
69
+ test('the limit counts window entries only, not older commits', () => {
70
+ adopt();
71
+ repo.commit({ 'a.ts': '1' }, 'fix: old', '2026-10-01T00:00:00Z');
72
+ repo.commit({ 'b.ts': '1' }, 'fix: new', '2026-10-16T00:00:00Z');
73
+ expect(listed({ limit: 1 })).toMatchObject({ truncated: false, omitted: 0 });
74
+ });
75
+ test('a limit above 200 is capped at 200: 205 window entries give 200 listed and 5 omitted', () => {
76
+ expect(list_1.DEFAULT_LIMIT).toBe(200);
77
+ expect(list_1.MAX_RECORDS_PER_ENTRY).toBe(50);
78
+ expect(list_1.MAX_RECORDS_PER_RUN).toBe(500);
79
+ adopt();
80
+ (0, helpers_1.importCommits)(repo, Array.from({ length: 205 }, (_, i) => ({ message: `fix: n${i}`, files: { [`f${i}.ts`]: '1' }, date: new Date(Date.UTC(2026, 9, 16, 0, 0, 0) + i * 60_000).toISOString() })));
81
+ const r = listed({ limit: 100_000 });
82
+ expect(r.changes).toHaveLength(200);
83
+ expect(r).toMatchObject({ truncated: true, omitted: 5 });
84
+ expect(r.changes[0].subject).toBe('fix: n204');
85
+ });
86
+ const files = (n, from = 0) => Object.fromEntries(Array.from({ length: n }, (_, i) => {
87
+ const id = `2026-10-16-r${String(from + i).padStart(3, '0')}`;
88
+ return [(0, list_fixtures_1.recordPath)(id), (0, list_fixtures_1.record)(id)];
89
+ }));
90
+ test('a commit adding more records than the per-commit bound is not read: one too-many-records diagnostic stands for it', () => {
91
+ adopt();
92
+ repo.commit(files(list_1.MAX_RECORDS_PER_ENTRY + 1), 'feat: many', '2026-10-16T20:00:00Z');
93
+ const spy = (0, helpers_1.spyOnGit)();
94
+ const entry = listed().changes[0];
95
+ spy.restore();
96
+ expect(entry.classification).toBe('invalid-record');
97
+ expect(entry.records).toEqual([{ path: 'docs/changes', diagnostics: [expect.objectContaining({ code: 'too-many-records' })] }]);
98
+ expect(spy.calls.filter((a) => a.includes('ls-tree') || a.includes('show'))).toEqual([]);
99
+ });
100
+ test('a commit adding exactly the per-commit bound of valid records is recorded and all are read', () => {
101
+ adopt();
102
+ repo.commit(files(list_1.MAX_RECORDS_PER_ENTRY), 'feat: many', '2026-10-16T20:00:00Z');
103
+ const entry = listed().changes[0];
104
+ expect(entry.classification).toBe('recorded');
105
+ expect(entry.records).toHaveLength(list_1.MAX_RECORDS_PER_ENTRY);
106
+ expect(entry.records.every((r) => 'schema' in r)).toBe(true);
107
+ });
108
+ test('the per-commit bound can be lowered, and the commit is then not read at all', () => {
109
+ adopt();
110
+ repo.commit(files(2), 'feat: two', '2026-10-16T20:00:00Z');
111
+ const entry = listed({ maxRecordsPerEntry: 1 }).changes[0];
112
+ expect(entry.classification).toBe('invalid-record');
113
+ expect(entry.records).toEqual([{ path: 'docs/changes', diagnostics: [expect.objectContaining({ code: 'too-many-records' })] }]);
114
+ expect(listed({ maxRecordsPerEntry: 2 }).changes[0].classification).toBe('recorded');
115
+ });
116
+ test('the per-run budget fails closed on the commits that come after it is spent', () => {
117
+ adopt();
118
+ for (let i = 1; i <= 3; i++)
119
+ repo.commit({ [(0, list_fixtures_1.recordPath)(`2026-10-16-r${i}`)]: (0, list_fixtures_1.record)(`2026-10-16-r${i}`) }, `feat: r${i}`, `2026-10-16T0${i}:00:00Z`);
120
+ const r = listed({ maxRecords: 2 });
121
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'feat: r3': 'recorded', 'feat: r2': 'recorded', 'feat: r1': 'invalid-record' });
122
+ expect(r.changes[2].records).toEqual([{ path: 'docs/changes', diagnostics: [expect.objectContaining({ code: 'too-many-records' })] }]);
123
+ });
124
+ test('the per-run budget is spent on records actually read: a commit refused for being too large does not use it up', () => {
125
+ adopt();
126
+ repo.commit(files(1, 100), 'feat: small', '2026-10-16T01:00:00Z');
127
+ repo.commit(files(3, 200), 'feat: big', '2026-10-16T02:00:00Z');
128
+ repo.commit(files(2, 300), 'feat: medium', '2026-10-16T03:00:00Z');
129
+ const r = listed({ maxRecords: 3 });
130
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'feat: medium': 'recorded', 'feat: big': 'invalid-record', 'feat: small': 'recorded' });
131
+ expect(r.changes.find((c) => c.subject === 'feat: big')?.records).toHaveLength(1);
132
+ });
133
+ test('a budget of exactly the records of the run reads them all', () => {
134
+ adopt();
135
+ repo.commit(files(2, 100), 'feat: a', '2026-10-16T01:00:00Z');
136
+ repo.commit(files(1, 200), 'feat: b', '2026-10-16T02:00:00Z');
137
+ expect(Object.values((0, list_fixtures_1.byClass)(listed({ maxRecords: 3 })))).toEqual(['recorded', 'recorded']);
138
+ expect(Object.values((0, list_fixtures_1.byClass)(listed({ maxRecords: 2 })))).toEqual(['recorded', 'invalid-record']);
139
+ });
140
+ });
141
+ describe('listChanges: subjects and tags beyond their caps are clipped, never an error (R5.5, R5.7)', () => {
142
+ const bigMessage = (first) => { const file = path_1.default.join(repo.home, 'message.txt'); fs_1.default.writeFileSync(file, `${first}\n`); return file; };
143
+ test('a 17 MB subject is clipped to the cap and the run succeeds', () => {
144
+ adopt();
145
+ repo.gitAt('2026-10-16T10:00:00Z', 'commit', '--allow-empty', '-q', '-F', bigMessage(`feat: ${'x'.repeat(17_000_000)}`));
146
+ repo.commit({ 'b.ts': '1' }, 'fix: next', '2026-10-16T11:00:00Z');
147
+ const r = listed();
148
+ expect((0, list_fixtures_1.subjects)(r)[0]).toBe('fix: next');
149
+ expect(r.changes[1].subject.length).toBe(list_1.MAX_SUBJECT);
150
+ expect(r.changes[1].subject.startsWith('feat: xxxx')).toBe(true);
151
+ expect(r.changes[1].subject.endsWith('…')).toBe(true);
152
+ }, 120_000);
153
+ test('the subject is asked from git bounded, so that git never prints all of it', () => {
154
+ adopt();
155
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
156
+ const spy = (0, helpers_1.spyOnGit)();
157
+ listed();
158
+ spy.restore();
159
+ const call = spy.calls.find((a) => a.some((x) => x.startsWith('--format=%H%x1f%<(')));
160
+ expect(call).toBeDefined();
161
+ expect(call?.some((x) => /%<\(\d+,trunc\)%s/.test(x))).toBe(true);
162
+ });
163
+ test('a short subject is returned without the padding git adds', () => {
164
+ adopt();
165
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
166
+ expect((0, list_fixtures_1.subjects)(listed())).toEqual(['fix: a']);
167
+ });
168
+ test('R5.5 a commit whose subject cannot be read still lists: its pr is null (the tail fallback), never a failure', () => {
169
+ adopt();
170
+ const file = path_1.default.join(repo.home, 'message.txt');
171
+ fs_1.default.writeFileSync(file, `fix: ${'\u0301'.repeat(9_000_000)} (#12)\n`);
172
+ repo.gitAt('2026-10-16T10:00:00Z', 'commit', '--allow-empty', '-q', '-F', file);
173
+ const r = listed();
174
+ expect(r.changes).toHaveLength(1);
175
+ expect(r.changes[0]).toMatchObject({ subject: '<subject unavailable>', pr: null });
176
+ });
177
+ test('one bad subject among many is found by halving the batch: a logarithmic number of calls, not one per commit', () => {
178
+ adopt();
179
+ for (let i = 1; i <= 7; i++)
180
+ repo.commit({ [`f${i}.ts`]: '1' }, `fix: n${i}`, '2026-10-16T10:00:00Z');
181
+ repo.gitAt('2026-10-16T10:00:00Z', 'commit', '--allow-empty', '-q', '-F', bigMessage(`fix: ${'\u0301'.repeat(9_000_000)}`));
182
+ for (let i = 8; i <= 15; i++)
183
+ repo.commit({ [`f${i}.ts`]: '1' }, `fix: n${i}`, '2026-10-16T11:00:00Z');
184
+ const spy = (0, helpers_1.spyOnGit)();
185
+ const r = listed();
186
+ spy.restore();
187
+ const asked = (needle) => spy.calls.filter((a) => a.some((x) => x.startsWith('--format=%H%x1f%<(') && x.includes(needle))).length;
188
+ expect((0, list_fixtures_1.subjects)(r).filter((s) => s === '<subject unavailable>')).toHaveLength(1);
189
+ expect((0, list_fixtures_1.subjects)(r).filter((s) => /^fix: n\d+$/.test(s))).toHaveLength(15);
190
+ // 16 commits: the whole batch, then one failing half and one good half per level (4 levels); asking each one took 17.
191
+ expect(asked(',trunc)')).toBeLessThanOrEqual(9);
192
+ expect(asked(',ltrunc)')).toBeLessThanOrEqual(9);
193
+ expect(asked(',trunc)')).toBeGreaterThan(1);
194
+ }, 120_000);
195
+ (0, helpers_1.posixOnly)('a tag name longer than 255 characters is clipped, never git-failed', () => {
196
+ adopt();
197
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
198
+ repo.git('tag', `${'a'.repeat(120)}/${'b'.repeat(200)}`);
199
+ const release = listed().changes[0].release;
200
+ expect(release?.length).toBe(255);
201
+ expect(release?.startsWith('aaaa')).toBe(true);
202
+ expect(release?.endsWith('…')).toBe(true);
203
+ });
204
+ });
205
+ describe('listChanges: the first-parent scan is read in pages (R5.7)', () => {
206
+ const isScan = (a) => a.includes('log') && a.some((x) => x === '--format=%H%x1f%ct%x1f%P');
207
+ const isAdoptionPage = (a) => a.includes('log') && a.some((x) => x.startsWith(':(top,glob)docs/changes/'));
208
+ /** `n` commits in the window, `fix: n1` oldest. */
209
+ const commits = (n, from = 1) => { for (let i = from; i < from + n; i++)
210
+ repo.commit({ [`f${i}.ts`]: '1' }, `fix: n${i}`, '2026-10-16T10:00:00Z'); };
211
+ const countCalls = (pred, opts) => { const spy = (0, helpers_1.spyOnGit)(); listed(opts); spy.restore(); return spy.calls.filter(pred).length; };
212
+ test('the real page size is 10 000 commits and is what a default run asks git for', () => {
213
+ expect(list_1.PAGE_SIZE).toBe(10_000);
214
+ adopt();
215
+ const spy = (0, helpers_1.spyOnGit)();
216
+ listed();
217
+ spy.restore();
218
+ expect(spy.calls.filter(isScan).every((a) => a.includes('--max-count=10000'))).toBe(true);
219
+ expect(spy.calls.filter(isAdoptionPage).every((a) => a.includes('--max-count=10000'))).toBe(true);
220
+ });
221
+ test('a page size above the real one is capped to it', () => {
222
+ adopt();
223
+ const spy = (0, helpers_1.spyOnGit)();
224
+ listed({ pageSize: 1_000_000 });
225
+ spy.restore();
226
+ expect(spy.calls.filter(isScan).every((a) => a.includes('--max-count=10000'))).toBe(true);
227
+ });
228
+ test.each([0, -1, 1.5, Number.NaN, '3', null])('the page size %j is invalid-input', (pageSize) => {
229
+ expect(list({ pageSize })).toMatchObject({ ok: false, failure: { code: 'invalid-input' } });
230
+ });
231
+ test('entries across page boundaries are listed exactly as with one page', () => {
232
+ adopt();
233
+ commits(8);
234
+ const paged = listed({ pageSize: 3 });
235
+ expect(paged.changes).toHaveLength(8);
236
+ expect(paged).toEqual(listed());
237
+ expect((0, list_fixtures_1.subjects)(paged)[0]).toBe('fix: n8');
238
+ expect((0, list_fixtures_1.subjects)(paged)[7]).toBe('fix: n1');
239
+ });
240
+ test('a page size of 1 gives the same answer', () => {
241
+ adopt();
242
+ commits(4);
243
+ expect(listed({ pageSize: 1 })).toEqual(listed());
244
+ });
245
+ test('the scan stops at the adoption point: no page is read beyond the one holding it', () => {
246
+ repo.commit({ 'old.ts': '1' }, 'feat: before', '2026-10-16T09:00:00Z');
247
+ repo.commit({ 'old2.ts': '1' }, 'feat: before too', '2026-10-16T09:30:00Z');
248
+ adopt('2026-10-16-first');
249
+ commits(5);
250
+ const r = listed({ pageSize: 2 });
251
+ expect((0, list_fixtures_1.subjects)(r)).toEqual(['fix: n5', 'fix: n4', 'fix: n3', 'fix: n2', 'fix: n1']);
252
+ expect(countCalls(isScan, { pageSize: 2 })).toBe(3);
253
+ });
254
+ test('the scan stops at the first page without a commit in the window', () => {
255
+ adopt();
256
+ for (let i = 1; i <= 6; i++)
257
+ repo.commit({ [`o${i}.ts`]: '1' }, `fix: old${i}`, '2026-10-01T10:00:00Z');
258
+ commits(2);
259
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 2 }))).toEqual(['fix: n2', 'fix: n1']);
260
+ expect(countCalls(isScan, { pageSize: 2 })).toBe(2);
261
+ });
262
+ test('a window with no commit in it reads one page only', () => {
263
+ adopt();
264
+ for (let i = 1; i <= 6; i++)
265
+ repo.commit({ [`o${i}.ts`]: '1' }, `fix: old${i}`, '2026-10-01T10:00:00Z');
266
+ expect(listed({ pageSize: 2 })).toMatchObject({ adopted: true, changes: [], truncated: false, omitted: 0 });
267
+ expect(countCalls(isScan, { pageSize: 2 })).toBe(1);
268
+ });
269
+ test('a page that holds some commits of the window and some older ones is followed by one more page', () => {
270
+ adopt();
271
+ for (let i = 1; i <= 4; i++)
272
+ repo.commit({ [`o${i}.ts`]: '1' }, `fix: old${i}`, '2026-10-01T10:00:00Z');
273
+ commits(1);
274
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 2 }))).toEqual(['fix: n1']);
275
+ expect(countCalls(isScan, { pageSize: 2 })).toBe(2);
276
+ });
277
+ test('a commit dated inside the window beyond a page with none is not listed (dates are assumed not to decrease)', () => {
278
+ adopt();
279
+ commits(1);
280
+ for (let i = 1; i <= 3; i++)
281
+ repo.commit({ [`o${i}.ts`]: '1' }, `fix: old${i}`, '2026-10-01T10:00:00Z');
282
+ commits(1, 2);
283
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 2 }))).toEqual(['fix: n2']);
284
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 10 }))).toEqual(['fix: n2', 'fix: n1']);
285
+ });
286
+ test('an unusable date counts as inside the window when it decides whether to read on', () => {
287
+ adopt();
288
+ for (let i = 1; i <= 4; i++)
289
+ repo.commit({ [`o${i}.ts`]: '1' }, `fix: old${i}`, '2026-10-01T10:00:00Z');
290
+ (0, list_fixtures_1.commitLiterally)(repo, '');
291
+ repo.commit({ 'o5.ts': '1' }, 'fix: old5', '2026-10-01T10:00:00Z');
292
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 2 }))).toEqual(['fix: odd date']);
293
+ expect(countCalls(isScan, { pageSize: 2 })).toBe(2);
294
+ });
295
+ test('the omitted entries are counted over every page', () => {
296
+ adopt();
297
+ commits(8);
298
+ expect(listed({ pageSize: 3, limit: 3 })).toMatchObject({ truncated: true, omitted: 5 });
299
+ expect(listed({ pageSize: 3, limit: 3 })).toEqual(listed({ limit: 3 }));
300
+ });
301
+ test('merge commits count as one commit of the first-parent chain, across pages', () => {
302
+ adopt();
303
+ repo.git('checkout', '-q', '-b', 'side1');
304
+ repo.commit({ [(0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST)]: (0, list_fixtures_1.record)(list_fixtures_1.FIRST) }, 'feat: side1', '2026-10-16T08:00:00Z');
305
+ repo.git('checkout', '-q', 'main');
306
+ repo.gitAt('2026-10-16T09:00:00Z', 'merge', '--no-ff', '-q', 'side1', '-m', 'Merge side1');
307
+ repo.git('checkout', '-q', '-b', 'side2');
308
+ repo.commit({ 's2.ts': '1' }, 'chore: side2\n\nChange-Record: none', '2026-10-16T10:00:00Z');
309
+ repo.git('checkout', '-q', 'main');
310
+ repo.gitAt('2026-10-16T11:00:00Z', 'merge', '--no-ff', '-q', 'side2', '-m', 'Merge side2');
311
+ commits(1);
312
+ const paged = listed({ pageSize: 2 });
313
+ expect((0, list_fixtures_1.byClass)(paged)).toEqual({ 'fix: n1': 'missing', 'Merge side2': 'exempt', 'Merge side1': 'recorded' });
314
+ expect(paged).toEqual(listed());
315
+ });
316
+ test('the adoption search is paged too: the oldest commit that touched docs/changes is found across pages', () => {
317
+ repo.commit({ 'old.ts': '1' }, 'feat: before', '2026-10-16T09:00:00Z');
318
+ for (let i = 1; i <= 7; i++)
319
+ repo.commit({ [`docs/changes/2026-10-16-n${i}.md`]: '1' }, `docs: note ${i}`, '2026-10-16T10:00:00Z');
320
+ const paged = listed({ pageSize: 3 });
321
+ expect((0, list_fixtures_1.subjects)(paged)).toEqual(Array.from({ length: 7 }, (_, i) => `docs: note ${7 - i}`));
322
+ expect(paged).toEqual(listed());
323
+ expect(countCalls(isAdoptionPage, { pageSize: 3 })).toBe(3);
324
+ });
325
+ test('the adoption search reads one more, empty, page when the last one was full', () => {
326
+ for (let i = 1; i <= 6; i++)
327
+ repo.commit({ [`docs/changes/2026-10-16-n${i}.md`]: '1' }, `docs: note ${i}`, '2026-10-16T10:00:00Z');
328
+ expect((0, list_fixtures_1.subjects)(listed({ pageSize: 3 }))).toHaveLength(6);
329
+ expect(countCalls(isAdoptionPage, { pageSize: 3 })).toBe(3);
330
+ });
331
+ test('the adoption search skips commits that did not touch docs/changes between two pages', () => {
332
+ for (let i = 1; i <= 3; i++) {
333
+ repo.commit({ [`docs/changes/2026-10-16-n${i}.md`]: '1' }, `docs: note ${i}`, '2026-10-16T10:00:00Z');
334
+ commits(2, i * 10);
335
+ }
336
+ const r = listed({ pageSize: 2 });
337
+ expect(r.changes.map((c) => c.subject).filter((x) => x.startsWith('docs:'))).toEqual(['docs: note 3', 'docs: note 2', 'docs: note 1']);
338
+ expect(r).toEqual(listed());
339
+ });
340
+ test('a root commit that adopts ends the paging', () => {
341
+ repo.git('checkout', '-q', '--orphan', 'orph');
342
+ repo.git('rm', '-rfq', '.');
343
+ 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');
344
+ repo.commit({ 'z.ts': '1' }, 'fix: after root', '2026-10-16T21:00:00Z');
345
+ expect(listed({ base: 'orph', pageSize: 1 })).toEqual(listed({ base: 'orph' }));
346
+ expect((0, list_fixtures_1.subjects)(listed({ base: 'orph', pageSize: 1 }))).toEqual(['fix: after root', 'feat: root record']);
347
+ });
348
+ test('a git failure on a later page is a git-failed failure, not a partial list', () => {
349
+ adopt();
350
+ commits(6);
351
+ let seen = 0;
352
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (isScan(args) && ++seen === 2)
353
+ throw new Error('boom'); });
354
+ const outcome = list({ pageSize: 2 });
355
+ spy.restore();
356
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
357
+ });
358
+ test('a git failure on a later page of the adoption search is a git-failed failure', () => {
359
+ for (let i = 1; i <= 6; i++)
360
+ repo.commit({ [`docs/changes/2026-10-16-n${i}.md`]: '1' }, `docs: note ${i}`, '2026-10-16T10:00:00Z');
361
+ let seen = 0;
362
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (isAdoptionPage(args) && ++seen === 2)
363
+ throw new Error('boom'); });
364
+ const outcome = list({ pageSize: 2 });
365
+ spy.restore();
366
+ expect(outcome).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
367
+ });
368
+ });
369
+ describe('listChanges: the pr comes from the true end of the full subject (R5.5)', () => {
370
+ const prOf = (subject) => {
371
+ adopt();
372
+ repo.commit({ 'a.ts': '1' }, subject, '2026-10-16T10:00:00Z');
373
+ return listed().changes[0].pr;
374
+ };
375
+ test.each([
376
+ ['5000 characters long', `fix: ${'a'.repeat(5000)} (#12)`, 12],
377
+ ['1030 characters long (just beyond the subject cap)', `fix: ${'a'.repeat(1030 - 12)} (#12)`, 12],
378
+ ['exactly the width of the tail', `${'x'.repeat(64 - 6)} (#12)`, 12],
379
+ ['one column beyond the width of the tail', `${'x'.repeat(65 - 6)} (#12)`, 12],
380
+ ['one column short of the width of the tail', `${'x'.repeat(63 - 6)} (#12)`, 12],
381
+ ['shorter than the tail', 'fix: x (#3)', 3],
382
+ ['with (#12) glued to a longer token at the end', `fix: ${'a'.repeat(300)}tok(#12)`, 12],
383
+ ['with (#12) followed by more text', `fix: ${'a'.repeat(300)} (#12)x`, null],
384
+ ['with (#12) early and a long tail after it', `fix: (#12) ${'a'.repeat(5000)}`, null],
385
+ ['with a pr number longer than the tail would keep', `fix: ${'a'.repeat(300)} (#${'9'.repeat(60)})`, null],
386
+ ['with the largest safe number at the end', `fix: ${'a'.repeat(300)} (#9007199254740991)`, 9007199254740991],
387
+ ['with wide characters at the cut', `${'\u6f22'.repeat(100)} (#12)`, 12],
388
+ ['with accented characters at the cut', `${'\u00e9'.repeat(100)} (#12)`, 12],
389
+ ['with astral characters at the cut', `${'\u{1F600}'.repeat(100)} (#12)`, 12],
390
+ ['with two (#n) at the end', `fix: ${'a'.repeat(300)} (#1) (#2)`, 2],
391
+ ])('a subject %s', (_label, subject, pr) => {
392
+ expect(prOf(subject)).toBe(pr);
393
+ });
394
+ test('the end of the subject is asked from git bounded to the tail width', () => {
395
+ adopt();
396
+ repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
397
+ const spy = (0, helpers_1.spyOnGit)();
398
+ listed();
399
+ spy.restore();
400
+ expect(spy.calls.some((a) => a.includes('--format=%H%x1f%<(64,ltrunc)%s'))).toBe(true);
401
+ });
402
+ test('the subject itself is still clipped while the pr is read from its end', () => {
403
+ adopt();
404
+ repo.commit({ 'a.ts': '1' }, `fix: ${'a'.repeat(5000)} (#12)`, '2026-10-16T10:00:00Z');
405
+ const entry = listed().changes[0];
406
+ expect(entry.subject.length).toBe(list_1.MAX_SUBJECT);
407
+ expect(entry.pr).toBe(12);
408
+ });
409
+ test('the tail never leaks into the subject', () => {
410
+ adopt();
411
+ repo.commit({ 'a.ts': '1' }, 'fix: short (#4)', '2026-10-16T10:00:00Z');
412
+ expect(listed().changes[0].subject).toBe('fix: short (#4)');
413
+ });
414
+ });
415
+ describe('listChanges: read-only and bounded (R5.7)', () => {
416
+ // The whole tree is hashed, .git/objects included: reading must not add an object, a pack or a lock either.
417
+ const snapshot = () => `${(0, list_fixtures_1.snapshotDir)(repo.root)}\n${repo.git('--no-optional-locks', 'status', '--porcelain', '--ignored')}\n${repo.git('for-each-ref')}`;
418
+ test('writes nothing: files, index, refs and ignored files are byte-identical afterwards', () => {
419
+ history();
420
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, 'untracked.txt'), 'u');
421
+ fs_1.default.mkdirSync(path_1.default.join(repo.root, '.awm'), { recursive: true });
422
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, '.awm', 'ignored.txt'), 'i');
423
+ const before = snapshot();
424
+ listed();
425
+ listed({ limit: 2 });
426
+ expect(snapshot()).toBe(before);
427
+ });
428
+ // GIT_NO_LAZY_FETCH is honoured from git 2.43 on; an older git would fetch the blobs, so the test is reported as skipped there.
429
+ const needsGit243 = (0, helpers_1.gitAtLeast)(2, 43) ? test : test.skip;
430
+ needsGit243('a partial clone fails closed instead of fetching the blobs it lacks: nothing is written and nothing is fetched (needs git 2.43+)', () => {
431
+ adopt();
432
+ 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');
433
+ repo.commit({ 'b.ts': '1' }, 'fix: next', '2026-10-16T11:00:00Z');
434
+ repo.git('config', 'uploadpack.allowFilter', 'true');
435
+ repo.git('config', 'uploadpack.allowAnySHA1InWant', 'true');
436
+ const dest = path_1.default.join(repo.home, 'partial');
437
+ repo.git('clone', '-q', '--filter=blob:none', '--no-checkout', (0, url_1.pathToFileURL)(repo.root).href, dest);
438
+ const missing = () => repo.git('-C', dest, 'rev-list', '--objects', '--all', '--missing=print').split('\n').filter((l) => l.startsWith('?')).length;
439
+ const packs = () => fs_1.default.readdirSync(path_1.default.join(dest, '.git', 'objects', 'pack')).sort();
440
+ const before = { tree: (0, list_fixtures_1.snapshotDir)(dest), missing: missing(), packs: packs() };
441
+ expect(before.missing).toBeGreaterThan(0);
442
+ const r = (0, list_fixtures_1.ok)((0, list_1.listChanges)(dest, { since: list_fixtures_1.SINCE_48H }));
443
+ expect({ tree: (0, list_fixtures_1.snapshotDir)(dest), missing: missing(), packs: packs() }).toEqual(before);
444
+ expect((0, list_fixtures_1.byClass)(r)).toEqual({ 'fix: next': 'missing', 'feat: first': 'invalid-record' });
445
+ expect(r.changes[1].records).toEqual([{ path: (0, list_fixtures_1.recordPath)(list_fixtures_1.FIRST), diagnostics: [expect.objectContaining({ code: 'git-failed' })] }]);
446
+ });
447
+ test('every git call is read-only', () => {
448
+ history();
449
+ const spy = (0, helpers_1.spyOnGit)();
450
+ listed();
451
+ spy.restore();
452
+ const subcommands = new Set(spy.calls.map((a) => a.find((x) => !x.startsWith('-') && x !== 'diff.relative=false') ?? ''));
453
+ expect([...subcommands].sort()).toEqual(expect.arrayContaining(['log']));
454
+ for (const name of subcommands)
455
+ expect(['rev-parse', 'log', 'diff', 'ls-tree', 'show', 'for-each-ref', 'config']).toContain(name);
456
+ });
457
+ test('every git log carries the safety flags', () => {
458
+ history();
459
+ const spy = (0, helpers_1.spyOnGit)();
460
+ listed();
461
+ spy.restore();
462
+ const logs = spy.calls.filter((a) => a.includes('log'));
463
+ expect(logs.length).toBeGreaterThanOrEqual(3);
464
+ for (const call of logs) {
465
+ expect(call).toContain('--no-show-signature');
466
+ expect(call).toContain('--encoding=UTF-8');
467
+ expect(call).toContain('--end-of-options');
468
+ }
469
+ });
470
+ test('pathspecs are literal and follow a --', () => {
471
+ history();
472
+ const spy = (0, helpers_1.spyOnGit)();
473
+ listed();
474
+ spy.restore();
475
+ const withPath = spy.calls.filter((a) => a.some((x) => x.includes('docs/changes')));
476
+ expect(withPath.length).toBeGreaterThan(0);
477
+ for (const call of withPath) {
478
+ const dash = call.indexOf('--');
479
+ expect(dash).toBeGreaterThan(-1);
480
+ expect(call.slice(dash + 1).every((x) => x.startsWith(':(top,literal)') || x.startsWith(':(top,glob)docs/changes/') || /^docs\/changes\/[a-z0-9-]+\.md$/.test(x))).toBe(true);
481
+ }
482
+ });
483
+ test('the history is read along the first parent only, in pages', () => {
484
+ history();
485
+ const spy = (0, helpers_1.spyOnGit)();
486
+ listed();
487
+ spy.restore();
488
+ const scans = spy.calls.filter((a) => a.includes('log') && a.some((x) => x.startsWith('--max-count=')));
489
+ expect(scans.length).toBeGreaterThanOrEqual(2);
490
+ for (const call of scans) {
491
+ expect(call).toContain('--first-parent');
492
+ expect(call).toContain(`--max-count=${list_1.PAGE_SIZE}`);
493
+ }
494
+ });
495
+ test('git calls per entry are constant: recorded entries cost the same however long the history', () => {
496
+ adopt();
497
+ const add = (from, to) => {
498
+ for (let i = from; i <= to; i++) {
499
+ const id = `2026-10-16-r${String(i).padStart(3, '0')}`;
500
+ repo.commit({ [(0, list_fixtures_1.recordPath)(id)]: (0, list_fixtures_1.record)(id) }, `feat: r${i}`, '2026-10-16T10:00:00Z');
501
+ }
502
+ };
503
+ const count = () => { const spy = (0, helpers_1.spyOnGit)(); const r = listed(); spy.restore(); expect(r.changes.length).toBeGreaterThan(0); return spy.calls.length; };
504
+ add(1, 5);
505
+ const small = count();
506
+ add(6, 25);
507
+ const large = count();
508
+ expect((large - small) / 20).toBeLessThanOrEqual(5);
509
+ expect(large - small).toBeGreaterThan(0);
510
+ });
511
+ });
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const helpers_1 = require("./helpers");
4
+ const list_fixtures_1 = require("./list-fixtures");
5
+ const list_parse_1 = require("../../../src/core/changes/list-parse");
6
+ // Repo-free tests of the pure parsing of `awm changes list`: nothing here starts git.
7
+ describe('parseSince (R5.2)', () => {
8
+ test.each([
9
+ ['24h', '2026-10-16T12:00:00.000Z'],
10
+ ['7d', '2026-10-10T12:00:00.000Z'],
11
+ ['1h', '2026-10-17T11:00:00.000Z'],
12
+ ['3650d', '2016-10-19T12:00:00.000Z'],
13
+ ['87600h', '2016-10-19T12:00:00.000Z'],
14
+ ['3651d', '2016-10-18T12:00:00.000Z'],
15
+ ['87601h', '2016-10-19T11:00:00.000Z'],
16
+ ['99999d', '1753-01-02T12:00:00.000Z'],
17
+ ['12345h', '2025-05-21T03:00:00.000Z'],
18
+ ['2026-10-01', '2026-10-01T00:00:00.000Z'],
19
+ ['2026-02-28', '2026-02-28T00:00:00.000Z'],
20
+ ['2028-02-29', '2028-02-29T00:00:00.000Z'],
21
+ ])('accepts %s', (value, iso) => {
22
+ expect((0, list_parse_1.parseSince)(value, list_fixtures_1.NOW)?.toISOString()).toBe(iso);
23
+ });
24
+ test.each(['', '0h', '0d', '00d', '-1h', '+1h', '1.5h', '24', 'h', '24x', '24H', '24 h', ' 24h', '24h ', '24h\n', '1234567890h', '1234567890d', '0000000000h', '999999999d',
25
+ '2026-02-30', '2026-13-01', '2026-00-10', '2026-1-1', '2026-10-1', '2026-10-01T00:00:00Z', '2026-10-01 ', 'soon', 'now', '\u0661\u0662h', '1e3h', '0x10h',
26
+ ])('rejects %j', (value) => {
27
+ expect((0, list_parse_1.parseSince)(value, list_fixtures_1.NOW)).toBeNull();
28
+ });
29
+ test('a count of 9 digits is valid while the resulting instant is a valid date', () => {
30
+ expect((0, list_parse_1.parseSince)('999999999h', list_fixtures_1.NOW)).toBeInstanceOf(Date);
31
+ expect((0, list_parse_1.parseSince)('100000000d', list_fixtures_1.NOW)).toBeInstanceOf(Date);
32
+ expect((0, list_parse_1.parseSince)('999999999d', list_fixtures_1.NOW)).toBeNull();
33
+ });
34
+ test('rejects a very long text without scanning it more than once', () => {
35
+ expect((0, list_parse_1.parseSince)(`${'9'.repeat(1_000_000)}h`, list_fixtures_1.NOW)).toBeNull();
36
+ expect((0, list_parse_1.parseSince)('x'.repeat(1_000_000), list_fixtures_1.NOW)).toBeNull();
37
+ (0, helpers_1.expectLinearScaling)((n) => { (0, list_parse_1.parseSince)(`${'9'.repeat(n)}h`, list_fixtures_1.NOW); (0, list_parse_1.parseSince)('x'.repeat(n), list_fixtures_1.NOW); }, 100_000);
38
+ });
39
+ test.each([undefined, null, 24, {}, [], true])('rejects the non-string %j without throwing', (value) => {
40
+ expect((0, list_parse_1.parseSince)((0, helpers_1.bad)(value), list_fixtures_1.NOW)).toBeNull();
41
+ });
42
+ test.each([undefined, null, 'x', 5, new Date(Number.NaN), {}])('rejects the unusable clock %j', (clock) => {
43
+ expect((0, list_parse_1.parseSince)('24h', (0, helpers_1.bad)(clock))).toBeNull();
44
+ });
45
+ });
46
+ describe('parsePr (R5.5)', () => {
47
+ test.each([
48
+ ['feat: x', null],
49
+ ['feat: x (#12) more', null],
50
+ ['feat: (#12) x', null],
51
+ ['feat: x (#)', null],
52
+ ['feat: x (#1a)', null],
53
+ ['feat: x (# 12)', null],
54
+ ['feat: x (#-5)', null],
55
+ ['feat: x #12', null],
56
+ ['feat: x (#12', null],
57
+ ['feat: x (#99999999999999999999)', null],
58
+ ['feat: x (#9007199254740993)', null],
59
+ ['feat: x (#9007199254740991)', 9007199254740991],
60
+ ['feat: x (#123)', 123],
61
+ ['feat: x (#007)', 7],
62
+ ['feat: x (#12) ', 12],
63
+ ['feat: x (#12)\t\r\n', 12],
64
+ ['feat: x (#1) (#2)', 2],
65
+ ['(#3)', 3],
66
+ ['(#)', null],
67
+ ['', null],
68
+ ])('the PR of %j is %j', (subject, pr) => {
69
+ expect((0, list_parse_1.parsePr)(subject)).toBe(pr);
70
+ });
71
+ test.each([undefined, null, 5, {}, []])('the non-string %j is null, not a throw', (value) => {
72
+ expect((0, list_parse_1.parsePr)((0, helpers_1.bad)(value))).toBeNull();
73
+ });
74
+ test('a very long run of digits or of blanks after (# is parsed in linear time', () => {
75
+ expect((0, list_parse_1.parsePr)(`feat: x (#${'9'.repeat(1_000_000)}`)).toBeNull();
76
+ expect((0, list_parse_1.parsePr)(`feat: y (#${' '.repeat(1_000_000)}1)`)).toBeNull();
77
+ expect((0, list_parse_1.parsePr)(`feat: z ${'(#'.repeat(500_000)})`)).toBeNull();
78
+ expect((0, list_parse_1.parsePr)(`${' '.repeat(1_000_000)}`)).toBeNull();
79
+ (0, helpers_1.expectLinearScaling)((n) => {
80
+ (0, list_parse_1.parsePr)(`feat: x (#${'9'.repeat(n)}`);
81
+ (0, list_parse_1.parsePr)(`feat: y (#${' '.repeat(n)}1)`);
82
+ (0, list_parse_1.parsePr)(`feat: z ${'(#'.repeat(n / 2)})`);
83
+ (0, list_parse_1.parsePr)(`${' '.repeat(n)}`);
84
+ }, 100_000);
85
+ });
86
+ });
87
+ describe('cleanLine', () => {
88
+ test.each([undefined, null, 5, {}])('the non-string %j is a placeholder, not a throw', (value) => {
89
+ expect((0, list_parse_1.cleanLine)((0, helpers_1.bad)(value), 10)).toBe('<non-string>');
90
+ });
91
+ test('a clean line within the limit is returned as it is', () => {
92
+ expect((0, list_parse_1.cleanLine)('fix: a', 10)).toBe('fix: a');
93
+ expect((0, list_parse_1.cleanLine)('1234567890', 10)).toBe('1234567890');
94
+ });
95
+ test('a line one unit over the limit is cut and ends with an ellipsis, within the limit', () => {
96
+ expect((0, list_parse_1.cleanLine)('12345678901', 10)).toBe('123456789\u2026');
97
+ });
98
+ test('control characters and line breaks are escaped', () => {
99
+ const out = (0, list_parse_1.cleanLine)('a\u001b[31m\nb\u2028c\u0085d\u007fe', 100);
100
+ expect(out).toBe('a\\u001b[31m\\u000ab\\u2028c\\u0085d\\u007fe');
101
+ expect(out).not.toMatch(list_fixtures_1.UNSAFE);
102
+ });
103
+ test('bidi marks are escaped', () => {
104
+ expect((0, list_parse_1.cleanLine)('a\u202eb\u2066c', 100)).toBe('a\\u202eb\\u2066c');
105
+ });
106
+ test('the escapes count against the limit: the result never exceeds it', () => {
107
+ const out = (0, list_parse_1.cleanLine)('\u001b'.repeat(50), 20);
108
+ expect(out.length).toBe(20);
109
+ expect(out.endsWith('\u2026')).toBe(true);
110
+ });
111
+ test('a surrogate pair is never split by the cut', () => {
112
+ const out = (0, list_parse_1.cleanLine)(`${'x'.repeat(8)}\u{1F600}\u{1F600}`, 9);
113
+ expect(Buffer.from(out, 'utf8').toString('utf8')).toBe(out);
114
+ expect(out.length).toBeLessThanOrEqual(9);
115
+ });
116
+ test('a very long text is cut in linear time', () => {
117
+ expect((0, list_parse_1.cleanLine)('\u001b'.repeat(5_000_000), 1024).length).toBe(1024);
118
+ (0, helpers_1.expectLinearScaling)((n) => { (0, list_parse_1.cleanLine)('\u001b'.repeat(n), 1024); (0, list_parse_1.cleanLine)('a\u202e'.repeat(n / 2), 1024); }, 200_000);
119
+ });
120
+ });