agentic-workflow-manager 9.19.0 → 9.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/run.js +1 -0
- package/dist/src/core/changes/command.js +38 -0
- package/dist/src/core/changes/derive.js +120 -0
- package/dist/src/core/changes/fs-safe.js +47 -0
- package/dist/src/core/changes/git-env.js +68 -0
- package/dist/src/core/changes/git.js +216 -0
- package/dist/src/core/changes/list-parse.js +67 -0
- package/dist/src/core/changes/list.js +408 -0
- package/dist/src/core/changes/parse.js +72 -0
- package/dist/src/core/changes/range.js +53 -0
- package/dist/src/core/changes/receipt.js +274 -0
- package/dist/src/core/changes/record-io.js +149 -0
- package/dist/src/core/changes/render-list.js +109 -0
- package/dist/src/core/changes/render.js +178 -0
- package/dist/src/core/changes/text-safety.js +116 -0
- package/dist/src/core/changes/trailers.js +76 -0
- package/dist/src/core/changes/types.js +26 -0
- package/dist/src/core/changes/validate-shape.js +219 -0
- package/dist/src/core/changes/worktree.js +53 -0
- package/dist/src/core/process/model.js +153 -10
- package/dist/src/index.js +4 -0
- package/dist/src/release/orchestrator.js +3 -1
- package/dist/tests/commands/change/new.test.js +1126 -0
- package/dist/tests/commands/change/validate.test.js +1145 -0
- package/dist/tests/commands/changes/list.test.js +423 -0
- package/dist/tests/commands/process.test.js +4 -0
- package/dist/tests/commands/sensors/index.test.js +1 -1
- package/dist/tests/core/changes/derive.test.js +455 -0
- package/dist/tests/core/changes/fs-safe.test.js +82 -0
- package/dist/tests/core/changes/git-env.test.js +143 -0
- package/dist/tests/core/changes/git.test.js +442 -0
- package/dist/tests/core/changes/helpers.js +269 -0
- package/dist/tests/core/changes/list-fixtures.js +122 -0
- package/dist/tests/core/changes/list-long-history.test.js +52 -0
- package/dist/tests/core/changes/list-paging.test.js +511 -0
- package/dist/tests/core/changes/list-parse.test.js +120 -0
- package/dist/tests/core/changes/list-tree.test.js +44 -0
- package/dist/tests/core/changes/list.test.js +821 -0
- package/dist/tests/core/changes/parse.test.js +142 -0
- package/dist/tests/core/changes/predicates.test.js +83 -0
- package/dist/tests/core/changes/range.test.js +92 -0
- package/dist/tests/core/changes/receipt.test.js +947 -0
- package/dist/tests/core/changes/record-io.test.js +307 -0
- package/dist/tests/core/changes/record.test.js +549 -0
- package/dist/tests/core/changes/render-list.test.js +212 -0
- package/dist/tests/core/changes/render.test.js +202 -0
- package/dist/tests/core/changes/text-safety.test.js +226 -0
- package/dist/tests/core/changes/trailers.test.js +181 -0
- package/dist/tests/core/changes/worktree.test.js +259 -0
- package/dist/tests/core/dashboard/write-html.test.js +2 -3
- package/dist/tests/core/evidence/store.test.js +2 -3
- package/dist/tests/core/ledger/store.test.js +3 -9
- package/dist/tests/core/process/discover.test.js +21 -0
- package/dist/tests/core/process/model.test.js +338 -0
- package/dist/tests/helpers/platform.js +27 -0
- package/dist/tests/release/orchestrator-trailer.test.js +72 -0
- package/dist/tests/release/orchestrator.test.js +1 -0
- package/dist/tests/structural/change-commands-are-registered.test.js +29 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +78 -0
- package/dist/tests/structural/process-model-description-contract.test.js +34 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const helpers_1 = require("./helpers");
|
|
4
|
+
const render_list_1 = require("../../../src/core/changes/render-list");
|
|
5
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/;
|
|
6
|
+
const change = (o = {}) => ({
|
|
7
|
+
sha: 'abcdef0123456789abcdef0123456789abcdef01', subject: 'feat: x', classification: 'missing', merged_at: '2026-10-16T20:00:00.000Z', pr: null, release: null, records: [], ...o,
|
|
8
|
+
});
|
|
9
|
+
const result = (o = {}) => ({
|
|
10
|
+
repo: 'github.com/Kodria/belanz', base: 'main', since: '2026-10-15T12:00:00.000Z', adopted: true, truncated: false, omitted: 0, changes: [], ...o,
|
|
11
|
+
});
|
|
12
|
+
const rec = (o = {}) => ({
|
|
13
|
+
path: 'docs/changes/2026-10-16-first.md', schema: 'awm.change-record/v1', id: '2026-10-16-first', title: 'Primer', summary_short: 'Primer cambio.', type: 'feat',
|
|
14
|
+
scope: null, semver: 'minor', breaking: false, modules: ['cli'], verification: { commit: 'a'.repeat(40), overall: 'pass', sensors: { lint: 'pass' }, receipt_digest: `sha256:${'b'.repeat(64)}` },
|
|
15
|
+
manual_validation: [{ step: 's1', expected: 'e1' }, { step: 's2', expected: 'e2' }], risks: [], rollback: 'revert', ...o,
|
|
16
|
+
});
|
|
17
|
+
const recorded = (o = {}, c = {}) => change({ classification: 'recorded', records: [rec(o)], ...c });
|
|
18
|
+
const lines = (text) => text.split('\n').slice(0, -1);
|
|
19
|
+
describe('renderJson (R5.6)', () => {
|
|
20
|
+
test('is one line ending in a newline', () => {
|
|
21
|
+
const out = (0, render_list_1.renderJson)(result({ changes: [recorded()] }));
|
|
22
|
+
expect(out.endsWith('\n')).toBe(true);
|
|
23
|
+
expect(lines(out)).toHaveLength(1);
|
|
24
|
+
});
|
|
25
|
+
test('has exactly the documented keys, in order', () => {
|
|
26
|
+
expect(Object.keys(JSON.parse((0, render_list_1.renderJson)(result())))).toEqual(['schema', 'repo', 'base', 'since', 'adopted', 'truncated', 'omitted', 'changes']);
|
|
27
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result())).schema).toBe('awm.changes-list/v1');
|
|
28
|
+
});
|
|
29
|
+
test('each change has exactly the documented keys, in order', () => {
|
|
30
|
+
const out = JSON.parse((0, render_list_1.renderJson)(result({ changes: [change()] })));
|
|
31
|
+
expect(Object.keys(out.changes[0])).toEqual(['sha', 'subject', 'classification', 'merged_at', 'pr', 'release', 'records']);
|
|
32
|
+
});
|
|
33
|
+
test('carries the values through', () => {
|
|
34
|
+
const out = JSON.parse((0, render_list_1.renderJson)(result({ truncated: true, omitted: 4, changes: [change({ pr: 10, release: 'v1.0.0', classification: 'exempt' })] })));
|
|
35
|
+
expect(out).toMatchObject({ repo: 'github.com/Kodria/belanz', base: 'main', since: '2026-10-15T12:00:00.000Z', truncated: true, omitted: 4 });
|
|
36
|
+
expect(out.changes[0]).toMatchObject({ classification: 'exempt', merged_at: '2026-10-16T20:00:00.000Z', pr: 10, release: 'v1.0.0', records: [] });
|
|
37
|
+
});
|
|
38
|
+
test('a recorded entry holds the path and the full frontmatter', () => {
|
|
39
|
+
const out = JSON.parse((0, render_list_1.renderJson)(result({ changes: [recorded()] })));
|
|
40
|
+
expect(out.changes[0].records[0]).toMatchObject({ path: 'docs/changes/2026-10-16-first.md', summary_short: 'Primer cambio.', verification: { overall: 'pass' } });
|
|
41
|
+
});
|
|
42
|
+
test('an invalid-record entry holds the path and the diagnostics', () => {
|
|
43
|
+
const diagnostics = [{ code: 'name-invalid', message: 'm' }];
|
|
44
|
+
const out = JSON.parse((0, render_list_1.renderJson)(result({ changes: [change({ classification: 'invalid-record', records: [{ path: 'docs/changes/x.md', diagnostics }] })] })));
|
|
45
|
+
expect(out.changes[0].records).toEqual([{ path: 'docs/changes/x.md', diagnostics }]);
|
|
46
|
+
});
|
|
47
|
+
test('a repository that has not adopted records is still a valid document with no changes', () => {
|
|
48
|
+
const out = JSON.parse((0, render_list_1.renderJson)(result({ adopted: false })));
|
|
49
|
+
expect(out).toMatchObject({ adopted: false, changes: [], truncated: false, omitted: 0 });
|
|
50
|
+
});
|
|
51
|
+
test('an adopted repository says so, with or without changes', () => {
|
|
52
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result())).adopted).toBe(true);
|
|
53
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result({ changes: [change()] }))).adopted).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
test('a change whose date is unusable keeps merged_at null', () => {
|
|
56
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result({ changes: [change({ merged_at: null })] }))).changes[0].merged_at).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
test('an invalid-record entry lists every added record, the valid ones with empty diagnostics', () => {
|
|
59
|
+
const records = [{ path: 'docs/changes/a.md', diagnostics: [] }, { path: 'docs/changes/z.md', diagnostics: [{ code: 'name-invalid', message: 'm' }] }];
|
|
60
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result({ changes: [change({ classification: 'invalid-record', records })] }))).changes[0].records).toEqual(records);
|
|
61
|
+
});
|
|
62
|
+
test('a null repo stays null', () => {
|
|
63
|
+
expect(JSON.parse((0, render_list_1.renderJson)(result({ repo: null }))).repo).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
test.each([undefined, null, 5, 'x', [], {}, { changes: 'x' }])('an unusable result %j yields an error document, not a throw', (value) => {
|
|
66
|
+
const out = (0, render_list_1.renderJson)((0, helpers_1.bad)(value));
|
|
67
|
+
expect(lines(out)).toHaveLength(1);
|
|
68
|
+
expect(JSON.parse(out)).toEqual({ error: 'invalid-input' });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('renderText (R5.6)', () => {
|
|
72
|
+
test('a repository without records says so in one line, naming the repo and the base', () => {
|
|
73
|
+
expect((0, render_list_1.renderText)(result({ adopted: false }), 'fallback')).toBe('belanz has not adopted Change Records (no record under docs/changes/ on main).\n');
|
|
74
|
+
});
|
|
75
|
+
test('without a remote the fallback name is used', () => {
|
|
76
|
+
expect((0, render_list_1.renderText)(result({ adopted: false, repo: null }), 'my-dir')).toBe('my-dir has not adopted Change Records (no record under docs/changes/ on main).\n');
|
|
77
|
+
});
|
|
78
|
+
test('an empty window says so', () => {
|
|
79
|
+
expect((0, render_list_1.renderText)(result(), 'f')).toBe('No changes on main since 2026-10-15T12:00:00.000Z.\n');
|
|
80
|
+
});
|
|
81
|
+
test('has a header and one row per change', () => {
|
|
82
|
+
const out = lines((0, render_list_1.renderText)(result({ changes: [recorded({}, { pr: 10, release: 'v1.0.0' }), change({ subject: 'fix: no record' })] }), 'f'));
|
|
83
|
+
expect(out).toHaveLength(3);
|
|
84
|
+
expect(out[0]).toMatch(/^WHEN\s+CLASSIFICATION\s+SHA\s+PR\s+CHANGE/);
|
|
85
|
+
});
|
|
86
|
+
test('a row has the time, classification, short sha, PR, title and release', () => {
|
|
87
|
+
const out = lines((0, render_list_1.renderText)(result({ changes: [recorded({}, { pr: 10, release: 'v1.0.0' })] }), 'f'))[1];
|
|
88
|
+
expect(out).toBe('2026-10-16 20:00 recorded abcdef01 #10 Primer [v1.0.0]');
|
|
89
|
+
});
|
|
90
|
+
test('a change without a record shows its subject; a missing PR and release show a dash and nothing', () => {
|
|
91
|
+
const out = lines((0, render_list_1.renderText)(result({ changes: [change({ subject: 'fix: no record' })] }), 'f'))[1];
|
|
92
|
+
expect(out).toBe('2026-10-16 20:00 missing abcdef01 - fix: no record');
|
|
93
|
+
});
|
|
94
|
+
test('an invalid record shows the code of its first diagnostic', () => {
|
|
95
|
+
const entry = change({ classification: 'invalid-record', subject: 'feat: bad', records: [{ path: 'docs/changes/x.md', diagnostics: [{ code: 'name-invalid', message: 'm' }, { code: 'other', message: 'm' }] }] });
|
|
96
|
+
expect(lines((0, render_list_1.renderText)(result({ changes: [entry] }), 'f'))[1]).toBe('2026-10-16 20:00 invalid-record abcdef01 - feat: bad (name-invalid)');
|
|
97
|
+
});
|
|
98
|
+
test('a mixed invalid-record commit shows the first FAILING record, not the first record', () => {
|
|
99
|
+
const entry = change({ classification: 'invalid-record', subject: 'feat: mixed', records: [{ path: 'docs/changes/a.md', diagnostics: [] }, { path: 'docs/changes/z.md', diagnostics: [{ code: 'name-invalid', message: 'm' }, { code: 'other', message: 'm' }] }] });
|
|
100
|
+
expect(lines((0, render_list_1.renderText)(result({ changes: [entry] }), 'f'))[1]).toBe('2026-10-16 20:00 invalid-record abcdef01 - feat: mixed (name-invalid)');
|
|
101
|
+
});
|
|
102
|
+
test('a change whose date is unusable shows unknown in place of the time', () => {
|
|
103
|
+
expect(lines((0, render_list_1.renderText)(result({ changes: [change({ merged_at: null })] }), 'f'))[1]).toBe('unknown missing abcdef01 - feat: x');
|
|
104
|
+
});
|
|
105
|
+
test('a truncated list ends with a notice naming how many were left out', () => {
|
|
106
|
+
const out = lines((0, render_list_1.renderText)(result({ truncated: true, omitted: 7, changes: [change()] }), 'f'));
|
|
107
|
+
expect(out[out.length - 1]).toBe('... 7 older change(s) not shown; narrow --since to see them.');
|
|
108
|
+
});
|
|
109
|
+
test('a list that is not truncated has no notice', () => {
|
|
110
|
+
expect((0, render_list_1.renderText)(result({ changes: [change()] }), 'f')).not.toMatch(/not shown/);
|
|
111
|
+
});
|
|
112
|
+
test('hostile text in every field stays on its own clean, bounded line', () => {
|
|
113
|
+
const hostile = `\u001b[31mred\u001b[0m\nsecond \u202eevil\u2028${'x'.repeat(1000)}`;
|
|
114
|
+
const entry = recorded({ title: hostile }, { release: hostile, pr: 3 });
|
|
115
|
+
const out = (0, render_list_1.renderText)(result({ base: hostile, repo: hostile, changes: [entry, change({ subject: hostile, classification: 'invalid-record', records: [{ path: hostile, diagnostics: [{ code: hostile, message: hostile }] }] })] }), hostile);
|
|
116
|
+
for (const line of lines(out)) {
|
|
117
|
+
expect(line).not.toMatch(UNSAFE);
|
|
118
|
+
expect(line.length).toBeLessThanOrEqual(300);
|
|
119
|
+
}
|
|
120
|
+
expect(lines(out)).toHaveLength(3);
|
|
121
|
+
expect(lines((0, render_list_1.renderText)(result({ adopted: false, base: hostile, repo: hostile }), hostile))).toHaveLength(1);
|
|
122
|
+
expect(lines((0, render_list_1.renderText)(result({ base: hostile }), hostile))).toHaveLength(1);
|
|
123
|
+
for (const line of lines((0, render_list_1.renderText)(result({ adopted: false, base: hostile, repo: hostile }), hostile))) {
|
|
124
|
+
expect(line).not.toMatch(UNSAFE);
|
|
125
|
+
expect(line.length).toBeLessThanOrEqual(300);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
test.each([undefined, null, 5, 'x', [], {}, { changes: 'x' }])('an unusable result %j yields one line, not a throw', (value) => {
|
|
129
|
+
expect(lines((0, render_list_1.renderText)((0, helpers_1.bad)(value), 'f'))).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
test('an unusable fallback name does not throw', () => {
|
|
132
|
+
expect(lines((0, render_list_1.renderText)(result({ adopted: false, repo: null }), (0, helpers_1.bad)(undefined)))).toHaveLength(1);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
describe('renderVoice (R5.6)', () => {
|
|
136
|
+
test('one sentence per recorded change, then the closing count', () => {
|
|
137
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [recorded(), change({ subject: 'fix: no record' }), change({ classification: 'invalid-record' })] }), 'f');
|
|
138
|
+
expect(lines(out)).toEqual([
|
|
139
|
+
'belanz: Primer cambio. feat, verificado, 2 validaciones manuales pendientes.',
|
|
140
|
+
'Además: 1 cambio(s) sin record y 1 con record inválido.',
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
test('the closing count is always given when there are changes', () => {
|
|
144
|
+
expect(lines((0, render_list_1.renderVoice)(result({ changes: [recorded()] }), 'f'))[1]).toBe('Además: 0 cambio(s) sin record y 0 con record inválido.');
|
|
145
|
+
});
|
|
146
|
+
test.each(['fail', 'skipped', 'not_certified'])('overall %s is sin verificar', (overall) => {
|
|
147
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [recorded({ verification: { overall } })] }), 'f');
|
|
148
|
+
expect(lines(out)[0]).toBe('belanz: Primer cambio. feat, sin verificar, 2 validaciones manuales pendientes.');
|
|
149
|
+
});
|
|
150
|
+
test('a record with no manual validation reports zero pending', () => {
|
|
151
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [recorded({ manual_validation: { none: 'nothing to check' } })] }), 'f');
|
|
152
|
+
expect(lines(out)[0]).toBe('belanz: Primer cambio. feat, verificado, 0 validaciones manuales pendientes.');
|
|
153
|
+
});
|
|
154
|
+
test('one pending validation is reported with the same wording', () => {
|
|
155
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [recorded({ manual_validation: [{ step: 'a', expected: 'b' }] })] }), 'f');
|
|
156
|
+
expect(lines(out)[0]).toContain('1 validaciones manuales pendientes.');
|
|
157
|
+
});
|
|
158
|
+
test.each([
|
|
159
|
+
['Primer cambio.', 'Primer cambio.'],
|
|
160
|
+
['Primer cambio', 'Primer cambio.'],
|
|
161
|
+
['Funciona!', 'Funciona!'],
|
|
162
|
+
['¿Funciona?', '¿Funciona?'],
|
|
163
|
+
])('the summary %j is spoken as %j followed by the type', (summary, spoken) => {
|
|
164
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [recorded({ summary_short: summary, type: 'fix' })] }), 'f');
|
|
165
|
+
expect(lines(out)[0]).toBe(`belanz: ${spoken} fix, verificado, 2 validaciones manuales pendientes.`);
|
|
166
|
+
});
|
|
167
|
+
test('a mixed invalid-record commit is counted, not spoken', () => {
|
|
168
|
+
const entry = change({ classification: 'invalid-record', records: [{ path: 'docs/changes/a.md', diagnostics: [] }, { path: 'docs/changes/z.md', diagnostics: [{ code: 'x', message: 'm' }] }] });
|
|
169
|
+
expect(lines((0, render_list_1.renderVoice)(result({ changes: [entry] }), 'f'))).toEqual(['Además: 0 cambio(s) sin record y 1 con record inválido.']);
|
|
170
|
+
});
|
|
171
|
+
test('a change whose date is unusable is spoken like any other', () => {
|
|
172
|
+
expect(lines((0, render_list_1.renderVoice)(result({ changes: [recorded({}, { merged_at: null })] }), 'f'))[0]).toBe('belanz: Primer cambio. feat, verificado, 2 validaciones manuales pendientes.');
|
|
173
|
+
});
|
|
174
|
+
test('only recorded changes get a sentence; exempt ones are silent', () => {
|
|
175
|
+
const out = (0, render_list_1.renderVoice)(result({ changes: [change({ classification: 'exempt' }), recorded()] }), 'f');
|
|
176
|
+
expect(lines(out)).toHaveLength(2);
|
|
177
|
+
});
|
|
178
|
+
test('a change with two records gets one sentence for each', () => {
|
|
179
|
+
const entry = change({ classification: 'recorded', records: [rec({ summary_short: 'Uno.' }), rec({ summary_short: 'Dos.' })] });
|
|
180
|
+
const out = lines((0, render_list_1.renderVoice)(result({ changes: [entry] }), 'f'));
|
|
181
|
+
expect(out.slice(0, 2).map((l) => l.split(' ')[1])).toEqual(['Uno.', 'Dos.']);
|
|
182
|
+
});
|
|
183
|
+
test('the name falls back to the directory name without a remote', () => {
|
|
184
|
+
expect(lines((0, render_list_1.renderVoice)(result({ repo: null, changes: [recorded()] }), 'my-dir'))[0].startsWith('my-dir: ')).toBe(true);
|
|
185
|
+
});
|
|
186
|
+
test('a repository without records is told in one sentence', () => {
|
|
187
|
+
expect((0, render_list_1.renderVoice)(result({ adopted: false }), 'f')).toBe('belanz: todavía no usa Change Records.\n');
|
|
188
|
+
});
|
|
189
|
+
test('an empty window is told in one sentence', () => {
|
|
190
|
+
expect((0, render_list_1.renderVoice)(result(), 'f')).toBe('belanz: sin cambios desde 2026-10-15.\n');
|
|
191
|
+
});
|
|
192
|
+
test('a truncated list ends with a notice', () => {
|
|
193
|
+
const out = lines((0, render_list_1.renderVoice)(result({ truncated: true, omitted: 3, changes: [recorded()] }), 'f'));
|
|
194
|
+
expect(out[out.length - 1]).toBe('Hay 3 cambio(s) más antiguo(s) sin mostrar; acota el período.');
|
|
195
|
+
});
|
|
196
|
+
test('a list that is not truncated has no notice', () => {
|
|
197
|
+
expect((0, render_list_1.renderVoice)(result({ changes: [recorded()] }), 'f')).not.toMatch(/acota/);
|
|
198
|
+
});
|
|
199
|
+
test('hostile text stays on clean, bounded lines', () => {
|
|
200
|
+
const hostile = `\u001b[31mred\u001b[0m\nsecond \u202eevil\u2028${'x'.repeat(1000)}`;
|
|
201
|
+
const out = (0, render_list_1.renderVoice)(result({ repo: hostile, changes: [recorded({ summary_short: hostile, type: hostile })] }), hostile);
|
|
202
|
+
expect(lines(out)).toHaveLength(2);
|
|
203
|
+
for (const line of lines(out)) {
|
|
204
|
+
expect(line).not.toMatch(UNSAFE);
|
|
205
|
+
expect(line.length).toBeLessThanOrEqual(600);
|
|
206
|
+
}
|
|
207
|
+
expect(lines((0, render_list_1.renderVoice)(result({ adopted: false, repo: hostile }), hostile))).toHaveLength(1);
|
|
208
|
+
});
|
|
209
|
+
test.each([undefined, null, 5, 'x', [], {}, { changes: 'x' }])('an unusable result %j yields one line, not a throw', (value) => {
|
|
210
|
+
expect(lines((0, render_list_1.renderVoice)((0, helpers_1.bad)(value), 'f'))).toHaveLength(1);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
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 js_yaml_1 = __importDefault(require("js-yaml"));
|
|
7
|
+
const helpers_1 = require("./helpers");
|
|
8
|
+
const parse_1 = require("../../../src/core/changes/parse");
|
|
9
|
+
const render_1 = require("../../../src/core/changes/render");
|
|
10
|
+
const validate_shape_1 = require("../../../src/core/changes/validate-shape");
|
|
11
|
+
// Pure functions: no repository, no filesystem.
|
|
12
|
+
const frontmatter = (text) => {
|
|
13
|
+
const split = (0, parse_1.splitRecord)(text);
|
|
14
|
+
if (!split.ok)
|
|
15
|
+
throw new Error('not a record');
|
|
16
|
+
const loaded = (0, parse_1.loadFrontmatter)(split.frontmatterText);
|
|
17
|
+
if (!loaded.ok)
|
|
18
|
+
throw new Error('bad yaml');
|
|
19
|
+
return loaded.value;
|
|
20
|
+
};
|
|
21
|
+
describe('render: renderSkeleton', () => {
|
|
22
|
+
const fields = { type: 'feat', scope: null, semver: 'minor', breaking: false, modules: ['cli/src'] };
|
|
23
|
+
const verification = { commit: 'a'.repeat(40), overall: 'pass', sensors: { lint: 'pass' }, receipt_digest: `sha256:${'b'.repeat(64)}` };
|
|
24
|
+
test('renders frontmatter and the four body sections', () => {
|
|
25
|
+
const text = (0, render_1.renderSkeleton)({ id: '2026-10-17-x', fields, verification });
|
|
26
|
+
expect(text.startsWith('---\nschema: awm.change-record/v1\nid: 2026-10-17-x\n')).toBe(true);
|
|
27
|
+
expect((0, validate_shape_1.validateRecordShape)('2026-10-17-x.md', text).diagnostics.map((d) => d.code)).toEqual(['pending-content']);
|
|
28
|
+
});
|
|
29
|
+
test('hostile provenance and link values survive a YAML round trip as plain strings', () => {
|
|
30
|
+
const nasty = 'a: b # c\n- d';
|
|
31
|
+
const text = (0, render_1.renderSkeleton)({ id: '2026-10-17-x', fields, verification, provenance: { model: nasty, session: '&anchor *alias !!tag' }, links: { issue: 'https://e.com/?a=b#c' } });
|
|
32
|
+
expect(text).not.toBeNull();
|
|
33
|
+
const fm = frontmatter(text);
|
|
34
|
+
expect(fm.provenance).toEqual({ model: nasty, session: '&anchor *alias !!tag' });
|
|
35
|
+
expect(fm.links).toEqual({ issue: 'https://e.com/?a=b#c' });
|
|
36
|
+
expect(Object.keys(fm)).toEqual(['schema', 'id', 'title', 'summary_short', 'type', 'scope', 'semver', 'breaking', 'modules', 'links', 'verification', 'manual_validation', 'risks', 'rollback', 'provenance']);
|
|
37
|
+
});
|
|
38
|
+
const circular = {};
|
|
39
|
+
circular.self = circular;
|
|
40
|
+
test.each([
|
|
41
|
+
['a null input', () => (0, render_1.renderSkeleton)(null)],
|
|
42
|
+
['an undefined verification', () => (0, render_1.renderSkeleton)({ id: '2026-10-17-x', fields, verification: undefined })],
|
|
43
|
+
['a numeric id', () => (0, render_1.renderSkeleton)({ id: 5, fields, verification })],
|
|
44
|
+
['a circular provenance', () => (0, render_1.renderSkeleton)({ id: '2026-10-17-x', fields, verification, provenance: circular })],
|
|
45
|
+
])('yields null for %s instead of throwing', (_name, call) => {
|
|
46
|
+
expect(call()).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('render: refreshDerived', () => {
|
|
50
|
+
const FRESH = { type: 'feat', scope: 'core', semver: 'minor', breaking: false, modules: ['cli/src/core'] };
|
|
51
|
+
const FRESH_V = { commit: 'c'.repeat(40), overall: 'pass', sensors: { lint: 'pass' }, receipt_digest: `sha256:${'d'.repeat(64)}` };
|
|
52
|
+
const OLD = [
|
|
53
|
+
'---', 'schema: awm.change-record/v1', 'id: 2026-10-17-x', '# derived fields follow', 'type: fix', 'scope: null', 'semver: patch', 'breaking: false',
|
|
54
|
+
'modules:', '- old/a', '- old/b', '# a comment after the list', '', 'links:', ' plan: docs/plans/p.md', 'verification:', ` commit: ${'0'.repeat(40)}`,
|
|
55
|
+
' overall: fail', ' sensors:', ' lint: fail', ` receipt_digest: sha256:${'0'.repeat(64)}`, 'title: Hello', '---', '', 'body text', '',
|
|
56
|
+
];
|
|
57
|
+
const NEW_BLOCKS = [
|
|
58
|
+
'---', 'schema: awm.change-record/v1', 'id: 2026-10-17-x', '# derived fields follow', 'type: feat', 'scope: core', 'semver: minor', 'breaking: false',
|
|
59
|
+
'modules:', ' - cli/src/core', '# a comment after the list', '', 'links:', ' plan: docs/plans/p.md', 'verification:', ` commit: ${'c'.repeat(40)}`,
|
|
60
|
+
' overall: pass', ' sensors:', ' lint: pass', ` receipt_digest: sha256:${'d'.repeat(64)}`, 'title: Hello', '---', '', 'body text', '',
|
|
61
|
+
];
|
|
62
|
+
const lf = (lines) => lines.join('\n');
|
|
63
|
+
const crlf = (lines) => lines.join('\r\n');
|
|
64
|
+
test('replaces exactly the derived blocks, keeping comments, blank lines, other keys and the body', () => {
|
|
65
|
+
expect((0, render_1.refreshDerived)(lf(OLD), FRESH, FRESH_V)).toBe(lf(NEW_BLOCKS));
|
|
66
|
+
});
|
|
67
|
+
test('a column-0 list is replaced as one block and the key after it is untouched', () => {
|
|
68
|
+
const old = ['---', 'type: fix', 'scope: x', 'semver: none', 'breaking: false', 'modules:', '- a', '- b', 'verification: {}', 'title: T', '---', 'b', ''];
|
|
69
|
+
const out = (0, render_1.refreshDerived)(lf(old), FRESH, FRESH_V);
|
|
70
|
+
expect(out.endsWith('title: T\n---\nb\n')).toBe(true);
|
|
71
|
+
expect(frontmatter(out).modules).toEqual(['cli/src/core']);
|
|
72
|
+
expect(frontmatter(out).title).toBe('T');
|
|
73
|
+
});
|
|
74
|
+
test('CRLF files keep CRLF', () => {
|
|
75
|
+
expect((0, render_1.refreshDerived)(crlf(OLD), FRESH, FRESH_V)).toBe(crlf(NEW_BLOCKS));
|
|
76
|
+
});
|
|
77
|
+
test('mixed line endings keep each untouched line ending', () => {
|
|
78
|
+
const old = OLD.map((l, i) => (i % 2 === 0 ? `${l}\r` : l));
|
|
79
|
+
const out = (0, render_1.refreshDerived)(old.join('\n'), FRESH, FRESH_V);
|
|
80
|
+
expect(out.split('\n').find((l) => l.startsWith('title:'))).toBe(old.find((l) => l.startsWith('title:')));
|
|
81
|
+
expect(out.endsWith(old.slice(-2).join('\n'))).toBe(true);
|
|
82
|
+
expect(frontmatter(out).modules).toEqual(['cli/src/core']);
|
|
83
|
+
});
|
|
84
|
+
test('the body is preserved byte for byte, even when it looks like frontmatter', () => {
|
|
85
|
+
const tail = '\n\n---\ntype: nope\n---\nmodules:\n- z\n';
|
|
86
|
+
const out = (0, render_1.refreshDerived)(lf(OLD) + tail, FRESH, FRESH_V);
|
|
87
|
+
expect(out.endsWith(tail)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
test('trailing whitespace after the closing delimiter content and a missing final newline survive', () => {
|
|
90
|
+
const out = (0, render_1.refreshDerived)(lf(OLD).replace(/\n$/, '') + ' ', FRESH, FRESH_V);
|
|
91
|
+
expect(out.endsWith('body text ')).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
test('a scalar with a trailing comment on the key line is replaced as a unit', () => {
|
|
94
|
+
const old = lf(OLD).replace('type: fix', 'type: fix # why');
|
|
95
|
+
expect((0, render_1.refreshDerived)(old, FRESH, FRESH_V)).toBe(lf(NEW_BLOCKS));
|
|
96
|
+
});
|
|
97
|
+
test('a null scope round-trips as null', () => {
|
|
98
|
+
const out = (0, render_1.refreshDerived)(lf(OLD), { ...FRESH, scope: null }, FRESH_V);
|
|
99
|
+
expect(frontmatter(out).scope).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
test('hostile sensor names and module names are quoted so they parse back unchanged', () => {
|
|
102
|
+
const v = { ...FRESH_V, sensors: { 'a: b': 'pass', '# x': 'fail', 'yes': 'skipped', '-': 'pass' } };
|
|
103
|
+
const out = (0, render_1.refreshDerived)(lf(OLD), { ...FRESH, modules: ['a b', 'c#d', '*x', 'y:z'] }, v);
|
|
104
|
+
expect(frontmatter(out).verification).toEqual(v);
|
|
105
|
+
expect(frontmatter(out).modules).toEqual(['a b', 'c#d', '*x', 'y:z']);
|
|
106
|
+
expect(strip2(out)).toBe(strip2(lf(NEW_BLOCKS)));
|
|
107
|
+
});
|
|
108
|
+
// The title/id/links/body lines, which a refresh never touches.
|
|
109
|
+
const strip2 = (s) => s.split('\n').filter((l) => /^(schema|id|title|links| plan|#|body)/.test(l)).join('\n');
|
|
110
|
+
test.each([
|
|
111
|
+
['no frontmatter', 'type: fix\n'],
|
|
112
|
+
['unclosed frontmatter', '---\ntype: fix\n'],
|
|
113
|
+
['unparseable yaml', '---\n: : :\n---\n'],
|
|
114
|
+
['a missing derived key', lf(OLD).replace('semver: patch\n', '')],
|
|
115
|
+
['a missing verification', lf(OLD).replace(/^verification:[\s\S]*?(?=^title)/m, '')],
|
|
116
|
+
['a non-mapping frontmatter', '---\n- a\n- b\n---\n'],
|
|
117
|
+
['a duplicated key', lf(OLD).replace('title: Hello', 'type: fix\ntitle: Hello')],
|
|
118
|
+
['an anchor', lf(OLD).replace('title: Hello', 'title: &a Hello')],
|
|
119
|
+
['a key the line scan cannot recognise, right after a replaced block', lf(OLD).replace('title: Hello', 'odd key: 1\ntitle: Hello')],
|
|
120
|
+
['an escaped spelling of a derived key next to the bare one', lf(OLD).replace('title: Hello', '"\\u0074ype": x\ntitle: Hello')],
|
|
121
|
+
['an escaped spelling of a derived key instead of the bare one', lf(OLD).replace('type: fix\n', '"\\u0074ype": fix\n')],
|
|
122
|
+
['an empty string', ''],
|
|
123
|
+
])('returns null for %s', (_name, raw) => {
|
|
124
|
+
expect((0, render_1.refreshDerived)(raw, FRESH, FRESH_V)).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
const circular = {};
|
|
127
|
+
circular.self = circular;
|
|
128
|
+
test.each([
|
|
129
|
+
['raw text that is not a string', () => (0, render_1.refreshDerived)(undefined, FRESH, FRESH_V)],
|
|
130
|
+
['fields that are null', () => (0, render_1.refreshDerived)(lf(OLD), null, FRESH_V)],
|
|
131
|
+
['verification that is undefined', () => (0, render_1.refreshDerived)(lf(OLD), FRESH, undefined)],
|
|
132
|
+
['a derived field that is undefined', () => (0, render_1.refreshDerived)(lf(OLD), { ...FRESH, scope: undefined }, FRESH_V)],
|
|
133
|
+
['a circular verification', () => (0, render_1.refreshDerived)(lf(OLD), FRESH, circular)],
|
|
134
|
+
])('returns null for %s, never throws', (_name, call) => {
|
|
135
|
+
expect(call()).toBeNull();
|
|
136
|
+
});
|
|
137
|
+
test('a block scalar after the derived keys is carried over byte for byte', () => {
|
|
138
|
+
const raw = lf(OLD).replace('title: Hello', 'title: >\n one\n two\nrollback: kept');
|
|
139
|
+
const out = (0, render_1.refreshDerived)(raw, FRESH, FRESH_V);
|
|
140
|
+
expect(frontmatter(out).title).toBe('one two\n');
|
|
141
|
+
expect(frontmatter(out).rollback).toBe('kept');
|
|
142
|
+
expect(out).toContain('title: >\n one\n two\nrollback: kept');
|
|
143
|
+
});
|
|
144
|
+
test('never alters a key that is not derived, whatever follows a replaced block', () => {
|
|
145
|
+
const raw = lf(OLD).replace('breaking: false\n', 'breaking: false\nrisks:\n- r1\n- r2\n');
|
|
146
|
+
const out = (0, render_1.refreshDerived)(raw, FRESH, FRESH_V);
|
|
147
|
+
expect(frontmatter(out).risks).toEqual(['r1', 'r2']);
|
|
148
|
+
expect(out).toContain('risks:\n- r1\n- r2\n');
|
|
149
|
+
});
|
|
150
|
+
describe('recomputed keys written other than bare', () => {
|
|
151
|
+
const SPELLINGS = [
|
|
152
|
+
['double-quoted', (k) => `"${k}":`],
|
|
153
|
+
['single-quoted', (k) => `'${k}':`],
|
|
154
|
+
['bare with a space before the colon', (k) => `${k} :`],
|
|
155
|
+
['bare with a tab before the colon', (k) => `${k}\t:`],
|
|
156
|
+
['double-quoted with spaces before the colon', (k) => `"${k}" :`],
|
|
157
|
+
['single-quoted with a tab before the colon', (k) => `'${k}'\t:`],
|
|
158
|
+
];
|
|
159
|
+
const spell = (lines, key, fn) => lines.map((l) => (l.startsWith(`${key}:`) ? fn(key) + l.slice(key.length + 1) : l));
|
|
160
|
+
const KEYS = ['type', 'scope', 'semver', 'breaking', 'modules', 'verification'];
|
|
161
|
+
test.each(SPELLINGS.flatMap(([name, fn]) => KEYS.map((key) => [name, key, fn])))('%s %s is recomputed like a bare key', (_name, key, fn) => {
|
|
162
|
+
const old = spell(OLD, key, fn);
|
|
163
|
+
expect(old).not.toEqual(OLD);
|
|
164
|
+
expect((0, render_1.refreshDerived)(lf(old), FRESH, FRESH_V)).toBe(lf(NEW_BLOCKS));
|
|
165
|
+
});
|
|
166
|
+
test.each(SPELLINGS)('%s keys keep CRLF', (_name, fn) => {
|
|
167
|
+
const old = KEYS.reduce((lines, key) => spell(lines, key, fn), OLD);
|
|
168
|
+
expect((0, render_1.refreshDerived)(crlf(old), FRESH, FRESH_V)).toBe(crlf(NEW_BLOCKS));
|
|
169
|
+
});
|
|
170
|
+
test('a spelled key is also found in the middle of a file next to other keys', () => {
|
|
171
|
+
const old = lf(OLD).replace('title: Hello', '"title": Hello');
|
|
172
|
+
expect((0, render_1.refreshDerived)(old, FRESH, FRESH_V)).toBe(lf(NEW_BLOCKS).replace('title: Hello', '"title": Hello'));
|
|
173
|
+
});
|
|
174
|
+
test('a quoted key with an escaped quote is kept, never taken for a derived key', () => {
|
|
175
|
+
const line = '"sem\\"ver": 1';
|
|
176
|
+
const out = (0, render_1.refreshDerived)(lf(OLD).replace('title: Hello', `${line}\ntitle: Hello`), FRESH, FRESH_V);
|
|
177
|
+
expect(out).toBe(lf(NEW_BLOCKS).replace('title: Hello', `${line}\ntitle: Hello`));
|
|
178
|
+
expect(frontmatter(out)['sem"ver']).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
test('a single-quoted key with a doubled quote is kept, never taken for a derived key', () => {
|
|
181
|
+
const line = "'it''s': 1";
|
|
182
|
+
const out = (0, render_1.refreshDerived)(lf(OLD).replace('title: Hello', `${line}\ntitle: Hello`), FRESH, FRESH_V);
|
|
183
|
+
expect(out).toBe(lf(NEW_BLOCKS).replace('title: Hello', `${line}\ntitle: Hello`));
|
|
184
|
+
expect(frontmatter(out)["it's"]).toBe(1);
|
|
185
|
+
});
|
|
186
|
+
test('a quoted key that merely contains a derived name is not recomputed', () => {
|
|
187
|
+
const line = '"type extra": 1';
|
|
188
|
+
const out = (0, render_1.refreshDerived)(lf(OLD).replace('title: Hello', `${line}\ntitle: Hello`), FRESH, FRESH_V);
|
|
189
|
+
expect(out).toContain(`${line}\ntitle: Hello`);
|
|
190
|
+
});
|
|
191
|
+
test('a hostile very long quoted line is scanned in linear time', () => {
|
|
192
|
+
const hostile = (n) => lf(OLD).replace('title: Hello', `"${'\\'.repeat(n)}\ntitle: Hello`);
|
|
193
|
+
expect((0, render_1.refreshDerived)(hostile(200000), FRESH, FRESH_V)).toBeNull();
|
|
194
|
+
(0, helpers_1.expectLinearScaling)((n) => { (0, render_1.refreshDerived)(hostile(n), FRESH, FRESH_V); }, 50_000);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
test('the dumped blocks use the core schema (what the validator parses)', () => {
|
|
198
|
+
const out = (0, render_1.refreshDerived)(lf(OLD), { ...FRESH, scope: 'null' }, FRESH_V);
|
|
199
|
+
expect(out).toContain("scope: 'null'");
|
|
200
|
+
expect(js_yaml_1.default.load(out.split('\n---')[0].slice(4), { schema: js_yaml_1.default.CORE_SCHEMA })).toMatchObject({ scope: 'null' });
|
|
201
|
+
});
|
|
202
|
+
});
|