agentic-workflow-manager 9.18.0 → 9.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/change/index.js +492 -0
- package/dist/src/commands/changes/index.js +84 -0
- package/dist/src/commands/plan/index.js +1 -1
- package/dist/src/commands/sensors/index.js +9 -0
- package/dist/src/commands/sensors/read-only-observation.js +305 -0
- package/dist/src/commands/sensors/run.js +24 -60
- package/dist/src/core/admission/index.js +11 -1
- 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/currentness/check.js +5 -2
- 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/sensors/index.test.js +1 -1
- package/dist/tests/commands/sensors/read-only-observation.test.js +537 -0
- package/dist/tests/commands/sensors/run-read-only.test.js +151 -0
- package/dist/tests/core/admission/index.test.js +60 -0
- 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/currentness/check-insteadof.test.js +120 -0
- package/dist/tests/core/currentness/check.test.js +75 -14
- 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 +77 -0
- package/dist/tests/structural/cloud-admission-docs-contract.test.js +54 -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,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const text_safety_1 = require("../../../src/core/changes/text-safety");
|
|
4
|
+
const LINE_BREAKS = ['\r', '\n', '\u0085', '
', '
'];
|
|
5
|
+
describe('isSingleLine', () => {
|
|
6
|
+
test.each(['', 'plain', 'with spaces', 'ñandú', '😀', 'a/b:c'])('accepts %j', (t) => {
|
|
7
|
+
expect((0, text_safety_1.isSingleLine)(t)).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
test.each([...LINE_BREAKS, '\t', '\u0000', '\u001b', '\u007f', '\u0080', '\u009f'])('rejects a string containing %j', (c) => {
|
|
10
|
+
expect((0, text_safety_1.isSingleLine)(`a${c}b`)).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
test.each([undefined, null, 1, {}, []])('rejects the non-string %j', (v) => {
|
|
13
|
+
expect((0, text_safety_1.isSingleLine)(v)).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('isSafeMultiline', () => {
|
|
17
|
+
test('accepts newline, tab, U+2028 and plain text', () => {
|
|
18
|
+
expect((0, text_safety_1.isSafeMultiline)('line 1\n\tline 2
x')).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
test.each(['\r', '\u0085', '\u0000', '\u001b', '\u007f', '\u0080', '\u009f'])('rejects a string containing %j', (c) => {
|
|
21
|
+
expect((0, text_safety_1.isSafeMultiline)(`a${c}b`)).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
test.each([undefined, null, 1, {}, []])('rejects the non-string %j', (v) => {
|
|
24
|
+
expect((0, text_safety_1.isSafeMultiline)(v)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
const LONE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
28
|
+
describe('sliceNoSplit', () => {
|
|
29
|
+
test('returns short text unchanged', () => {
|
|
30
|
+
expect((0, text_safety_1.sliceNoSplit)('abc', 3)).toBe('abc');
|
|
31
|
+
expect((0, text_safety_1.sliceNoSplit)('', 3)).toBe('');
|
|
32
|
+
});
|
|
33
|
+
test('cuts at the given number of UTF-16 units', () => {
|
|
34
|
+
expect((0, text_safety_1.sliceNoSplit)('abcdef', 4)).toBe('abcd');
|
|
35
|
+
});
|
|
36
|
+
test('drops a trailing high surrogate instead of splitting the pair', () => {
|
|
37
|
+
expect((0, text_safety_1.sliceNoSplit)('ab😀cd', 3)).toBe('ab');
|
|
38
|
+
expect((0, text_safety_1.sliceNoSplit)('ab😀cd', 4)).toBe('ab😀');
|
|
39
|
+
expect((0, text_safety_1.sliceNoSplit)('😀😀', 1)).toBe('');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('escapeText', () => {
|
|
43
|
+
test('JSON-escapes quotes, backslashes and controls', () => {
|
|
44
|
+
expect((0, text_safety_1.escapeText)('a"b\\c\nd\x1b')).toBe('a\\"b\\\\c\\nd\\u001b');
|
|
45
|
+
});
|
|
46
|
+
test('escapes the line breaks JSON leaves alone', () => {
|
|
47
|
+
expect((0, text_safety_1.escapeText)('\u0085\u2028\u2029')).toBe('\\u0085\\u2028\\u2029');
|
|
48
|
+
});
|
|
49
|
+
test('escapes lone surrogates', () => {
|
|
50
|
+
expect((0, text_safety_1.escapeText)('\ud800')).not.toMatch(LONE);
|
|
51
|
+
});
|
|
52
|
+
test('does not clip', () => {
|
|
53
|
+
expect((0, text_safety_1.escapeText)('x'.repeat(1000))).toHaveLength(1000);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('esc', () => {
|
|
57
|
+
test('leaves ordinary short text unchanged', () => {
|
|
58
|
+
expect((0, text_safety_1.esc)('status')).toBe('status');
|
|
59
|
+
});
|
|
60
|
+
test('clips to 80 code points with an ellipsis', () => {
|
|
61
|
+
expect((0, text_safety_1.esc)('y'.repeat(81))).toBe(`${'y'.repeat(80)}…`);
|
|
62
|
+
});
|
|
63
|
+
test('bounds the escaped length and never splits a surrogate pair', () => {
|
|
64
|
+
for (let pad = 0; pad < 4; pad++) {
|
|
65
|
+
const out = (0, text_safety_1.esc)(`${'\u0001'.repeat(39)}${'a'.repeat(pad)}😀bbbb`);
|
|
66
|
+
expect(out.length).toBeLessThanOrEqual(241);
|
|
67
|
+
expect(out).not.toMatch(LONE);
|
|
68
|
+
expect(out).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('clipMessage', () => {
|
|
73
|
+
test('leaves messages up to the limit unchanged', () => {
|
|
74
|
+
expect((0, text_safety_1.clipMessage)('x'.repeat(text_safety_1.MAX_MESSAGE))).toBe('x'.repeat(text_safety_1.MAX_MESSAGE));
|
|
75
|
+
});
|
|
76
|
+
test('clips to the limit including the ellipsis', () => {
|
|
77
|
+
const out = (0, text_safety_1.clipMessage)('x'.repeat(text_safety_1.MAX_MESSAGE + 50));
|
|
78
|
+
expect(out).toHaveLength(text_safety_1.MAX_MESSAGE);
|
|
79
|
+
expect(out.endsWith('…')).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
test('never splits a surrogate pair', () => {
|
|
82
|
+
for (const pad of ['', 'x']) {
|
|
83
|
+
const out = (0, text_safety_1.clipMessage)(`${pad}${'😀'.repeat(300)}`);
|
|
84
|
+
expect(out.length).toBeLessThanOrEqual(text_safety_1.MAX_MESSAGE);
|
|
85
|
+
expect(out).not.toMatch(LONE);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('every export tolerates non-string text without throwing', () => {
|
|
90
|
+
const nonStrings = [['undefined', undefined], ['null', null], ['42', 42], ['{}', {}], ['[]', []], ['a Buffer', Buffer.from('x')], ['NaN', NaN]];
|
|
91
|
+
test.each(nonStrings)('esc, escapeText, cleanText and clipMessage map %s to the placeholder', (_label, value) => {
|
|
92
|
+
expect(() => (0, text_safety_1.esc)(value)).not.toThrow();
|
|
93
|
+
expect((0, text_safety_1.esc)(value)).toBe(text_safety_1.NON_STRING);
|
|
94
|
+
expect((0, text_safety_1.escapeText)(value)).toBe(text_safety_1.NON_STRING);
|
|
95
|
+
expect((0, text_safety_1.cleanText)(value)).toBe(text_safety_1.NON_STRING);
|
|
96
|
+
expect((0, text_safety_1.clipMessage)(value)).toBe(text_safety_1.NON_STRING);
|
|
97
|
+
});
|
|
98
|
+
test.each(nonStrings)('sliceNoSplit returns an empty string for text %s', (_label, value) => {
|
|
99
|
+
expect(() => (0, text_safety_1.sliceNoSplit)(value, 5)).not.toThrow();
|
|
100
|
+
expect((0, text_safety_1.sliceNoSplit)(value, 5)).toBe('');
|
|
101
|
+
});
|
|
102
|
+
test.each(nonStrings)('isSingleLine and isSafeMultiline are false for %s', (_label, value) => {
|
|
103
|
+
expect((0, text_safety_1.isSingleLine)(value)).toBe(false);
|
|
104
|
+
expect((0, text_safety_1.isSafeMultiline)(value)).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
test('the placeholder is itself single-line text', () => {
|
|
107
|
+
expect((0, text_safety_1.isSingleLine)(text_safety_1.NON_STRING)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('sliceNoSplit with a bad limit never throws and never over-returns', () => {
|
|
111
|
+
test.each([['NaN', NaN], ['-1', -1], ['-Infinity', -Infinity], ['Infinity', Infinity], ['0', 0], ['0.9', 0.9], ['undefined', undefined], ['null', null], ['a string', '3']])('maxUnits %s yields an empty string', (_label, limit) => {
|
|
112
|
+
expect(() => (0, text_safety_1.sliceNoSplit)('abc', limit)).not.toThrow();
|
|
113
|
+
expect((0, text_safety_1.sliceNoSplit)('abc', limit)).toBe('');
|
|
114
|
+
});
|
|
115
|
+
test('a fractional limit is floored', () => {
|
|
116
|
+
expect((0, text_safety_1.sliceNoSplit)('abcdef', 2.9)).toBe('ab');
|
|
117
|
+
});
|
|
118
|
+
test('a whole-number limit is unchanged', () => {
|
|
119
|
+
expect((0, text_safety_1.sliceNoSplit)('abcdef', 6)).toBe('abcdef');
|
|
120
|
+
expect((0, text_safety_1.sliceNoSplit)('abcdef', 100)).toBe('abcdef');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
describe('isSafeRelativePath', () => {
|
|
124
|
+
test.each(['a', 'a/b', 'docs/changes', '.awm/journal', 'src/file name.ts', 'ñandú/x', '.hidden/..dots', 'a'.repeat(1024)])('accepts %j', (p) => {
|
|
125
|
+
expect((0, text_safety_1.isSafeRelativePath)(p)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
test.each([
|
|
128
|
+
['empty', ''], ['dot', '.'], ['dot-dot', '..'], ['parent segment', '../a'], ['inner parent', 'a/../b'], ['dot segment', 'a/./b'], ['empty segment', 'a//b'],
|
|
129
|
+
['trailing slash', 'a/'], ['absolute', '/a'], ['drive letter', 'C:/a'], ['drive letter, no slash', 'C:a'], ['backslash', 'a\\b'],
|
|
130
|
+
['leading space', ' a'], ['trailing space', 'a '], ['leading tab', '\ta'], ['newline', 'a\nb'], ['carriage return', 'a\rb'], ['NUL', 'a\u0000b'],
|
|
131
|
+
['ESC', 'a\u001bb'], ['DEL', 'a\u007fb'], ['C1 control', 'a\u0085b'], ['line separator', 'a\u2028b'], ['paragraph separator', 'a\u2029b'],
|
|
132
|
+
['over-length', 'a'.repeat(1025)], ['trailing no-break space', 'a\u00a0'],
|
|
133
|
+
])('rejects %s', (_label, p) => {
|
|
134
|
+
expect((0, text_safety_1.isSafeRelativePath)(p)).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
test.each([[undefined], [null], [7], [{}], [['a']], [true]])('rejects the non-string %p without throwing', (p) => {
|
|
137
|
+
expect((0, text_safety_1.isSafeRelativePath)(p)).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('isObj', () => {
|
|
141
|
+
test.each([[{}], [{ a: 1 }], [Object.create(null)]])('accepts the object %j', (v) => {
|
|
142
|
+
expect((0, text_safety_1.isObj)(v)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
test.each([[null], [undefined], [[]], [[1]], ['x'], [1], [true]])('rejects %j', (v) => {
|
|
145
|
+
expect((0, text_safety_1.isObj)(v)).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('isWellFormed', () => {
|
|
149
|
+
test.each(['', 'plain', 'ñandú', '😀'])('accepts %j', (t) => {
|
|
150
|
+
expect((0, text_safety_1.isWellFormed)(t)).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
test.each(['a\ud800b', '\udc00', 'a\ud83d'])('rejects a lone surrogate in %j', (t) => {
|
|
153
|
+
expect((0, text_safety_1.isWellFormed)(t)).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
test.each([[undefined], [null], [1]])('rejects the non-string %p', (v) => {
|
|
156
|
+
expect((0, text_safety_1.isWellFormed)(v)).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
describe('isHttpsUrl', () => {
|
|
160
|
+
const lenient = { allowCredentials: true };
|
|
161
|
+
const strict = { allowCredentials: false, maxLength: 2048 };
|
|
162
|
+
test.each(['https://github.com/o/r/issues/1', 'https://example.com', 'https://example.com:8443/a?b=c#d', 'https://[::1]/x'])('accepts %j in both modes', (u) => {
|
|
163
|
+
expect((0, text_safety_1.isHttpsUrl)(u, lenient)).toBe(true);
|
|
164
|
+
expect((0, text_safety_1.isHttpsUrl)(u, strict)).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
test.each([
|
|
167
|
+
['http', 'http://example.com'], ['other scheme', 'javascript:alert(1)'], ['upper-case scheme', 'HTTPS://example.com'], ['no authority', 'https://'],
|
|
168
|
+
['empty host before a path', 'https:///a'], ['query first', 'https://?a'], ['whitespace', 'https://example.com/a b'], ['a line break', 'https://example.com/a\nb'],
|
|
169
|
+
['a backslash first', 'https://\\example.com'], ['an at-sign first', 'https://@example.com'], ['plain text', 'not a url'], ['empty', ''],
|
|
170
|
+
])('rejects %s in both modes', (_name, u) => {
|
|
171
|
+
expect((0, text_safety_1.isHttpsUrl)(u, lenient)).toBe(false);
|
|
172
|
+
expect((0, text_safety_1.isHttpsUrl)(u, strict)).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
test('credentials are accepted only when allowed', () => {
|
|
175
|
+
expect((0, text_safety_1.isHttpsUrl)('https://user:pw@example.com/1', lenient)).toBe(true);
|
|
176
|
+
expect((0, text_safety_1.isHttpsUrl)('https://user:pw@example.com/1', strict)).toBe(false);
|
|
177
|
+
expect((0, text_safety_1.isHttpsUrl)('https://user@example.com/1', strict)).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
test('the length limit applies only when given', () => {
|
|
180
|
+
const long = `https://example.com/${'a'.repeat(3000)}`;
|
|
181
|
+
expect((0, text_safety_1.isHttpsUrl)(long, lenient)).toBe(true);
|
|
182
|
+
expect((0, text_safety_1.isHttpsUrl)(long, strict)).toBe(false);
|
|
183
|
+
expect((0, text_safety_1.isHttpsUrl)(`https://example.com/${'a'.repeat(2048 - 20)}`, strict)).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
test.each([[undefined], [null], [1], [{}]])('rejects the non-string %p without throwing', (v) => {
|
|
186
|
+
expect((0, text_safety_1.isHttpsUrl)(v, lenient)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
describe('isCanonicalIso', () => {
|
|
190
|
+
test.each(['2026-10-17T12:00:00.000Z', '1970-01-01T00:00:00.000Z', '2024-02-29T23:59:59.999Z'])('accepts %j', (v) => {
|
|
191
|
+
expect((0, text_safety_1.isCanonicalIso)(v)).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
test.each([
|
|
194
|
+
['a date only', '2026-10-17'], ['no milliseconds', '2026-10-17T12:00:00Z'], ['an offset', '2026-10-17T12:00:00.000+00:00'], ['lower-case z', '2026-10-17T12:00:00.000z'],
|
|
195
|
+
['an impossible day', '2026-02-30T00:00:00.000Z'], ['padding', ' 2026-10-17T12:00:00.000Z'], ['text', 'yesterday'], ['empty', ''],
|
|
196
|
+
])('rejects %s', (_n, v) => {
|
|
197
|
+
expect((0, text_safety_1.isCanonicalIso)(v)).toBe(false);
|
|
198
|
+
});
|
|
199
|
+
test.each([[undefined], [null], [0], [{}], [new Date(0)], [Symbol('x')], [{ toString: () => { throw new Error('boom'); } }], [{ toString: () => '2026-10-17T12:00:00.000Z' }]])('rejects the non-string %p without throwing', (v) => {
|
|
200
|
+
expect((0, text_safety_1.isCanonicalIso)(v)).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
// Bidirectional controls reorder what a terminal shows around them: every echo of untrusted text escapes them, the same way everywhere.
|
|
204
|
+
const BIDI_CHARS = ['\u061c', '\u200e', '\u200f', '\u202a', '\u202b', '\u202c', '\u202d', '\u202e', '\u2066', '\u2067', '\u2068', '\u2069'];
|
|
205
|
+
const hex = (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`;
|
|
206
|
+
describe('bidi controls are escaped by the shared helpers', () => {
|
|
207
|
+
test.each(BIDI_CHARS)('escapeBidi escapes %j', (c) => {
|
|
208
|
+
expect((0, text_safety_1.escapeBidi)(`a${c}b`)).toBe(`a${hex(c)}b`);
|
|
209
|
+
});
|
|
210
|
+
test('escapeBidi leaves other text alone', () => {
|
|
211
|
+
expect((0, text_safety_1.escapeBidi)('plain \u00e9 \u6f22 \u{1F600}')).toBe('plain \u00e9 \u6f22 \u{1F600}');
|
|
212
|
+
});
|
|
213
|
+
test.each([...BIDI_CHARS, '\u2028', '\u2029'])('escapeText, cleanText and esc escape %j', (c) => {
|
|
214
|
+
expect((0, text_safety_1.escapeText)(`a${c}b`)).toBe(`a${hex(c)}b`);
|
|
215
|
+
expect((0, text_safety_1.cleanText)(`a${c}b`)).toBe(`a${hex(c)}b`);
|
|
216
|
+
expect((0, text_safety_1.esc)(`a${c}b`)).toBe(`a${hex(c)}b`);
|
|
217
|
+
});
|
|
218
|
+
test('the escaped output of every helper holds no bidi control and no raw line separator', () => {
|
|
219
|
+
const hostile = `${BIDI_CHARS.join('')}\u2028\u2029x`;
|
|
220
|
+
for (const out of [(0, text_safety_1.escapeText)(hostile), (0, text_safety_1.cleanText)(hostile), (0, text_safety_1.esc)(hostile)])
|
|
221
|
+
expect(out).not.toMatch(/[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069\u2028\u2029]/);
|
|
222
|
+
});
|
|
223
|
+
test('they are still valid text for the predicates: isSingleLine is about controls and line breaks, not bidi', () => {
|
|
224
|
+
expect((0, text_safety_1.isSingleLine)('a\u202eb')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
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 helpers_1 = require("./helpers");
|
|
9
|
+
const trailers_1 = require("../../../src/core/changes/trailers");
|
|
10
|
+
describe('hasOptOut', () => {
|
|
11
|
+
let repo;
|
|
12
|
+
let template;
|
|
13
|
+
beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
|
|
14
|
+
afterAll(() => template.cleanup());
|
|
15
|
+
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
16
|
+
afterEach(() => repo.cleanup());
|
|
17
|
+
const branch = () => (0, helpers_1.branchOff)(repo);
|
|
18
|
+
const noNewFile = (name) => expect(fs_1.default.existsSync(path_1.default.join(repo.root, name))).toBe(false);
|
|
19
|
+
const nonRepoDir = () => (0, helpers_1.nonRepoDir)(repo);
|
|
20
|
+
test('detects Change-Record: none in a range', () => {
|
|
21
|
+
const base = branch();
|
|
22
|
+
repo.commit({ 'a.ts': '1' }, 'docs: typo');
|
|
23
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
24
|
+
repo.commit({ 'b.ts': '1' }, 'docs: typo2\n\nChange-Record: none');
|
|
25
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
test('the key and the value match case-insensitively', () => {
|
|
28
|
+
const base = branch();
|
|
29
|
+
repo.commit({ 'a.ts': '1' }, 'docs: typo\n\nchange-record: NONE ');
|
|
30
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
test('another value, another key or a longer key is not an opt-out', () => {
|
|
33
|
+
const base = branch();
|
|
34
|
+
repo.commit({ 'a.ts': '1' }, 'docs: a\n\nChange-Record: pending');
|
|
35
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Records: none');
|
|
36
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c\n\nX-Change-Record: none');
|
|
37
|
+
repo.commit({ 'd.ts': '1' }, 'docs: d\n\nChange-Record: none-ish');
|
|
38
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
test('DD-6 documented limit: a trailer in a mid-message paragraph (a squash-merge body) is NOT an opt-out; only the final paragraph counts', () => {
|
|
41
|
+
const base = branch();
|
|
42
|
+
// What a squash merge produces: the squashed commits' messages become the body, so the author's trailer is not last.
|
|
43
|
+
repo.commit({ 'a.ts': '1' }, 'docs: typo (#12)\n\n* docs: typo\n\nChange-Record: none\n\n* fix: other');
|
|
44
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
45
|
+
// The same trailer as the final paragraph (the author edited the squash message) is found.
|
|
46
|
+
repo.commit({ 'b.ts': '1' }, 'docs: typo (#13)\n\n* docs: typo\n\nChange-Record: none');
|
|
47
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
test('an opt-out is found among several trailers and among several commits', () => {
|
|
50
|
+
const base = branch();
|
|
51
|
+
repo.commit({ 'a.ts': '1' }, 'docs: a');
|
|
52
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: pending\nChange-Record: none');
|
|
53
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c');
|
|
54
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
test('trailer.* configuration cannot turn another separator or token into an opt-out', () => {
|
|
57
|
+
const base = branch();
|
|
58
|
+
repo.git('config', 'trailer.separators', ':=#');
|
|
59
|
+
repo.git('config', 'trailer.cr.key', 'Change-Record');
|
|
60
|
+
repo.commit({ 'a.ts': '1' }, 'docs: a\n\nChange-Record= none');
|
|
61
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nCR: none');
|
|
62
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c\n\nChange-Record# none');
|
|
63
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
64
|
+
repo.commit({ 'd.ts': '1' }, 'docs: d\n\nChange-Record: none');
|
|
65
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
test('the value must be exactly none after optional blanks, in the final paragraph', () => {
|
|
68
|
+
branch();
|
|
69
|
+
repo.commit({ 'a.ts': '1' }, 'docs: a\n\nChange-Record:none');
|
|
70
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(true);
|
|
71
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record:\t none \t');
|
|
72
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(true);
|
|
73
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c\n\nSigned-off-by: t\nChange-Record: none');
|
|
74
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(true);
|
|
75
|
+
for (const message of ['docs: d\n\nChange-Record: nonesuch', 'docs: e\n\nChange-Record: none of it', 'docs: f\n\nChange-Record:', 'docs: g\n\nChange-Record:\n none', 'docs: h\n\n Change-Record: none', 'docs: i\n\nChange-Record: none\u00a0', 'docs: j\n\nChange-Record: none\u2028', 'docs: k\n\nChange-Record: none\u000b']) {
|
|
76
|
+
repo.commit({ [`${message[6]}.ts`]: '1' }, message);
|
|
77
|
+
expect([message, (0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])]).toEqual([message, false]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test('text that is not in the trailer block does not count', () => {
|
|
81
|
+
const base = branch();
|
|
82
|
+
repo.commit({ 'a.ts': '1' }, 'Change-Record: none');
|
|
83
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: none\n\nand then more prose after it');
|
|
84
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
test('a trailer value cannot forge none through folding or separators', () => {
|
|
87
|
+
const base = branch();
|
|
88
|
+
repo.commit({ 'a.ts': '1' }, 'docs: a\n\nChange-Record: other\n none');
|
|
89
|
+
repo.commit({ 'b.ts': '1' }, 'docs: b\n\nChange-Record: other\u001fnone');
|
|
90
|
+
repo.commit({ 'c.ts': '1' }, 'docs: c\n\nChange-Record: other\u001enone');
|
|
91
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
test('only the commits selected by the revisions are examined', () => {
|
|
94
|
+
repo.commit({ 'a.ts': '1' }, 'docs: early\n\nChange-Record: none');
|
|
95
|
+
const base = branch();
|
|
96
|
+
const tip = repo.commit({ 'b.ts': '1' }, 'docs: plain');
|
|
97
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(false);
|
|
98
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', tip])).toBe(false);
|
|
99
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [base])).toBe(true);
|
|
100
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', base])).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
test.each([
|
|
103
|
+
[['--output=x']], [['--all']], [['-1', '--all']], [['-1', '-2', 'HEAD']], [['HEAD~1']], [['HEAD^']], [['HEAD@{1}']], [['-x', 'HEAD']], [['a\nb']], [['HEAD\u0000']], [[]], [['-1']], [(0, helpers_1.bad)('HEAD')], [(0, helpers_1.bad)(null)], [(0, helpers_1.bad)([7])], [(0, helpers_1.bad)([null])], [['']], [['-99999999', 'HEAD']],
|
|
104
|
+
])('hostile revisions %j are rejected as no opt-out', (revArgs) => {
|
|
105
|
+
repo.commit({ 'z.ts': '1' }, 'docs: z\n\nChange-Record: none');
|
|
106
|
+
repo.commit({ 'y.ts': '1' }, 'docs: y');
|
|
107
|
+
expect((0, trailers_1.hasOptOut)(repo.root, revArgs)).toBe(false);
|
|
108
|
+
noNewFile('x');
|
|
109
|
+
});
|
|
110
|
+
test('option injection through the revisions creates no file', () => {
|
|
111
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['--output=injected.txt', 'HEAD'])).toBe(false);
|
|
112
|
+
noNewFile('injected.txt');
|
|
113
|
+
});
|
|
114
|
+
test('a non-string root or a non-repository is no opt-out', () => {
|
|
115
|
+
expect((0, trailers_1.hasOptOut)((0, helpers_1.bad)(null), ['HEAD'])).toBe(false);
|
|
116
|
+
expect((0, trailers_1.hasOptOut)(nonRepoDir(), ['HEAD'])).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
test('hasOptOut sees a signed Change-Record: none commit even with log.showSignature on', () => {
|
|
119
|
+
const base = branch();
|
|
120
|
+
(0, helpers_1.showSignaturesInLog)(repo);
|
|
121
|
+
(0, helpers_1.commitForgedSigned)(repo, 'docs: signed\n\nChange-Record: none');
|
|
122
|
+
expect(repo.git('log', '-1', '--format=%s')).not.toBe('docs: signed');
|
|
123
|
+
expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true);
|
|
124
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
// The verification text of a signed commit comes out BEFORE the formatted message when log.showSignature is on. A fake gpg that
|
|
127
|
+
// ends its text with a blank line turns a subject-only `Change-Record: none` into a "second paragraph": without
|
|
128
|
+
// --no-show-signature (LOG_SAFETY_ARGS) hasOptOut would exempt a change that never opted out.
|
|
129
|
+
(0, helpers_1.posixOnly)('a signed commit whose verification text ends in a blank line is not read as having a final paragraph (LOG_SAFETY_ARGS)', () => {
|
|
130
|
+
const gpg = path_1.default.join(repo.home, 'fake-gpg.sh');
|
|
131
|
+
fs_1.default.writeFileSync(gpg, '#!/bin/sh\necho "[GNUPG:] GOODSIG 0123456789ABCDEF t <t@t>"\nprintf \'gpg: Signature made today\\ngpg: Good signature from "t"\\n\\n\' >&2\nexit 0\n', { mode: 0o755 });
|
|
132
|
+
repo.git('config', 'log.showSignature', 'true');
|
|
133
|
+
repo.git('config', 'gpg.program', gpg);
|
|
134
|
+
(0, helpers_1.commitForgedSigned)(repo, 'Change-Record: none');
|
|
135
|
+
// The fixture works: an unprotected log really carries the verification text in front of the message.
|
|
136
|
+
expect(repo.git('log', '-1', '-z', '--format=%B')).toContain('gpg: Good signature from "t"');
|
|
137
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
test('R4.2 a message with CRLF line endings (kept verbatim) still opts out: the trailing CR of the trailer line is ignored', () => {
|
|
140
|
+
const file = path_1.default.join(repo.home, 'crlf.txt');
|
|
141
|
+
fs_1.default.writeFileSync(file, 'docs: crlf\r\n\r\nChange-Record: none\r\n');
|
|
142
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'crlf.ts'), '1');
|
|
143
|
+
repo.git('add', '-A');
|
|
144
|
+
repo.git('commit', '-q', '--cleanup=verbatim', '-F', file);
|
|
145
|
+
expect(repo.git('log', '-1', '--format=%B')).toContain('\r');
|
|
146
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
test.each(['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'])('%s=1 does not change the opt-out result', (name) => {
|
|
149
|
+
const base = branch();
|
|
150
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'feat(c): a\n\nChange-Record: none');
|
|
151
|
+
(0, helpers_1.withEnv)({ [name]: '1' }, () => expect((0, trailers_1.hasOptOut)(repo.root, [`${base}..HEAD`])).toBe(true));
|
|
152
|
+
});
|
|
153
|
+
// Untrusted commit messages are scanned in linear time: a regex that backtracks over a long whitespace run took seconds (80k spaces: 6.6 s).
|
|
154
|
+
// The scaling is measured on the pure parser (no git child), so a slow or loaded runner cannot fail it; the git-level test only
|
|
155
|
+
// checks the result.
|
|
156
|
+
describe('linear-time parsing of hostile messages', () => {
|
|
157
|
+
const CASES = [
|
|
158
|
+
['spaces inside a line of the final paragraph', (n) => `docs: a\n\nx${' '.repeat(n)}y`, false],
|
|
159
|
+
['spaces inside the subject line', (n) => `docs: x${' '.repeat(n)}y\n\nChange-Record: none`, true],
|
|
160
|
+
['alternating blanks inside a line', (n) => `docs: a\n\nx${' \t'.repeat(n / 2)}y`, false],
|
|
161
|
+
['a single very long line', (n) => `docs: a\n\n${'x'.repeat(n)}`, false],
|
|
162
|
+
['an opt-out line followed by blanks and text', (n) => `docs: a\n\nChange-Record: none${' '.repeat(n)}x`, false],
|
|
163
|
+
['an opt-out key followed by blanks and no value', (n) => `docs: a\n\nChange-Record:${' '.repeat(n)}`, false],
|
|
164
|
+
['an opt-out line with trailing blanks', (n) => `docs: a\n\nChange-Record: none${' '.repeat(n)}`, true],
|
|
165
|
+
['many blank lines between paragraphs', (n) => `docs: a\n${'\n'.repeat(n / 2)}Change-Record: none`, true],
|
|
166
|
+
['many whitespace-only lines', (n) => `docs: a\n\n${' \n'.repeat(n / 2)}Change-Record: none`, true],
|
|
167
|
+
];
|
|
168
|
+
test.each(CASES)('%s gives the usual result through git', (_label, message, expected) => {
|
|
169
|
+
const file = path_1.default.join(repo.home, 'message.txt');
|
|
170
|
+
fs_1.default.writeFileSync(file, message(200_000));
|
|
171
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'perf.txt'), 'x');
|
|
172
|
+
repo.git('add', '-A');
|
|
173
|
+
repo.git('commit', '-q', '--cleanup=verbatim', '-F', file);
|
|
174
|
+
expect((0, trailers_1.hasOptOut)(repo.root, ['-1', 'HEAD'])).toBe(expected);
|
|
175
|
+
});
|
|
176
|
+
test.each(CASES)('%s: the parser gives the usual result and scales linearly', (_label, message, expected) => {
|
|
177
|
+
expect((0, trailers_1.optsOut)(message(200_000))).toBe(expected);
|
|
178
|
+
(0, helpers_1.expectLinearScaling)((n) => { (0, trailers_1.optsOut)(message(n)); }, 20_000);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
});
|