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,549 @@
|
|
|
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 os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const validate_shape_1 = require("../../../src/core/changes/validate-shape");
|
|
10
|
+
const types_1 = require("../../../src/core/changes/types");
|
|
11
|
+
const SHA = 'a'.repeat(40);
|
|
12
|
+
const DIGEST = 'sha256:' + 'b'.repeat(64);
|
|
13
|
+
const NAME = '2026-10-17-change-record.md';
|
|
14
|
+
function fm(overrides = {}) {
|
|
15
|
+
const fields = {
|
|
16
|
+
schema: 'schema: awm.change-record/v1',
|
|
17
|
+
id: 'id: 2026-10-17-change-record',
|
|
18
|
+
title: 'title: "Change Record por PR"',
|
|
19
|
+
summary_short: 'summary_short: "Agrega el Change Record."',
|
|
20
|
+
type: 'type: feat',
|
|
21
|
+
scope: 'scope: changes',
|
|
22
|
+
semver: 'semver: minor',
|
|
23
|
+
breaking: 'breaking: false',
|
|
24
|
+
modules: 'modules:\n - cli/src/core\n - cli/tests/core',
|
|
25
|
+
verification: `verification:\n commit: ${SHA}\n overall: pass\n sensors:\n lint: pass\n typecheck: pass\n receipt_digest: ${DIGEST}`,
|
|
26
|
+
manual_validation: 'manual_validation:\n - step: "Run awm changes list"\n expected: "Shows the record"',
|
|
27
|
+
risks: 'risks: []',
|
|
28
|
+
rollback: 'rollback: "Revert the PR"',
|
|
29
|
+
...overrides,
|
|
30
|
+
};
|
|
31
|
+
return Object.values(fields).filter((v) => v !== '').join('\n');
|
|
32
|
+
}
|
|
33
|
+
const BODY = '## Qué se hizo\nA\n\n## Qué se probó\nB\n\n## Qué falta validar\nC\n\n## Riesgos y rollback\nD\n';
|
|
34
|
+
const doc = (front, body = BODY) => `---\n${front}\n---\n${body}`;
|
|
35
|
+
const codes = (r) => r.diagnostics.map((d) => d.code);
|
|
36
|
+
/** Asserts the diagnostic list contains `code` (and, when given, on exactly this `field`). */
|
|
37
|
+
function expectDiag(r, code, field) {
|
|
38
|
+
expect(r.record).toBeUndefined();
|
|
39
|
+
expect(r.diagnostics).toContainEqual(expect.objectContaining(field === undefined ? { code } : { code, field }));
|
|
40
|
+
}
|
|
41
|
+
const check = (overrides, opts) => (0, validate_shape_1.validateRecordShape)(NAME, doc(fm(overrides)), opts);
|
|
42
|
+
const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
|
|
43
|
+
const withSensors = (sensors) => fm({ verification: `verification:\n commit: ${SHA}\n overall: pass\n sensors:\n${sensors}\n receipt_digest: ${DIGEST}` });
|
|
44
|
+
describe('validateRecordShape: body headings', () => {
|
|
45
|
+
const BLANKS = BODY.split('\n').map((l) => (l.startsWith('## ') ? `${l} \t ` : l)).join('\n');
|
|
46
|
+
test('R1.6 trailing blanks after a heading are ignored', () => {
|
|
47
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), BLANKS)).diagnostics).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
test.each([['leading blanks', (l) => ` ${l}`], ['extra text after the heading', (l) => `${l} (draft)`]])('R1.6 a heading with %s is not that section', (_n, change) => {
|
|
50
|
+
const body = BODY.split('\n').map((l) => (l === '## Qué se probó' ? change(l) : l)).join('\n');
|
|
51
|
+
expect(codes((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), body)))).toContain('body-section-missing');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('validateRecordShape', () => {
|
|
55
|
+
test('accepts a complete valid record', () => {
|
|
56
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, doc(fm()));
|
|
57
|
+
expect(r.diagnostics).toEqual([]);
|
|
58
|
+
expect(r.record?.semver).toBe('minor');
|
|
59
|
+
});
|
|
60
|
+
describe('R1.1 file names', () => {
|
|
61
|
+
test('rejects an invalid slug', () => {
|
|
62
|
+
expectDiag((0, validate_shape_1.validateRecordShape)('2026-10-17-Bad_Slug.md', doc(fm())), 'name-invalid');
|
|
63
|
+
});
|
|
64
|
+
test('rejects an impossible date', () => {
|
|
65
|
+
expectDiag((0, validate_shape_1.validateRecordShape)('2026-13-40-x.md', doc(fm({ id: 'id: 2026-13-40-x' }))), 'name-invalid');
|
|
66
|
+
});
|
|
67
|
+
test('rejects a slug longer than 64 characters', () => {
|
|
68
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(`2026-10-17-${'a'.repeat(65)}.md`, doc(fm())), 'name-invalid');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe('R1.2 frontmatter', () => {
|
|
72
|
+
test('is required', () => {
|
|
73
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, BODY), 'frontmatter-missing');
|
|
74
|
+
});
|
|
75
|
+
test('rejects aliases of collections', () => {
|
|
76
|
+
expectDiag(check({ risks: 'risks: &r ["x"]', rollback: 'rollback: *r' }), 'yaml-alias');
|
|
77
|
+
});
|
|
78
|
+
test('rejects custom tags', () => {
|
|
79
|
+
expectDiag(check({ rollback: 'rollback: !!js/function "f"' }), 'yaml-invalid');
|
|
80
|
+
});
|
|
81
|
+
test('rejects scalar aliases and unused anchors', () => {
|
|
82
|
+
expectDiag(check({ title: 'title: &a T', rollback: 'rollback: *a' }), 'yaml-alias');
|
|
83
|
+
expectDiag(check({ rollback: 'rollback: &a r' }), 'yaml-alias');
|
|
84
|
+
expectDiag(check({ risks: 'risks:\n - &r x\n - *r' }), 'yaml-alias');
|
|
85
|
+
expectDiag(check({ modules: 'modules: &m\n - cli/src/core' }), 'yaml-alias');
|
|
86
|
+
expectDiag(check({ risks: 'risks:\n - &r x' }), 'yaml-alias');
|
|
87
|
+
});
|
|
88
|
+
test('accepts quoted text that merely looks like an anchor or alias', () => {
|
|
89
|
+
const r = check({ summary_short: 'summary_short: "uses *a and &b"', rollback: "rollback: 'revert &a *b'" });
|
|
90
|
+
expect(r.diagnostics).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('R1.3 unknown fields', () => {
|
|
94
|
+
test('are rejected at the top level', () => {
|
|
95
|
+
expectDiag(check({ extra: 'status: merged' }), 'unknown-field', 'status');
|
|
96
|
+
});
|
|
97
|
+
test('are rejected under links', () => {
|
|
98
|
+
expectDiag(check({ links: 'links:\n pr: "https://x"' }), 'unknown-field', 'links.pr');
|
|
99
|
+
});
|
|
100
|
+
test('are rejected under verification, manual_validation items and provenance', () => {
|
|
101
|
+
expectDiag(check({ verification: `verification:\n commit: ${SHA}\n overall: pass\n sensors: {}\n receipt_digest: ${DIGEST}\n extra: 1` }), 'unknown-field', 'verification.extra');
|
|
102
|
+
expectDiag(check({ manual_validation: 'manual_validation:\n - step: "a"\n expected: "b"\n more: "c"' }), 'unknown-field', 'manual_validation.0.more');
|
|
103
|
+
expectDiag(check({ provenance: 'provenance:\n agent: "a"\n host: "h"' }), 'unknown-field', 'provenance.host');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('R1.4 field rules', () => {
|
|
107
|
+
test('id must equal the file name', () => {
|
|
108
|
+
expectDiag(check({ id: 'id: other' }), 'field-invalid', 'id');
|
|
109
|
+
});
|
|
110
|
+
test('R1.4 summary_short and title are limited in code points, not UTF-16 units: 280 and 120 emoji pass, one more fails', () => {
|
|
111
|
+
const emoji = '\u{1F600}';
|
|
112
|
+
expect(check({ summary_short: `summary_short: "${emoji.repeat(280)}"` }).diagnostics).toEqual([]);
|
|
113
|
+
expectDiag(check({ summary_short: `summary_short: "${emoji.repeat(281)}"` }), 'field-invalid', 'summary_short');
|
|
114
|
+
expect(check({ title: `title: "${emoji.repeat(120)}"` }).diagnostics).toEqual([]);
|
|
115
|
+
expectDiag(check({ title: `title: "${emoji.repeat(121)}"` }), 'field-invalid', 'title');
|
|
116
|
+
});
|
|
117
|
+
test('summary_short is at most 280 characters', () => {
|
|
118
|
+
expectDiag(check({ summary_short: `summary_short: "${'x'.repeat(281)}"` }), 'field-invalid', 'summary_short');
|
|
119
|
+
});
|
|
120
|
+
test('semver must be a known level', () => {
|
|
121
|
+
expectDiag(check({ semver: 'semver: huge' }), 'field-invalid', 'semver');
|
|
122
|
+
});
|
|
123
|
+
test('modules must be sorted', () => {
|
|
124
|
+
expectDiag(check({ modules: 'modules:\n - b\n - a' }), 'field-invalid', 'modules');
|
|
125
|
+
});
|
|
126
|
+
test('modules must be unique', () => {
|
|
127
|
+
expectDiag(check({ modules: 'modules:\n - a\n - a' }), 'field-invalid', 'modules');
|
|
128
|
+
});
|
|
129
|
+
test('links.issue must be https', () => {
|
|
130
|
+
expectDiag(check({ links: 'links:\n issue: "http://insecure"' }), 'field-invalid', 'links.issue');
|
|
131
|
+
});
|
|
132
|
+
test('manual_validation must not be an empty list', () => {
|
|
133
|
+
expectDiag(check({ manual_validation: 'manual_validation: []' }), 'field-invalid', 'manual_validation');
|
|
134
|
+
});
|
|
135
|
+
test('manual_validation accepts {none: reason}', () => {
|
|
136
|
+
expect(check({ manual_validation: 'manual_validation:\n none: "docs only"' }).diagnostics).toEqual([]);
|
|
137
|
+
});
|
|
138
|
+
test('provenance timestamps must be canonical ISO-8601', () => {
|
|
139
|
+
expectDiag(check({ provenance: 'provenance:\n started_at: "2026-10-17 10:00"' }), 'field-invalid', 'provenance.started_at');
|
|
140
|
+
});
|
|
141
|
+
test('a provenance unknown key with an invalid value yields only unknown-field', () => {
|
|
142
|
+
const r = check({ provenance: 'provenance:\n host: ""' });
|
|
143
|
+
expect(r.diagnostics).toEqual([{ code: 'unknown-field', field: 'provenance.host', message: 'unknown field "provenance.host"' }]);
|
|
144
|
+
});
|
|
145
|
+
test('a provenance known key with an invalid value is field-invalid', () => {
|
|
146
|
+
const r = check({ provenance: 'provenance:\n agent: ""' });
|
|
147
|
+
expect(r.diagnostics.map((d) => d.code)).toEqual(['field-invalid']);
|
|
148
|
+
expectDiag(r, 'field-invalid', 'provenance.agent');
|
|
149
|
+
});
|
|
150
|
+
test('links.plan existence is checked when a root is given', () => {
|
|
151
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-'));
|
|
152
|
+
try {
|
|
153
|
+
expectDiag(check({ links: 'links:\n plan: docs/plans/missing.md' }, { root }), 'field-invalid', 'links.plan');
|
|
154
|
+
expectDiag(check({ links: 'links:\n plan: ../outside.md' }, { root }), 'field-invalid', 'links.plan');
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
test('links.issue must be a clean https://host URL', () => {
|
|
161
|
+
const issue = (v) => check({ links: `links:\n issue: ${v}` });
|
|
162
|
+
expect(issue('"https://github.com/Kodria/agentic-workflow/issues/217"').diagnostics).toEqual([]);
|
|
163
|
+
for (const v of ['"https://e.com/a\\nb"', '"https://e.com/a\\tb"', '" https://e.com/a"', '"https://e.com/a "', '"https:example.com"', '"https:///nohost"', '"https://e.com/a\\u2028b"', '"https://e.com/a b"']) {
|
|
164
|
+
expectDiag(issue(v), 'field-invalid', 'links.issue');
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
test('R1.4 decision: the query and the fragment of an issue URL are legitimate and are not inspected; only the userinfo is refused', () => {
|
|
168
|
+
const issue = (v) => check({ links: `links:\n issue: "${v}"` });
|
|
169
|
+
expect(issue('https://example.com/issues/1?foo=bar&label=x').diagnostics).toEqual([]);
|
|
170
|
+
expect(issue('https://example.com/issues/1#issuecomment-123').diagnostics).toEqual([]);
|
|
171
|
+
expect(issue('https://example.com/issues/1?token=abc#frag').diagnostics).toEqual([]);
|
|
172
|
+
expect(issue('https://example.com/issues/1?next=https://u:p@other.example/').diagnostics).toEqual([]);
|
|
173
|
+
expectDiag(issue('https://user:pw@example.com/issues/1?foo=bar'), 'field-invalid', 'links.issue');
|
|
174
|
+
expectDiag(issue('https://user@example.com/issues/1#frag'), 'field-invalid', 'links.issue');
|
|
175
|
+
});
|
|
176
|
+
test.each(['"https://user@example.com/1"', '"https://user:SECRET@example.com/1"', '"https://:SECRET@example.com/1"'])('R1.4 links.issue %s carries credentials and is field-invalid (a record never holds a secret)', (v) => {
|
|
177
|
+
const result = check({ links: `links:\n issue: ${v}` });
|
|
178
|
+
expectDiag(result, 'field-invalid', 'links.issue');
|
|
179
|
+
expect(JSON.stringify(result.diagnostics)).not.toContain('SECRET');
|
|
180
|
+
});
|
|
181
|
+
(process.platform === 'win32' ? test.skip : test)('links.plan cannot escape the root through a symlinked directory', () => {
|
|
182
|
+
const base = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-'));
|
|
183
|
+
const root = path_1.default.join(base, 'repo');
|
|
184
|
+
const outside = path_1.default.join(base, 'outside');
|
|
185
|
+
try {
|
|
186
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs', 'plans'), { recursive: true });
|
|
187
|
+
fs_1.default.mkdirSync(outside);
|
|
188
|
+
fs_1.default.writeFileSync(path_1.default.join(outside, 'secret.md'), 'x');
|
|
189
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plans', 'real.md'), 'x');
|
|
190
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'docs', 'link'));
|
|
191
|
+
const plan = (p) => check({ links: `links:\n plan: ${p}` }, { root });
|
|
192
|
+
expect(plan('docs/plans/real.md').diagnostics).toEqual([]);
|
|
193
|
+
expectDiag(plan('docs/link/secret.md'), 'field-invalid', 'links.plan');
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
fs_1.default.rmSync(base, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
test('links.plan accepts in-repo files whose names start with two dots', () => {
|
|
200
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-'));
|
|
201
|
+
try {
|
|
202
|
+
fs_1.default.mkdirSync(path_1.default.join(root, '..d'));
|
|
203
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '..notes.md'), 'x');
|
|
204
|
+
fs_1.default.writeFileSync(path_1.default.join(root, '..d', 'p.md'), 'x');
|
|
205
|
+
for (const plan of ['..notes.md', '..d/p.md']) {
|
|
206
|
+
expect(check({ links: `links:\n plan: ${plan}` }, { root }).diagnostics).toEqual([]);
|
|
207
|
+
}
|
|
208
|
+
expectDiag(check({ links: 'links:\n plan: ../outside.md' }, { root }), 'field-invalid', 'links.plan');
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
describe('R1.4 text safety', () => {
|
|
216
|
+
const singleLine = [
|
|
217
|
+
['title with a newline', { title: 'title: "a\\nb"' }, 'title'],
|
|
218
|
+
['title with ESC', { title: 'title: "a\\x1bb"' }, 'title'],
|
|
219
|
+
['summary_short with U+0085', { summary_short: 'summary_short: "a\\u0085b"' }, 'summary_short'],
|
|
220
|
+
['summary_short with U+2028', { summary_short: 'summary_short: "a\\u2028b"' }, 'summary_short'],
|
|
221
|
+
['summary_short with U+2029', { summary_short: 'summary_short: "a\\u2029b"' }, 'summary_short'],
|
|
222
|
+
['scope with a newline', { scope: 'scope: "a\\nb"' }, 'scope'],
|
|
223
|
+
['scope with a tab', { scope: 'scope: "a\\tb"' }, 'scope'],
|
|
224
|
+
['type with a control character', { type: 'type: "fe\\x07at"' }, 'type'],
|
|
225
|
+
['modules entry with a newline', { modules: 'modules:\n - "a\\nb"' }, 'modules'],
|
|
226
|
+
['modules entry with a leading space', { modules: 'modules:\n - " a"' }, 'modules'],
|
|
227
|
+
['modules entry with a trailing space', { modules: 'modules:\n - "a "' }, 'modules'],
|
|
228
|
+
['modules entry with a C1 character', { modules: 'modules:\n - "a\\x85b"' }, 'modules'],
|
|
229
|
+
['links.plan with a newline', { links: 'links:\n plan: "docs/a\\nb.md"' }, 'links.plan'],
|
|
230
|
+
['links.plan with a control character', { links: 'links:\n plan: "docs/a\\x1bb.md"' }, 'links.plan'],
|
|
231
|
+
['links.issue with a control character', { links: 'links:\n issue: "https://e.com/a\\x1bb"' }, 'links.issue'],
|
|
232
|
+
['provenance value with a newline', { provenance: 'provenance:\n agent: "a\\nb"' }, 'provenance.agent'],
|
|
233
|
+
['provenance value with U+2029', { provenance: 'provenance:\n model: "a\\u2029b"' }, 'provenance.model'],
|
|
234
|
+
];
|
|
235
|
+
test.each(singleLine)('single-line rule: %s is rejected', (_label, overrides, field) => {
|
|
236
|
+
expectDiag(check(overrides), 'field-invalid', field);
|
|
237
|
+
});
|
|
238
|
+
test('multi-line fields accept newline and tab', () => {
|
|
239
|
+
const r = check({
|
|
240
|
+
rollback: 'rollback: "step 1\\n\\tstep 2"',
|
|
241
|
+
risks: 'risks:\n - "a\\nb\\tc"',
|
|
242
|
+
manual_validation: 'manual_validation:\n - step: "one\\ntwo"\n expected: "x\\ty"',
|
|
243
|
+
});
|
|
244
|
+
expect(r.diagnostics).toEqual([]);
|
|
245
|
+
});
|
|
246
|
+
test.each([
|
|
247
|
+
['rollback', { rollback: 'rollback: "a\\x1bb"' }, 'rollback'],
|
|
248
|
+
['risks item', { risks: 'risks:\n - "a\\x1bb"' }, 'risks'],
|
|
249
|
+
['manual_validation step', { manual_validation: 'manual_validation:\n - step: "a\\x1bb"\n expected: "x"' }, 'manual_validation.0'],
|
|
250
|
+
['manual_validation expected', { manual_validation: 'manual_validation:\n - step: "a"\n expected: "x\\x07"' }, 'manual_validation.0'],
|
|
251
|
+
['manual_validation none', { manual_validation: 'manual_validation:\n none: "a\\x1bb"' }, 'manual_validation.none'],
|
|
252
|
+
['rollback with a carriage return', { rollback: 'rollback: "a\\rb"' }, 'rollback'],
|
|
253
|
+
])('multi-line rule: %s rejects other control characters', (_label, overrides, field) => {
|
|
254
|
+
expectDiag(check(overrides), 'field-invalid', field);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
describe('R1.5 verification block', () => {
|
|
258
|
+
test('commit must be a 40-hex SHA', () => {
|
|
259
|
+
expectDiag(check({ verification: 'verification:\n commit: abc\n overall: pass\n sensors: {}\n receipt_digest: x' }), 'field-invalid', 'verification.commit');
|
|
260
|
+
});
|
|
261
|
+
test('receipt_digest must be sha256:<64 hex>', () => {
|
|
262
|
+
expectDiag(check({ verification: 'verification:\n commit: abc\n overall: pass\n sensors: {}\n receipt_digest: x' }), 'field-invalid', 'verification.receipt_digest');
|
|
263
|
+
});
|
|
264
|
+
test('sensor status must be a known value', () => {
|
|
265
|
+
expectDiag(check({ verification: `verification:\n commit: ${SHA}\n overall: pass\n sensors:\n lint: green\n receipt_digest: ${DIGEST}` }), 'field-invalid', 'verification.sensors');
|
|
266
|
+
});
|
|
267
|
+
test('accepts free-form sensor names', () => {
|
|
268
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(' "lint:ts": pass\n "type check": skipped'))).diagnostics).toEqual([]);
|
|
269
|
+
});
|
|
270
|
+
test('rejects an empty sensor name', () => {
|
|
271
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(' "": pass'))), 'field-invalid', 'verification.sensors');
|
|
272
|
+
});
|
|
273
|
+
test('rejects a sensor name with a newline', () => {
|
|
274
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(' "a\\nb": pass'))), 'field-invalid', 'verification.sensors');
|
|
275
|
+
});
|
|
276
|
+
test('rejects a sensor name with a control character', () => {
|
|
277
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(' "a\\x07b": pass'))), 'field-invalid', 'verification.sensors');
|
|
278
|
+
});
|
|
279
|
+
test('counts sensor name length in code points', () => {
|
|
280
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(` "${'😀'.repeat(100)}": pass`))).diagnostics).toEqual([]);
|
|
281
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(` "${'😀'.repeat(128)}": pass`))).diagnostics).toEqual([]);
|
|
282
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(withSensors(` "${'😀'.repeat(129)}": pass`))), 'field-invalid', 'verification.sensors');
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
describe('R1.4 required fields and value rules', () => {
|
|
286
|
+
const REQUIRED = ['schema', 'id', 'title', 'summary_short', 'type', 'scope', 'semver', 'breaking', 'modules', 'verification', 'manual_validation', 'risks', 'rollback'];
|
|
287
|
+
test.each(REQUIRED)('a missing %s is reported by name', (key) => {
|
|
288
|
+
const r = check({ [key]: '' });
|
|
289
|
+
expect(r.record).toBeUndefined();
|
|
290
|
+
expect(r.diagnostics).toContainEqual(expect.objectContaining({ code: 'field-invalid', field: key, message: `missing required field "${key}"` }));
|
|
291
|
+
});
|
|
292
|
+
test('links and provenance are optional', () => {
|
|
293
|
+
expect(check({}).diagnostics).toEqual([]);
|
|
294
|
+
});
|
|
295
|
+
test('schema must be awm.change-record/v1', () => {
|
|
296
|
+
expectDiag(check({ schema: 'schema: awm.change-record/v2' }), 'field-invalid', 'schema');
|
|
297
|
+
});
|
|
298
|
+
test.each(['"false"', '1', 'null', '[]'])('breaking: %s is not a boolean', (v) => {
|
|
299
|
+
expectDiag(check({ breaking: `breaking: ${v}` }), 'field-invalid', 'breaking');
|
|
300
|
+
});
|
|
301
|
+
test('breaking: true is accepted', () => {
|
|
302
|
+
expect(check({ breaking: 'breaking: true' }).diagnostics).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
test('verification.overall must be a receipt verdict', () => {
|
|
305
|
+
expectDiag(check({ verification: `verification:\n commit: ${SHA}\n overall: green\n sensors: {}\n receipt_digest: ${DIGEST}` }), 'field-invalid', 'verification.overall');
|
|
306
|
+
});
|
|
307
|
+
test.each(['pass', 'fail', 'skipped', 'not_certified'])('verification.overall %s is accepted', (overall) => {
|
|
308
|
+
expect(check({ verification: `verification:\n commit: ${SHA}\n overall: ${overall}\n sensors: {}\n receipt_digest: ${DIGEST}` }).diagnostics).toEqual([]);
|
|
309
|
+
});
|
|
310
|
+
test('title of 120 characters is accepted and 121 is rejected', () => {
|
|
311
|
+
expect(check({ title: `title: "${'x'.repeat(120)}"` }).diagnostics).toEqual([]);
|
|
312
|
+
expectDiag(check({ title: `title: "${'x'.repeat(121)}"` }), 'field-invalid', 'title');
|
|
313
|
+
});
|
|
314
|
+
test('title length counts code points', () => {
|
|
315
|
+
expect(check({ title: `title: "${'😀'.repeat(120)}"` }).diagnostics).toEqual([]);
|
|
316
|
+
expectDiag(check({ title: `title: "${'😀'.repeat(121)}"` }), 'field-invalid', 'title');
|
|
317
|
+
});
|
|
318
|
+
test('title must not be blank', () => {
|
|
319
|
+
expectDiag(check({ title: 'title: " "' }), 'field-invalid', 'title');
|
|
320
|
+
});
|
|
321
|
+
test.each(['"none"', '"a"', '{}', '5'])('risks: %s is not an array', (v) => {
|
|
322
|
+
expectDiag(check({ risks: `risks: ${v}` }), 'field-invalid', 'risks');
|
|
323
|
+
});
|
|
324
|
+
test('risks items must be non-empty strings', () => {
|
|
325
|
+
expectDiag(check({ risks: 'risks:\n - ""' }), 'field-invalid', 'risks');
|
|
326
|
+
expectDiag(check({ risks: 'risks:\n - 5' }), 'field-invalid', 'risks');
|
|
327
|
+
});
|
|
328
|
+
test('risks with entries are accepted', () => {
|
|
329
|
+
expect(check({ risks: 'risks:\n - "latency"\n - "format drift"' }).diagnostics).toEqual([]);
|
|
330
|
+
});
|
|
331
|
+
test('links.plan pointing at a directory is rejected', () => {
|
|
332
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-'));
|
|
333
|
+
try {
|
|
334
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs', 'plans'), { recursive: true });
|
|
335
|
+
expectDiag(check({ links: 'links:\n plan: docs/plans' }, { root }), 'field-invalid', 'links.plan');
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
test('a complete provenance block with canonical timestamps is accepted', () => {
|
|
342
|
+
const r = check({ provenance: 'provenance:\n agent: "claude-code"\n model: "claude-sonnet-5-5"\n session: "session_01"\n started_at: "2026-10-17T10:00:00.000Z"\n finished_at: "2026-10-17T11:30:15.250Z"' });
|
|
343
|
+
expect(r.diagnostics).toEqual([]);
|
|
344
|
+
expect(r.record?.provenance?.agent).toBe('claude-code');
|
|
345
|
+
});
|
|
346
|
+
test.each(['started_at', 'finished_at'])('provenance.%s without milliseconds is rejected', (key) => {
|
|
347
|
+
expectDiag(check({ provenance: `provenance:\n ${key}: "2026-10-17T10:00:00Z"` }), 'field-invalid', `provenance.${key}`);
|
|
348
|
+
});
|
|
349
|
+
test('provenance must be a mapping', () => {
|
|
350
|
+
expectDiag(check({ provenance: 'provenance: "x"' }), 'field-invalid', 'provenance');
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
describe('R1.5 commit and receipt digest', () => {
|
|
354
|
+
const ver = (commit, digest) => check({ verification: `verification:\n commit: "${commit}"\n overall: pass\n sensors: {}\n receipt_digest: "${digest}"` });
|
|
355
|
+
test('R1.5 a 64-hex commit (a sha-256 repository) is accepted', () => {
|
|
356
|
+
expect(ver('0123456789abcdef'.repeat(4), DIGEST).diagnostics).toEqual([]);
|
|
357
|
+
});
|
|
358
|
+
test.each([['41 hex', 'a'.repeat(41)], ['63 hex', 'a'.repeat(63)], ['65 hex', 'a'.repeat(65)], ['64 hex with an uppercase letter', `A${'a'.repeat(63)}`]])('R1.5 a commit with %s is rejected', (_label, commit) => {
|
|
359
|
+
expectDiag(ver(commit, DIGEST), 'field-invalid', 'verification.commit');
|
|
360
|
+
});
|
|
361
|
+
test('a 40-hex commit is accepted', () => {
|
|
362
|
+
expect(ver('0123456789abcdef0123456789abcdef01234567', DIGEST).diagnostics).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
test.each([['39 hex', 'a'.repeat(39)], ['41 hex', 'a'.repeat(41)], ['uppercase hex', 'A'.repeat(40)], ['non-hex characters', 'g'.repeat(40)], ['empty', '']])('a commit with %s is rejected', (_label, commit) => {
|
|
365
|
+
expectDiag(ver(commit, DIGEST), 'field-invalid', 'verification.commit');
|
|
366
|
+
});
|
|
367
|
+
test('a sha256 digest is accepted', () => {
|
|
368
|
+
expect(ver(SHA, 'sha256:' + '0123456789abcdef'.repeat(4)).diagnostics).toEqual([]);
|
|
369
|
+
});
|
|
370
|
+
test.each([['63 hex', 'sha256:' + 'b'.repeat(63)], ['65 hex', 'sha256:' + 'b'.repeat(65)], ['uppercase hex', 'sha256:' + 'B'.repeat(64)], ['no sha256: prefix', 'b'.repeat(64)], ['another algorithm', 'sha1:' + 'b'.repeat(64)]])('a digest with %s is rejected', (_label, digest) => {
|
|
371
|
+
expectDiag(ver(SHA, digest), 'field-invalid', 'verification.receipt_digest');
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
describe('R1.6 body sections', () => {
|
|
375
|
+
test('a missing section is rejected', () => {
|
|
376
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), '## Qué se hizo\nA\n')), 'body-section-missing');
|
|
377
|
+
});
|
|
378
|
+
test('out-of-order sections are rejected', () => {
|
|
379
|
+
const swapped = '## Qué se probó\nB\n## Qué se hizo\nA\n## Qué falta validar\nC\n## Riesgos y rollback\nD\n';
|
|
380
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), swapped)), 'body-section-missing');
|
|
381
|
+
});
|
|
382
|
+
test('an empty section is rejected', () => {
|
|
383
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), BODY.replace('B\n', '\n'))), 'body-section-missing');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
describe('R1.7 pending markers', () => {
|
|
387
|
+
test('are rejected in the raw frontmatter', () => {
|
|
388
|
+
expectDiag(check({ rollback: `rollback: "${types_1.PENDING_MARKER}"` }), 'pending-content');
|
|
389
|
+
});
|
|
390
|
+
test('are rejected in the body', () => {
|
|
391
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, doc(fm(), BODY.replace('A', types_1.PENDING_MARKER))), 'pending-content');
|
|
392
|
+
});
|
|
393
|
+
test('are rejected after YAML unescaping, at any depth', () => {
|
|
394
|
+
const escaped = '\\x3c!-- awm:pending -->';
|
|
395
|
+
expectDiag(check({ rollback: `rollback: "${escaped}"` }), 'pending-content');
|
|
396
|
+
expectDiag(check({ risks: `risks:\n - "ok"\n - "see ${escaped}"` }), 'pending-content');
|
|
397
|
+
expectDiag(check({ manual_validation: `manual_validation:\n - step: "${escaped}"\n expected: "x"` }), 'pending-content');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
describe('R1.8 bounds', () => {
|
|
401
|
+
test('rejects oversize files before parsing', () => {
|
|
402
|
+
const big = doc(fm()) + 'x'.repeat(types_1.MAX_RECORD_BYTES);
|
|
403
|
+
expect(codes((0, validate_shape_1.validateRecordShape)(NAME, big))).toEqual(['record-too-large']);
|
|
404
|
+
});
|
|
405
|
+
test('caps diagnostics at 50, the last being diagnostics-truncated', () => {
|
|
406
|
+
const keys = Array.from({ length: 6000 }, (_v, i) => `k${i}: 1`).join('\n');
|
|
407
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\n${keys}`));
|
|
408
|
+
expect(r.diagnostics.length).toBeLessThanOrEqual(50);
|
|
409
|
+
expect(r.diagnostics[r.diagnostics.length - 1].code).toBe('diagnostics-truncated');
|
|
410
|
+
expect(r.diagnostics[0].code).toBe('unknown-field');
|
|
411
|
+
});
|
|
412
|
+
test('reports how many diagnostics the cap dropped, so that kept + omitted is the total', () => {
|
|
413
|
+
const keys = Array.from({ length: 6000 }, (_v, i) => `k${i}: 1`).join('\n');
|
|
414
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\n${keys}`));
|
|
415
|
+
expect(r.omitted).toBe(6000 - 49);
|
|
416
|
+
expect(r.diagnostics.length - 1 + (r.omitted ?? 0)).toBe(6000);
|
|
417
|
+
});
|
|
418
|
+
test('reports exactly 51 diagnostics as 49 kept and 2 omitted', () => {
|
|
419
|
+
const keys = Array.from({ length: 51 }, (_v, i) => `k${i}: 1`).join('\n');
|
|
420
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\n${keys}`)).omitted).toBe(2);
|
|
421
|
+
});
|
|
422
|
+
test('has no omitted count when the cap was not reached', () => {
|
|
423
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\nk1: 1`))).not.toHaveProperty('omitted');
|
|
424
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(fm()))).not.toHaveProperty('omitted');
|
|
425
|
+
});
|
|
426
|
+
test('does not add diagnostics-truncated when 50 or fewer diagnostics exist', () => {
|
|
427
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\nk1: 1\nk2: 1`));
|
|
428
|
+
expect(codes(r)).toEqual(['unknown-field', 'unknown-field']);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
test('accepts CRLF line endings', () => {
|
|
432
|
+
expect((0, validate_shape_1.validateRecordShape)(NAME, doc(fm()).replace(/\n/g, '\r\n')).diagnostics).toEqual([]);
|
|
433
|
+
});
|
|
434
|
+
describe('hostile input never throws', () => {
|
|
435
|
+
test.each([
|
|
436
|
+
['undefined raw', NAME, undefined],
|
|
437
|
+
['Buffer raw', NAME, Buffer.from(doc(fm()))],
|
|
438
|
+
['number raw', NAME, 42],
|
|
439
|
+
['null raw', NAME, null],
|
|
440
|
+
['undefined fileName', undefined, doc(fm())],
|
|
441
|
+
['number fileName', 7, doc(fm())],
|
|
442
|
+
['Buffer fileName', Buffer.from(NAME), doc(fm())],
|
|
443
|
+
])('%s yields input-invalid', (_label, name, raw) => {
|
|
444
|
+
let r;
|
|
445
|
+
expect(() => { r = (0, validate_shape_1.validateRecordShape)(name, raw); }).not.toThrow();
|
|
446
|
+
expect(codes(r)).toEqual(['input-invalid']);
|
|
447
|
+
expect(r.record).toBeUndefined();
|
|
448
|
+
});
|
|
449
|
+
test('YAML nesting deeper than 100 levels yields a diagnostic', () => {
|
|
450
|
+
for (const depth of [101, 200, 30000]) {
|
|
451
|
+
const r = check({ title: `title: ${'['.repeat(depth)}${']'.repeat(depth)}` });
|
|
452
|
+
expectDiag(r, 'yaml-invalid');
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
test('duplicate keys yield yaml-invalid', () => {
|
|
456
|
+
expectDiag(check({ extra: 'title: "again"' }), 'yaml-invalid');
|
|
457
|
+
});
|
|
458
|
+
test.each([['a sequence', '- a\n- b'], ['a scalar', 'just text'], ['an empty block', ''], ['comments only', '# nothing']])('frontmatter that is %s yields not-an-object', (_label, front) => {
|
|
459
|
+
expectDiag((0, validate_shape_1.validateRecordShape)(NAME, `---\n${front}\n---\n${BODY}`), 'not-an-object');
|
|
460
|
+
});
|
|
461
|
+
test('hostile unknown keys are escaped and bounded in field and message', () => {
|
|
462
|
+
const r = check({ extra: 'k: 1\n"x\\ny\\x1b[31mz": 1\n"' + 'L'.repeat(1000) + '": 1' });
|
|
463
|
+
const unknown = r.diagnostics.filter((d) => d.code === 'unknown-field');
|
|
464
|
+
expect(unknown.length).toBe(3);
|
|
465
|
+
for (const d of unknown) {
|
|
466
|
+
expect(d.message).not.toMatch(UNSAFE);
|
|
467
|
+
expect(d.field).toBeDefined();
|
|
468
|
+
expect(d.field).not.toMatch(UNSAFE);
|
|
469
|
+
expect(d.field.length).toBeLessThan(120);
|
|
470
|
+
expect(d.message.length).toBeLessThan(250);
|
|
471
|
+
}
|
|
472
|
+
expect(unknown[1].field).toContain('x\\ny\\u001b[31mz');
|
|
473
|
+
});
|
|
474
|
+
test('a hostile file name is escaped in the name-invalid message', () => {
|
|
475
|
+
const r = (0, validate_shape_1.validateRecordShape)('bad\n\x1b[31m' + 'N'.repeat(500) + '.md', doc(fm()));
|
|
476
|
+
const d = r.diagnostics.find((x) => x.code === 'name-invalid');
|
|
477
|
+
expect(d).toBeDefined();
|
|
478
|
+
expect(d.message).not.toMatch(UNSAFE);
|
|
479
|
+
expect(d.message.length).toBeLessThan(250);
|
|
480
|
+
});
|
|
481
|
+
test('a hostile links.plan is escaped in its diagnostic', () => {
|
|
482
|
+
const root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-rec-'));
|
|
483
|
+
try {
|
|
484
|
+
const r = check({ links: 'links:\n plan: "docs/' + 'p'.repeat(500) + '.md"' }, { root });
|
|
485
|
+
const d = r.diagnostics.find((x) => x.field === 'links.plan');
|
|
486
|
+
expect(d).toBeDefined();
|
|
487
|
+
expect(d.message.length).toBeLessThan(250);
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
test('no diagnostic message is longer than 300 characters, ellipsis included', () => {
|
|
494
|
+
const r = (0, validate_shape_1.validateRecordShape)('\u0001'.repeat(80) + '.md', doc(fm()));
|
|
495
|
+
expect(r.diagnostics.length).toBeGreaterThan(0);
|
|
496
|
+
for (const d of r.diagnostics)
|
|
497
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
498
|
+
const long = (0, validate_shape_1.validateRecordShape)(NAME, `---\na: *${'"\\'.repeat(200)}\n---\n${BODY}`);
|
|
499
|
+
expect(codes(long)).toContain('yaml-invalid');
|
|
500
|
+
for (const d of long.diagnostics)
|
|
501
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
502
|
+
const clipped = long.diagnostics.find((d) => d.code === 'yaml-invalid');
|
|
503
|
+
expect(clipped?.message.length).toBe(300);
|
|
504
|
+
expect(clipped?.message.endsWith('…')).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
test('clipping never splits a surrogate pair', () => {
|
|
507
|
+
const lone = /[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
|
|
508
|
+
for (const pad of ['', 'x']) {
|
|
509
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, `---\na: *${pad}${'😀'.repeat(300)}\n---\n${BODY}`);
|
|
510
|
+
const d = r.diagnostics.find((x) => x.code === 'yaml-invalid');
|
|
511
|
+
expect(d).toBeDefined();
|
|
512
|
+
expect(d.message.length).toBeLessThanOrEqual(300);
|
|
513
|
+
expect(d.message).not.toMatch(lone);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
test('escaping a hostile key never leaves a lone surrogate in message or field', () => {
|
|
517
|
+
const lone = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
518
|
+
const keyed = (controls, pad) => `"${'\\u0001'.repeat(controls)}${'a'.repeat(pad)}😀bbbb": 1`;
|
|
519
|
+
const run = (controls, pad) => {
|
|
520
|
+
const r = (0, validate_shape_1.validateRecordShape)(NAME, doc(`${fm()}\n${keyed(controls, pad)}`));
|
|
521
|
+
const unknown = r.diagnostics.filter((d) => d.code === 'unknown-field');
|
|
522
|
+
expect(unknown).toHaveLength(1);
|
|
523
|
+
expect(unknown[0].message).not.toMatch(lone);
|
|
524
|
+
expect(unknown[0].field).not.toMatch(lone);
|
|
525
|
+
expect(unknown[0].message.length).toBeLessThanOrEqual(300);
|
|
526
|
+
};
|
|
527
|
+
run(39, 5);
|
|
528
|
+
for (let controls = 30; controls <= 44; controls++)
|
|
529
|
+
for (let pad = 0; pad <= 7; pad++)
|
|
530
|
+
run(controls, pad);
|
|
531
|
+
});
|
|
532
|
+
test('a short message is left unchanged', () => {
|
|
533
|
+
const r = check({ rollback: 'rollback: ""' });
|
|
534
|
+
expect(r.diagnostics).toContainEqual({ code: 'field-invalid', field: 'rollback', message: 'rollback must be a non-empty string without control characters' });
|
|
535
|
+
});
|
|
536
|
+
test('every diagnostic of a heavily hostile record is single-line', () => {
|
|
537
|
+
const hostile = '"\\x1b\\n\\u2028\\u0085"';
|
|
538
|
+
const r = check({
|
|
539
|
+
title: `title: ${hostile}`, scope: `scope: ${hostile}`, type: `type: ${hostile}`,
|
|
540
|
+
provenance: `provenance:\n ${hostile}: 1\n agent: ${hostile}`,
|
|
541
|
+
verification: `verification:\n commit: ${hostile}\n overall: ${hostile}\n sensors:\n ${hostile}: ${hostile}\n receipt_digest: ${hostile}\n ${hostile}: 1`,
|
|
542
|
+
});
|
|
543
|
+
expect(r.diagnostics.length).toBeGreaterThan(5);
|
|
544
|
+
for (const d of r.diagnostics) {
|
|
545
|
+
expect(`${d.message}${d.field ?? ''}`).not.toMatch(UNSAFE);
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
});
|
|
549
|
+
});
|