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,455 @@
|
|
|
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 child_process_1 = __importDefault(require("child_process"));
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const helpers_1 = require("./helpers");
|
|
11
|
+
const core_1 = require("../../../src/release/core");
|
|
12
|
+
const git_1 = require("../../../src/core/changes/git");
|
|
13
|
+
const derive_1 = require("../../../src/core/changes/derive");
|
|
14
|
+
const NOT_STRINGS = [undefined, null, 7, {}, ['main'], true];
|
|
15
|
+
const c = (type, scope, breaking = false) => ({ type, scope, breaking, subject: 's' });
|
|
16
|
+
const ANY_ROOT = os_1.default.tmpdir();
|
|
17
|
+
describe('deriveFromCommits and deriveModules (no repository)', () => {
|
|
18
|
+
test('R3.3 semver/type/breaking/scope rules', () => {
|
|
19
|
+
expect((0, derive_1.deriveFromCommits)([c('fix', 'a'), c('feat', 'b'), c('feat', 'b')])).toEqual({ type: 'feat', scope: 'b', semver: 'minor', breaking: false });
|
|
20
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'a'), c('refactor', 'z', true)])).toEqual({ type: 'refactor', scope: 'a', semver: 'major', breaking: true });
|
|
21
|
+
expect((0, derive_1.deriveFromCommits)([c('docs', null), c('chore', null)])).toEqual({ type: 'docs', scope: null, semver: 'none', breaking: false });
|
|
22
|
+
expect((0, derive_1.deriveFromCommits)([c('perf', 'b'), c('perf', 'a')])).toEqual({ type: 'perf', scope: 'a', semver: 'patch', breaking: false });
|
|
23
|
+
});
|
|
24
|
+
test('R3.3 type precedence is breaking > feat > fix > perf > first commit', () => {
|
|
25
|
+
expect((0, derive_1.deriveFromCommits)([c('perf', null), c('fix', null), c('docs', null)])?.type).toBe('fix');
|
|
26
|
+
expect((0, derive_1.deriveFromCommits)([c('docs', null), c('perf', null), c('chore', null)])?.type).toBe('perf');
|
|
27
|
+
expect((0, derive_1.deriveFromCommits)([c('chore', null), c('docs', null)])?.type).toBe('chore');
|
|
28
|
+
expect((0, derive_1.deriveFromCommits)([c('fix', null), c('feat', null), c('docs', null, true)])?.type).toBe('docs');
|
|
29
|
+
});
|
|
30
|
+
test('R3.3 a scope is trimmed before it is counted, and a blank scope is no scope', () => {
|
|
31
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', ' a ')])?.scope).toBe('a');
|
|
32
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', ' a'), c('feat', 'a '), c('feat', 'b')])?.scope).toBe('a');
|
|
33
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', ' ')])?.scope).toBeNull();
|
|
34
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', '\t')])?.scope).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
test('R3.3 several breaking commits: the FIRST one gives the type', () => {
|
|
37
|
+
expect((0, derive_1.deriveFromCommits)([c('docs', null), c('refactor', null, true), c('chore', null, true)])?.type).toBe('refactor');
|
|
38
|
+
expect((0, derive_1.deriveFromCommits)([c('chore', null, true), c('refactor', null, true)])?.type).toBe('chore');
|
|
39
|
+
});
|
|
40
|
+
test('R3.3 the most frequent scope wins, ties break alphabetically, null scopes do not count', () => {
|
|
41
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'a'), c('feat', 'b'), c('feat', 'b')])?.scope).toBe('b');
|
|
42
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'b'), c('feat', 'a')])?.scope).toBe('a');
|
|
43
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', null), c('feat', null), c('feat', 'z')])?.scope).toBe('z');
|
|
44
|
+
});
|
|
45
|
+
test('R3.3 semver none for non-releasing types, patch for fix', () => {
|
|
46
|
+
expect((0, derive_1.deriveFromCommits)([c('fix', null), c('docs', null)])?.semver).toBe('patch');
|
|
47
|
+
expect((0, derive_1.deriveFromCommits)([c('test', null)])?.semver).toBe('none');
|
|
48
|
+
});
|
|
49
|
+
test('deriveFromCommits returns null for an empty list and for non-array input', () => {
|
|
50
|
+
expect((0, derive_1.deriveFromCommits)([])).toBeNull();
|
|
51
|
+
expect((0, derive_1.deriveFromCommits)((0, helpers_1.bad)(null))).toBeNull();
|
|
52
|
+
expect((0, derive_1.deriveFromCommits)((0, helpers_1.bad)('feat: x'))).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
test('deriveFromCommits ignores entries that are not usable conventional commits', () => {
|
|
55
|
+
const hostile = [null, 'x', { type: 7, scope: null, breaking: false, subject: 's' }, c('', 'a'), c('fe-at', 'a'), c('fe at', 'a'), c('fe\nat', 'a')];
|
|
56
|
+
expect((0, derive_1.deriveFromCommits)((0, helpers_1.bad)(hostile))).toBeNull();
|
|
57
|
+
expect((0, derive_1.deriveFromCommits)((0, helpers_1.bad)([...hostile, c('fix', 'k')]))).toEqual({ type: 'fix', scope: 'k', semver: 'patch', breaking: false });
|
|
58
|
+
});
|
|
59
|
+
test('R3.3 breaking and semver cover every commit the release parser accepts, whatever the type spelling', () => {
|
|
60
|
+
expect((0, derive_1.deriveFromCommits)([c('Feat', null, true)])).toEqual({ type: 'feat', scope: null, semver: 'major', breaking: true });
|
|
61
|
+
expect((0, derive_1.deriveFromCommits)([c('Feat', null, true), c('fix', 'a')])).toEqual({ type: 'feat', scope: 'a', semver: 'major', breaking: true });
|
|
62
|
+
expect((0, derive_1.deriveFromCommits)([c('Refactor', 'core', true)])).toEqual({ type: 'refactor', scope: 'core', semver: 'major', breaking: true });
|
|
63
|
+
});
|
|
64
|
+
test('R3.3 a type that is not purely letters after lowercasing is recorded as other', () => {
|
|
65
|
+
expect((0, derive_1.deriveFromCommits)([c('feat2', null, true)])).toEqual({ type: 'other', scope: null, semver: 'major', breaking: true });
|
|
66
|
+
expect((0, derive_1.deriveFromCommits)([c('fix_x', null, true)])?.type).toBe('other');
|
|
67
|
+
expect((0, derive_1.deriveFromCommits)([c('FIX_X', null)])).toEqual({ type: 'other', scope: null, semver: 'none', breaking: false });
|
|
68
|
+
expect((0, derive_1.deriveFromCommits)([c('FEAT', null)])?.type).toBe('feat');
|
|
69
|
+
});
|
|
70
|
+
test('R3.3 semver follows the release parser: only an exact feat or fix type releases', () => {
|
|
71
|
+
expect((0, derive_1.deriveFromCommits)([c('Feat', null)])?.semver).toBe('none');
|
|
72
|
+
expect((0, derive_1.deriveFromCommits)([c('Fix', null), c('docs', null)])?.semver).toBe('none');
|
|
73
|
+
});
|
|
74
|
+
test('R3.3 the scope tie-break is by UTF-16 code-unit order, not by locale', () => {
|
|
75
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'a'), c('feat', 'Z')])?.scope).toBe('Z');
|
|
76
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'f'), c('feat', 'é')])?.scope).toBe('f');
|
|
77
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'é'), c('feat', 'e')])?.scope).toBe('e');
|
|
78
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'B'), c('feat', 'b'), c('feat', 'b')])?.scope).toBe('b');
|
|
79
|
+
});
|
|
80
|
+
test('deriveFromCommits treats only a literal true as breaking', () => {
|
|
81
|
+
expect((0, derive_1.deriveFromCommits)((0, helpers_1.bad)([{ type: 'feat', scope: null, breaking: 'yes', subject: 's' }]))).toEqual({ type: 'feat', scope: null, semver: 'minor', breaking: false });
|
|
82
|
+
});
|
|
83
|
+
test('deriveFromCommits drops a scope that is blank or not single-line', () => {
|
|
84
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'a\nb'), c('feat', 'a\nb'), c('feat', ' '), c('feat', 'ok')])).toEqual({ type: 'feat', scope: 'ok', semver: 'minor', breaking: false });
|
|
85
|
+
expect((0, derive_1.deriveFromCommits)([c('feat', 'tab\there')])?.scope).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
test('R3.4 modules: parent dirs truncated to 3 segments, root files map to ., docs/changes excluded', () => {
|
|
88
|
+
expect((0, derive_1.deriveModules)(['cli/src/core/changes/x.ts', 'README.md', 'docs/changes/2026-10-17-a.md', 'cli/src/a.ts', 'cli/src/core/y.ts']))
|
|
89
|
+
.toEqual(['.', 'cli/src', 'cli/src/core']);
|
|
90
|
+
});
|
|
91
|
+
test('R3.4 truncation keeps exactly three segments and deduplicates', () => {
|
|
92
|
+
expect((0, derive_1.deriveModules)(['a/b/c/d/e.ts', 'a/b/c/f.ts', 'a/b/c/g/h/i.ts'])).toEqual(['a/b/c']);
|
|
93
|
+
expect((0, derive_1.deriveModules)(['a/b/x.ts', 'a/b/c/y.ts'])).toEqual(['a/b', 'a/b/c']);
|
|
94
|
+
});
|
|
95
|
+
test('R3.4 modules are sorted', () => {
|
|
96
|
+
expect((0, derive_1.deriveModules)(['z/a.ts', 'm.md', 'b/a.ts'])).toEqual(['.', 'b', 'z']);
|
|
97
|
+
});
|
|
98
|
+
test('R3.4 only docs/changes itself is excluded, not similarly named directories', () => {
|
|
99
|
+
expect((0, derive_1.deriveModules)(['docs/changes/x.md', 'docs/changes/sub/y.md', 'docs/changes-old/z.md', 'docs/changesx/w.md', 'docs/other.md']))
|
|
100
|
+
.toEqual(['docs', 'docs/changes-old', 'docs/changesx']);
|
|
101
|
+
});
|
|
102
|
+
test('R3.4 a docs/changes file does not contribute a module even when it is the only file', () => {
|
|
103
|
+
expect((0, derive_1.deriveModules)(['docs/changes/x.md'])).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
test('R3.4 directories a record cannot represent are dropped instead of leaking into modules', () => {
|
|
106
|
+
const odd = ['a\nb/x.ts', 'tab\there/x.ts', 'back\\slash/x.ts', ' pad/x.ts', 'ok/x.ts', '../up/x.ts', '/abs/x.ts', 'a//b/x.ts'];
|
|
107
|
+
expect((0, derive_1.deriveModules)(odd)).toEqual(['ok']);
|
|
108
|
+
});
|
|
109
|
+
test('R3.4 a bad segment beyond the third is truncated away and does not matter', () => {
|
|
110
|
+
expect((0, derive_1.deriveModules)(['a/b/c/we\nird/x.ts'])).toEqual(['a/b/c']);
|
|
111
|
+
});
|
|
112
|
+
test('deriveModules tolerates non-array input and non-string entries', () => {
|
|
113
|
+
expect((0, derive_1.deriveModules)((0, helpers_1.bad)(null))).toEqual([]);
|
|
114
|
+
expect((0, derive_1.deriveModules)((0, helpers_1.bad)(['ok/a.ts', 7, null, {}]))).toEqual(['ok']);
|
|
115
|
+
});
|
|
116
|
+
test.each([...NOT_STRINGS, '', 'HEAD', '--output=x', 'abc', 'g'.repeat(40), 'a'.repeat(65), 'main'])('deriveFields rejects a merge-base that is not a commit id (%p) with invalid-input', (sha) => {
|
|
117
|
+
expect((0, derive_1.deriveFields)(ANY_ROOT, (0, helpers_1.bad)(sha))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'invalid-input' }) });
|
|
118
|
+
});
|
|
119
|
+
test('deriveFields rejects a non-string root with invalid-input', () => {
|
|
120
|
+
expect((0, derive_1.deriveFields)((0, helpers_1.bad)(null), 'a'.repeat(40))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'invalid-input' }) });
|
|
121
|
+
});
|
|
122
|
+
test('A3.2 diagnostics never echo untrusted text raw', () => {
|
|
123
|
+
const hostile = 'abc\ndef\u001b[31m\u2028x';
|
|
124
|
+
const result = (0, derive_1.deriveFields)(ANY_ROOT, hostile);
|
|
125
|
+
expect(result.ok).toBe(false);
|
|
126
|
+
if (result.ok)
|
|
127
|
+
return;
|
|
128
|
+
expect(result.diagnostic.message).not.toMatch(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/);
|
|
129
|
+
expect(result.diagnostic.message).toContain('abc\\ndef');
|
|
130
|
+
expect(result.diagnostic.message.length).toBeLessThanOrEqual(300);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe('deriveFields in a repository', () => {
|
|
134
|
+
let repo;
|
|
135
|
+
let template;
|
|
136
|
+
beforeAll(() => { template = (0, helpers_1.makeRepo)(); });
|
|
137
|
+
afterAll(() => template.cleanup());
|
|
138
|
+
beforeEach(() => { repo = (0, helpers_1.cloneRepo)(template); });
|
|
139
|
+
afterEach(() => repo.cleanup());
|
|
140
|
+
const branch = () => (0, helpers_1.branchOff)(repo);
|
|
141
|
+
const noNewFile = (name) => expect(fs_1.default.existsSync(path_1.default.join(repo.root, name))).toBe(false);
|
|
142
|
+
const nonRepoDir = () => (0, helpers_1.nonRepoDir)(repo);
|
|
143
|
+
test('R3.3/R3.4 deriveFields over a real range ignores record-only commits', () => {
|
|
144
|
+
const base = branch();
|
|
145
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'feat(changes): add a');
|
|
146
|
+
repo.commit({ 'cli/src/b.ts': '1' }, 'fix(changes): b');
|
|
147
|
+
const before = (0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base));
|
|
148
|
+
repo.commit({ 'docs/changes/2026-10-17-x.md': 'r' }, 'docs(other): add record');
|
|
149
|
+
const after = (0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base));
|
|
150
|
+
expect(before).toEqual({ ok: true, fields: { type: 'feat', scope: 'changes', semver: 'minor', breaking: false, modules: ['cli/src'] } });
|
|
151
|
+
expect(after).toEqual(before);
|
|
152
|
+
});
|
|
153
|
+
test('R3.3 type order in a real range follows the history, oldest first: the first commit when nothing else outranks it', () => {
|
|
154
|
+
const base = branch();
|
|
155
|
+
repo.commit({ 'a.ts': '1' }, 'chore(x): first');
|
|
156
|
+
repo.commit({ 'b.ts': '1' }, 'docs(x): second');
|
|
157
|
+
repo.commit({ 'c.ts': '1' }, 'test(x): third');
|
|
158
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'chore', scope: 'x', semver: 'none', breaking: false, modules: ['.'] } });
|
|
159
|
+
});
|
|
160
|
+
test('R3.3 with several breaking commits in a real range the OLDEST breaking one gives the type', () => {
|
|
161
|
+
const base = branch();
|
|
162
|
+
repo.commit({ 'a.ts': '1' }, 'docs(x): intro');
|
|
163
|
+
repo.commit({ 'b.ts': '1' }, 'refactor(x)!: first break');
|
|
164
|
+
repo.commit({ 'c.ts': '1' }, 'chore(x)!: second break');
|
|
165
|
+
repo.commit({ 'd.ts': '1' }, 'feat(x): later feature');
|
|
166
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'refactor', scope: 'x', semver: 'major', breaking: true, modules: ['.'] } });
|
|
167
|
+
});
|
|
168
|
+
test('R3.3 a fix after a docs commit outranks it, whatever the order', () => {
|
|
169
|
+
const base = branch();
|
|
170
|
+
repo.commit({ 'a.ts': '1' }, 'docs(x): intro');
|
|
171
|
+
repo.commit({ 'b.ts': '1' }, 'fix(x): a fix');
|
|
172
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toMatchObject({ ok: true, fields: { type: 'fix', semver: 'patch' } });
|
|
173
|
+
});
|
|
174
|
+
test('R3.3 a record commit that also touches code still counts', () => {
|
|
175
|
+
const base = branch();
|
|
176
|
+
repo.commit({ 'docs/changes/r.md': 'r', 'cli/src/a.ts': '1' }, 'fix(mixed): code with its record');
|
|
177
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'mixed', semver: 'patch', breaking: false, modules: ['cli/src'] } });
|
|
178
|
+
});
|
|
179
|
+
test('R3.3 a BREAKING CHANGE footer or a ! marks the range as major', () => {
|
|
180
|
+
const base = branch();
|
|
181
|
+
repo.commit({ 'a.ts': '1' }, 'refactor(core): drop api\n\nBREAKING CHANGE: gone');
|
|
182
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'refactor', scope: 'core', semver: 'major', breaking: true, modules: ['.'] } });
|
|
183
|
+
});
|
|
184
|
+
test('R3.1 only the commits after the merge base count', () => {
|
|
185
|
+
branch();
|
|
186
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'fix(mine): mine');
|
|
187
|
+
repo.git('checkout', '-q', 'main');
|
|
188
|
+
repo.commit({ 'other/z.ts': '1' }, 'feat(theirs)!: theirs');
|
|
189
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
190
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, 'main'))).toEqual({ ok: true, fields: { type: 'fix', scope: 'mine', semver: 'patch', breaking: false, modules: ['cli/src'] } });
|
|
191
|
+
});
|
|
192
|
+
test('R3.3 a capitalised breaking type from a real commit is major', () => {
|
|
193
|
+
const base = branch();
|
|
194
|
+
repo.commit({ 'a.ts': '1' }, 'Feat!: remove api');
|
|
195
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: null, semver: 'major', breaking: true, modules: ['.'] } });
|
|
196
|
+
});
|
|
197
|
+
test('R3.3 a commit with a type the release parser accepts but a record cannot hold is other', () => {
|
|
198
|
+
const base = branch();
|
|
199
|
+
repo.commit({ 'a.ts': '1' }, 'feat2!: x');
|
|
200
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'other', scope: null, semver: 'major', breaking: true, modules: ['.'] } });
|
|
201
|
+
});
|
|
202
|
+
test('R3.3 a side branch merged with a tree-discarding merge still counts', () => {
|
|
203
|
+
const base = branch();
|
|
204
|
+
repo.commit({ 'a.ts': '1' }, 'fix(a): a');
|
|
205
|
+
repo.git('checkout', '-q', '-b', 'side', 'main');
|
|
206
|
+
repo.commit({ 'b.ts': '1' }, 'feat(b)!: big');
|
|
207
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
208
|
+
repo.git('merge', '-q', '-s', 'ours', '--no-ff', 'side', '-m', 'chore: merge side, keeping ours');
|
|
209
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'a', semver: 'major', breaking: true, modules: ['.'] } });
|
|
210
|
+
});
|
|
211
|
+
test('R3.5 code that only sits on a side branch merged with -s ours is not "no commits"', () => {
|
|
212
|
+
const base = branch();
|
|
213
|
+
repo.git('checkout', '-q', '-b', 'side', 'main');
|
|
214
|
+
repo.commit({ 'b.ts': '1' }, 'fix(b): only on the side');
|
|
215
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
216
|
+
repo.git('merge', '-q', '-s', 'ours', '--no-ff', 'side', '-m', 'chore: merge side, keeping ours');
|
|
217
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'b', semver: 'patch', breaking: false, modules: [] } });
|
|
218
|
+
});
|
|
219
|
+
test('R3.1 merge commits are not derived from', () => {
|
|
220
|
+
const base = branch();
|
|
221
|
+
repo.commit({ 'a.ts': '1' }, 'feat(core): a');
|
|
222
|
+
repo.git('checkout', '-q', '-b', 'side');
|
|
223
|
+
repo.commit({ 'side.ts': '1' }, 'docs(side): side');
|
|
224
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
225
|
+
repo.commit({ 'b.ts': '1' }, 'fix(core): b');
|
|
226
|
+
repo.git('merge', '-q', '--no-ff', 'side', '-m', 'feat(merge)!: merged');
|
|
227
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'core', semver: 'minor', breaking: false, modules: ['.'] } });
|
|
228
|
+
});
|
|
229
|
+
test('R3.3 non-conventional commits are skipped when another one is conventional', () => {
|
|
230
|
+
const base = branch();
|
|
231
|
+
repo.commit({ 'a.ts': '1' }, 'random message');
|
|
232
|
+
repo.commit({ 'b.ts': '1' }, 'fix(x): real');
|
|
233
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'x', semver: 'patch', breaking: false, modules: ['.'] } });
|
|
234
|
+
});
|
|
235
|
+
test('R3.3 one commit cannot forge another through the record separator in its message', () => {
|
|
236
|
+
const base = branch();
|
|
237
|
+
repo.commit({ 'a.ts': '1' }, 'docs(x): innocent\n\nsee \u001efeat(forged)!: evil\u001f more');
|
|
238
|
+
repo.commit({ 'b.ts': '1' }, 'docs(x): fake \u001efeat(forged)!: subject forgery');
|
|
239
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'docs', scope: 'x', semver: 'none', breaking: false, modules: ['.'] } });
|
|
240
|
+
});
|
|
241
|
+
(0, helpers_1.posixOnly)('R3.4 modules with unrepresentable directory names are dropped from the derived fields', () => {
|
|
242
|
+
const base = branch();
|
|
243
|
+
repo.commit({ 'ok/a.ts': '1', 'bad\nname/b.ts': '1' }, 'feat(x): odd');
|
|
244
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'x', semver: 'minor', breaking: false, modules: ['ok'] } });
|
|
245
|
+
});
|
|
246
|
+
test('R3.5 no commits and no conventional commits fail', () => {
|
|
247
|
+
const base = branch();
|
|
248
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-commits' }) });
|
|
249
|
+
repo.commit({ 'a.ts': '1' }, 'random message');
|
|
250
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-conventional-commits' }) });
|
|
251
|
+
});
|
|
252
|
+
// R3.3: only non-merge commits that touch at least one path outside docs/changes/ are derived from; an empty commit
|
|
253
|
+
// (`git commit --allow-empty`) changes no path at all, so it is ignored, as the release script's path-limited log does.
|
|
254
|
+
const emptyCommit = (message) => repo.git('commit', '-q', '--allow-empty', '-m', message);
|
|
255
|
+
test('R3.3 an empty breaking commit is ignored', () => {
|
|
256
|
+
const base = branch();
|
|
257
|
+
repo.commit({ 'src/x.ts': '1' }, 'fix: y');
|
|
258
|
+
emptyCommit('feat!: empty');
|
|
259
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: null, semver: 'patch', breaking: false, modules: ['src'] } });
|
|
260
|
+
});
|
|
261
|
+
test('R3.5 a range holding only an empty commit has no commits', () => {
|
|
262
|
+
const base = branch();
|
|
263
|
+
emptyCommit('feat: empty');
|
|
264
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-commits' }) });
|
|
265
|
+
});
|
|
266
|
+
test('R3.3 with an empty commit, a record-only commit and a real commit, only the real one counts', () => {
|
|
267
|
+
const base = branch();
|
|
268
|
+
emptyCommit('feat(e)!: empty');
|
|
269
|
+
repo.commit({ 'docs/changes/r.md': 'r' }, 'feat(r)!: record');
|
|
270
|
+
repo.commit({ 'src/real.ts': '1' }, 'perf(real): real');
|
|
271
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'perf', scope: 'real', semver: 'patch', breaking: false, modules: ['src'] } });
|
|
272
|
+
});
|
|
273
|
+
test('R3.5 a range holding only record commits has no commits', () => {
|
|
274
|
+
const base = branch();
|
|
275
|
+
repo.commit({ 'docs/changes/r.md': 'r' }, 'docs(changes): record');
|
|
276
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-commits' }) });
|
|
277
|
+
});
|
|
278
|
+
test('R3.5 a range holding only a merge commit has no commits', () => {
|
|
279
|
+
const fork = branch();
|
|
280
|
+
repo.git('checkout', '-q', 'main');
|
|
281
|
+
const moved = repo.commit({ 'm.ts': '1' }, 'feat(main): moves main');
|
|
282
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
283
|
+
// An evil merge (it adds a file of its own), so that path-limited history simplification cannot hide it.
|
|
284
|
+
repo.git('merge', '-q', '--no-ff', '--no-commit', 'main');
|
|
285
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'evil.ts'), '1');
|
|
286
|
+
repo.git('add', 'evil.ts');
|
|
287
|
+
repo.git('commit', '-q', '-m', 'feat(merge)!: bring main');
|
|
288
|
+
expect((0, derive_1.deriveFields)(repo.root, fork)).toEqual({ ok: true, fields: expect.objectContaining({ type: 'feat', scope: 'main', breaking: false }) });
|
|
289
|
+
expect((0, derive_1.deriveFields)(repo.root, moved)).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-commits' }) });
|
|
290
|
+
});
|
|
291
|
+
test('deriveFields reports git-failed when git cannot read the range', () => {
|
|
292
|
+
branch();
|
|
293
|
+
const result = (0, derive_1.deriveFields)(nonRepoDir(), 'a'.repeat(40));
|
|
294
|
+
expect(result).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'git-failed' }) });
|
|
295
|
+
});
|
|
296
|
+
test('deriveFields works from a subdirectory of the repository', () => {
|
|
297
|
+
const base = branch();
|
|
298
|
+
repo.commit({ 'cli/src/a.ts': '1', 'top.ts': '1' }, 'feat(c): top and sub');
|
|
299
|
+
const sub = path_1.default.join(repo.root, 'cli');
|
|
300
|
+
expect((0, derive_1.deriveFields)(sub, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['.', 'cli/src'] } });
|
|
301
|
+
});
|
|
302
|
+
test('R3.3 a signed breaking commit is derived even with log.showSignature on', () => {
|
|
303
|
+
const base = branch();
|
|
304
|
+
(0, helpers_1.showSignaturesInLog)(repo);
|
|
305
|
+
(0, helpers_1.commitForgedSigned)(repo, 'feat(zzz)!: signed');
|
|
306
|
+
expect(repo.git('log', '-1', '--format=%s')).not.toBe('feat(zzz)!: signed'); // the config really pollutes plain git log
|
|
307
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'zzz', semver: 'major', breaking: true, modules: ['.'] } });
|
|
308
|
+
});
|
|
309
|
+
test('R3.3 a non-ASCII scope survives i18n.logOutputEncoding', () => {
|
|
310
|
+
const base = branch();
|
|
311
|
+
repo.git('config', 'i18n.logOutputEncoding', 'ISO-8859-1');
|
|
312
|
+
repo.commit({ 'a.ts': '1' }, 'feat(mañana): x');
|
|
313
|
+
expect(repo.git('log', '-1', '--format=%s')).not.toBe('feat(mañana): x'); // the config really re-encodes plain git log
|
|
314
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'mañana', semver: 'minor', breaking: false, modules: ['.'] } });
|
|
315
|
+
});
|
|
316
|
+
test('R3.4 changed paths stay repo-relative with diff.relative on, from a subdirectory too', () => {
|
|
317
|
+
const base = branch();
|
|
318
|
+
repo.git('config', 'diff.relative', 'true');
|
|
319
|
+
repo.commit({ 'cli/src/a.ts': '1', 'top.ts': '1' }, 'feat(c): top and sub');
|
|
320
|
+
const sub = path_1.default.join(repo.root, 'cli');
|
|
321
|
+
expect((0, git_1.changedFiles)(sub, base)?.slice().sort()).toEqual(['cli/src/a.ts', 'top.ts']);
|
|
322
|
+
expect((0, derive_1.deriveFields)(sub, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['.', 'cli/src'] } });
|
|
323
|
+
});
|
|
324
|
+
test('R3.5 a commit that only touches a differently cased Docs/Changes is not a record commit, with or without GIT_ICASE_PATHSPECS', () => {
|
|
325
|
+
const base = branch();
|
|
326
|
+
repo.commit({ 'Docs/Changes/up.md': '1' }, 'feat(c): up');
|
|
327
|
+
const expected = { ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['Docs/Changes'] } };
|
|
328
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual(expected);
|
|
329
|
+
(0, helpers_1.withEnv)({ GIT_ICASE_PATHSPECS: '1' }, () => expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual(expected));
|
|
330
|
+
});
|
|
331
|
+
test('R3.5 a commit that only touches docs/changes stays excluded when the environment asks for literal pathspecs', () => {
|
|
332
|
+
const base = branch();
|
|
333
|
+
repo.commit({ 'docs/changes/r.md': '1' }, 'docs(c): record');
|
|
334
|
+
(0, helpers_1.withEnv)({ GIT_LITERAL_PATHSPECS: '1' }, () => expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-commits' }) }));
|
|
335
|
+
});
|
|
336
|
+
test('a bumped gitlink stays in modules with diff.ignoreSubmodules=all and ignore=all in .gitmodules', () => {
|
|
337
|
+
const first = repo.git('rev-parse', 'HEAD');
|
|
338
|
+
const second = repo.commit({ 'x.txt': '1', '.gitmodules': '[submodule "vendor/sm"]\n\tpath = vendor/sm\n\turl = ./nowhere\n\tignore = all\n' }, 'chore: gitmodules');
|
|
339
|
+
repo.git('config', 'diff.ignoreSubmodules', 'all');
|
|
340
|
+
repo.git('update-index', '--add', '--cacheinfo', `160000,${first},vendor/sm`);
|
|
341
|
+
repo.git('commit', '-q', '-m', 'chore: add submodule');
|
|
342
|
+
const base = branch();
|
|
343
|
+
repo.git('update-index', '--cacheinfo', `160000,${second},vendor/sm`);
|
|
344
|
+
repo.git('commit', '-q', '-m', 'feat(v): bump submodule');
|
|
345
|
+
expect((0, git_1.changedFiles)(repo.root, base)).toEqual(['vendor/sm']);
|
|
346
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'v', semver: 'minor', breaking: false, modules: ['vendor'] } });
|
|
347
|
+
});
|
|
348
|
+
test.each(['GIT_LITERAL_PATHSPECS', 'GIT_GLOB_PATHSPECS', 'GIT_NOGLOB_PATHSPECS', 'GIT_ICASE_PATHSPECS'])('R3.3 %s=1 does not change the derived range or modules', (name) => {
|
|
349
|
+
const base = branch();
|
|
350
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'feat(c): a');
|
|
351
|
+
(0, helpers_1.withEnv)({ [name]: '1' }, () => {
|
|
352
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['cli/src'] } });
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
test('deriveFields rejects a merge-base that is not a commit id without creating files', () => {
|
|
356
|
+
expect((0, derive_1.deriveFields)(repo.root, '--output=x')).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'invalid-input' }) });
|
|
357
|
+
noNewFile('x');
|
|
358
|
+
});
|
|
359
|
+
test('a hostile 200k-character subject is not a conventional commit', () => {
|
|
360
|
+
const base = branch();
|
|
361
|
+
const file = path_1.default.join(repo.home, 'message.txt');
|
|
362
|
+
for (const [i, subject] of [`feat(${' '.repeat(200000)}`, `feat: ${' '.repeat(200000)}y`, `feat${'('.repeat(100000)}`, `fix(${'x'.repeat(200000)})!: z`].entries()) {
|
|
363
|
+
fs_1.default.writeFileSync(file, subject);
|
|
364
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, `f${i}.txt`), 'x');
|
|
365
|
+
repo.git('add', '-A');
|
|
366
|
+
repo.git('commit', '-q', '--cleanup=verbatim', '-F', file);
|
|
367
|
+
}
|
|
368
|
+
const result = (0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base));
|
|
369
|
+
// every one of these subjects is over 1024 units, so none is a conventional commit (R3.3)
|
|
370
|
+
expect(result).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-conventional-commits' }) });
|
|
371
|
+
});
|
|
372
|
+
// The release parser's header regex backtracks quadratically on blanks followed by a line terminator its `.` cannot cross
|
|
373
|
+
// (60k spaces then `\ry\rz` took 3.3 s). A subject over 1024 UTF-16 units is therefore never handed to it (R3.3).
|
|
374
|
+
test.each([
|
|
375
|
+
['spaces followed by lone CRs', `feat: ${' '.repeat(100000)}\ry\rz`],
|
|
376
|
+
['spaces followed by U+2028 separators', `feat: ${' '.repeat(100000)}\u2028y\u2028z`],
|
|
377
|
+
['a 200k-character subject', `feat: ${'x'.repeat(200000)}`],
|
|
378
|
+
])('R3.3 a hostile subject (%s) is ignored', (_label, subject) => {
|
|
379
|
+
const base = branch();
|
|
380
|
+
const file = path_1.default.join(repo.home, 'message.txt');
|
|
381
|
+
fs_1.default.writeFileSync(file, subject);
|
|
382
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, 'f.txt'), 'x');
|
|
383
|
+
repo.git('add', '-A');
|
|
384
|
+
repo.git('commit', '-q', '--cleanup=verbatim', '-F', file);
|
|
385
|
+
const result = (0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base));
|
|
386
|
+
expect(result).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-conventional-commits' }) });
|
|
387
|
+
});
|
|
388
|
+
test('R3.3 a subject of exactly 1024 units is parsed, one of 1025 is not a conventional commit', () => {
|
|
389
|
+
const base = branch();
|
|
390
|
+
const long = (type, units) => `${type}: ${'x'.repeat(units - type.length - 2)}`;
|
|
391
|
+
repo.commit({ 'a.ts': '1' }, long('feat', 1024));
|
|
392
|
+
expect(long('feat', 1024)).toHaveLength(1024);
|
|
393
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: null, semver: 'minor', breaking: false, modules: ['.'] } });
|
|
394
|
+
repo.commit({ 'b.ts': '1' }, long('fix!', 1025));
|
|
395
|
+
expect(long('fix!', 1025)).toHaveLength(1025);
|
|
396
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: null, semver: 'minor', breaking: false, modules: ['.'] } });
|
|
397
|
+
});
|
|
398
|
+
test.each([
|
|
399
|
+
['a trailing record separator makes a 1025-unit subject', `feat: ${'a'.repeat(1018)}\u001e`],
|
|
400
|
+
['a record separator inside makes a 1025-unit subject', `feat: ${'a'.repeat(600)}\u001e${'a'.repeat(418)}`],
|
|
401
|
+
])('R3.3 the subject cap counts the raw subject, before any separator is stripped (%s)', (_label, subject) => {
|
|
402
|
+
const base = branch();
|
|
403
|
+
expect(subject).toHaveLength(1025);
|
|
404
|
+
repo.commit({ 'a.ts': '1' }, subject);
|
|
405
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'no-conventional-commits' }) });
|
|
406
|
+
});
|
|
407
|
+
test('R3.3 a raw 1024-unit subject holding a record separator is still parsed', () => {
|
|
408
|
+
const base = branch();
|
|
409
|
+
const subject = `feat: ${'a'.repeat(600)}\u001e${'a'.repeat(417)}`;
|
|
410
|
+
expect(subject).toHaveLength(1024);
|
|
411
|
+
repo.commit({ 'a.ts': '1' }, subject);
|
|
412
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: null, semver: 'minor', breaking: false, modules: ['.'] } });
|
|
413
|
+
});
|
|
414
|
+
test('R3.3 one HEAD is read and pinned: a commit made while deriving is not mixed in', () => {
|
|
415
|
+
const base = branch();
|
|
416
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'fix(a): before');
|
|
417
|
+
const real = child_process_1.default.execFileSync;
|
|
418
|
+
let moved = false;
|
|
419
|
+
const spy = jest.spyOn(child_process_1.default, 'execFileSync').mockImplementation(((cmd, args, opts) => {
|
|
420
|
+
if (!moved && args[0] === 'log') {
|
|
421
|
+
moved = true; // HEAD moves after it was read and before the log and the diff: both must still describe the pinned HEAD
|
|
422
|
+
spy.mockRestore();
|
|
423
|
+
repo.commit({ 'late/z.ts': '1' }, 'feat(late)!: after');
|
|
424
|
+
}
|
|
425
|
+
return real(cmd, args, opts);
|
|
426
|
+
}));
|
|
427
|
+
try {
|
|
428
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'a', semver: 'patch', breaking: false, modules: ['cli/src'] } });
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
spy.mockRestore();
|
|
432
|
+
}
|
|
433
|
+
expect(moved).toBe(true);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
describe('commitsFromRecords: hostile subjects are never handed to the release parser (R3.3), in linear time', () => {
|
|
437
|
+
// The release parser's header regex backtracks quadratically on blanks followed by a line terminator its `.` cannot cross, so a
|
|
438
|
+
// subject over 1024 units is dropped before it. Pure parsing: no git child, so the timing says nothing about the runner's speed.
|
|
439
|
+
const record = (subject) => [`${subject}${core_1.US}${core_1.RS}`];
|
|
440
|
+
test.each([
|
|
441
|
+
['spaces followed by lone CRs', (n) => `feat: ${' '.repeat(n)}\ry\rz`],
|
|
442
|
+
['spaces followed by U+2028 separators', (n) => `feat: ${' '.repeat(n)}\u2028y\u2028z`],
|
|
443
|
+
['an unterminated scope of blanks', (n) => `feat(${' '.repeat(n)}`],
|
|
444
|
+
['a run of opening parentheses', (n) => `feat${'('.repeat(n)}`],
|
|
445
|
+
['a very long scope', (n) => `fix(${'x'.repeat(n)})!: z`],
|
|
446
|
+
['a very long plain subject', (n) => `feat: ${'x'.repeat(n)}`],
|
|
447
|
+
])('%s gives no commit and scales linearly', (_label, subject) => {
|
|
448
|
+
expect((0, derive_1.commitsFromRecords)(record(subject(20_000)))).toEqual([]);
|
|
449
|
+
(0, helpers_1.expectLinearScaling)((n) => { (0, derive_1.commitsFromRecords)(record(subject(n))); }, 5_000);
|
|
450
|
+
});
|
|
451
|
+
test('a subject of exactly 1024 units is parsed, one of 1025 is not', () => {
|
|
452
|
+
expect((0, derive_1.commitsFromRecords)(record(`feat: ${'x'.repeat(1024 - 6)}`))).toHaveLength(1);
|
|
453
|
+
expect((0, derive_1.commitsFromRecords)(record(`feat: ${'x'.repeat(1025 - 6)}`))).toEqual([]);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
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 fs_safe_1 = require("../../../src/core/changes/fs-safe");
|
|
10
|
+
// HOME and AWM_HOME point at a throwaway home for the whole file (makeRepo); every scratch directory lives inside it.
|
|
11
|
+
let repo;
|
|
12
|
+
let n = 0;
|
|
13
|
+
beforeAll(() => { repo = (0, helpers_1.makeRepo)(); });
|
|
14
|
+
afterAll(() => repo.cleanup());
|
|
15
|
+
afterEach(() => jest.restoreAllMocks());
|
|
16
|
+
const scratch = () => fs_1.default.mkdtempSync(path_1.default.join(repo.home, `fss${n++}-`));
|
|
17
|
+
describe('isRegularFileInside', () => {
|
|
18
|
+
test('accepts a regular file inside', () => {
|
|
19
|
+
const root = scratch();
|
|
20
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
21
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'p.md'), 'x');
|
|
22
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'docs/p.md')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
test.each([['missing', 'nope.md'], ['a directory', 'docs'], ['a parent segment', '../x'], ['an absolute path', '/etc/passwd'], ['a backslash', 'docs\\p.md'], ['empty', '']])('rejects %s', (_n, rel) => {
|
|
25
|
+
const root = scratch();
|
|
26
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
27
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, rel)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
(0, helpers_1.posixOnly)('rejects a symlink to a file', () => {
|
|
30
|
+
const root = scratch();
|
|
31
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'real.md'), 'x');
|
|
32
|
+
fs_1.default.symlinkSync(path_1.default.join(root, 'real.md'), path_1.default.join(root, 'link.md'));
|
|
33
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'link.md')).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
(0, helpers_1.posixOnly)('rejects a file reached through a symlinked directory that leaves the root', () => {
|
|
36
|
+
const root = scratch();
|
|
37
|
+
const outside = scratch();
|
|
38
|
+
fs_1.default.writeFileSync(path_1.default.join(outside, 'p.md'), 'x');
|
|
39
|
+
fs_1.default.symlinkSync(outside, path_1.default.join(root, 'plans'));
|
|
40
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'plans/p.md')).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
(0, helpers_1.posixOnly)('accepts a file reached through a symlinked directory that stays inside the root', () => {
|
|
43
|
+
const root = scratch();
|
|
44
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'real'));
|
|
45
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'real', 'p.md'), 'x');
|
|
46
|
+
fs_1.default.symlinkSync(path_1.default.join(root, 'real'), path_1.default.join(root, 'alias'));
|
|
47
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'alias/p.md')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
test.each([[undefined], [null], [5]])('rejects the non-string %p without throwing', (rel) => {
|
|
50
|
+
expect((0, fs_safe_1.isRegularFileInside)(scratch(), rel)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
test('accepts a path spelled exactly as it is on disk', () => {
|
|
53
|
+
const root = scratch();
|
|
54
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
55
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'Plan.md'), 'x');
|
|
56
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'docs/Plan.md')).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
test('rejects a spelling that differs only in case from the file on disk (what a case-sensitive checkout would not find)', () => {
|
|
59
|
+
const root = scratch();
|
|
60
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
61
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plan.md'), 'x');
|
|
62
|
+
// Behave like a case-insensitive file system: the wrong spelling resolves to the real file.
|
|
63
|
+
const fold = (p) => String(p).replace('PLAN.md', 'plan.md').replace('/DOCS/', '/docs/');
|
|
64
|
+
const lstat = fs_1.default.lstatSync;
|
|
65
|
+
const realpath = fs_1.default.realpathSync.native;
|
|
66
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((p, ...rest) => lstat(fold(p), ...rest)));
|
|
67
|
+
jest.spyOn(fs_1.default.realpathSync, 'native').mockImplementation(((p) => realpath(fold(p))));
|
|
68
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'docs/PLAN.md')).toBe(false);
|
|
69
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'docs/plan.md')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
test('rejects a directory spelled with another case', () => {
|
|
72
|
+
const root = scratch();
|
|
73
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'docs'));
|
|
74
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'docs', 'plan.md'), 'x');
|
|
75
|
+
const lstat = fs_1.default.lstatSync;
|
|
76
|
+
const realpath = fs_1.default.realpathSync.native;
|
|
77
|
+
const fold = (p) => String(p).replace(`${path_1.default.sep}DOCS${path_1.default.sep}`, `${path_1.default.sep}docs${path_1.default.sep}`);
|
|
78
|
+
jest.spyOn(fs_1.default, 'lstatSync').mockImplementation(((p, ...rest) => lstat(fold(p), ...rest)));
|
|
79
|
+
jest.spyOn(fs_1.default.realpathSync, 'native').mockImplementation(((p) => realpath(fold(p))));
|
|
80
|
+
expect((0, fs_safe_1.isRegularFileInside)(root, 'DOCS/plan.md')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|