agentic-workflow-manager 9.20.1 → 9.20.3
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 +2 -2
- package/dist/src/commands/changes/index.js +2 -1
- package/dist/src/core/changes/derive.js +21 -8
- package/dist/src/core/changes/git.js +37 -6
- package/dist/src/core/changes/range.js +28 -13
- package/dist/src/core/frontmatter.js +67 -6
- package/dist/src/core/process/model.js +128 -25
- package/dist/tests/commands/change/new.test.js +28 -1
- package/dist/tests/commands/change/validate.test.js +3 -3
- package/dist/tests/commands/changes/list.test.js +1 -0
- package/dist/tests/core/changes/derive.test.js +75 -2
- package/dist/tests/core/changes/git.test.js +83 -1
- package/dist/tests/core/changes/list.test.js +6 -5
- package/dist/tests/core/changes/range.test.js +89 -0
- package/dist/tests/core/frontmatter-hardening.test.js +90 -0
- package/dist/tests/core/process/model.test.js +163 -6
- package/dist/tests/structural/native-release-artifacts.test.js +295 -46
- 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
|
@@ -334,7 +334,7 @@ describe('base resolution (R3.1, R3.2, R3.5)', () => {
|
|
|
334
334
|
expectFailure(r, 'base-unresolved');
|
|
335
335
|
expect(r.stderr).toContain('remedy: pass --base <ref>');
|
|
336
336
|
});
|
|
337
|
-
test('no
|
|
337
|
+
test('no default base resolves: base-unresolved', () => {
|
|
338
338
|
repo.git('branch', '-m', 'main', 'trunk');
|
|
339
339
|
expectFailure((0, change_1.runChangeValidate)(repo.root, undefined, {}), 'base-unresolved');
|
|
340
340
|
});
|
|
@@ -794,7 +794,7 @@ describe('R4.5 operational failures under --json', () => {
|
|
|
794
794
|
['path-invalid (hostile path)', 'path-invalid', () => (0, change_1.runChangeValidate)(repo.root, `docs/changes/\u001b${'z\n'.repeat(400)}.md`, J)],
|
|
795
795
|
['git-failed', 'git-failed', () => { jest.spyOn(gitModule, 'headSha').mockReturnValue(null); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
796
796
|
['flag-invalid', 'flag-invalid', () => (0, change_1.runChangeValidate)(repo.root, undefined, (0, helpers_1.bad)({ ...J, base: 7 }))],
|
|
797
|
-
['internal-error', 'internal-error', () => { jest.spyOn(gitModule, '
|
|
797
|
+
['internal-error', 'internal-error', () => { jest.spyOn(gitModule, 'baseCandidates').mockImplementation(() => { throw new RangeError('x'); }); return (0, change_1.runChangeValidate)(repo.root, undefined, J); }],
|
|
798
798
|
];
|
|
799
799
|
test.each(cases)('%s prints one JSON line of the report shape on stdout and exits 2', (_name, code, call) => {
|
|
800
800
|
const r = call();
|
|
@@ -911,7 +911,7 @@ describe('one derivation, one ancestry check and one late-file listing per verif
|
|
|
911
911
|
let result;
|
|
912
912
|
const calls = gitCalls(() => { result = run(); });
|
|
913
913
|
expect(report(result)).toMatchObject({ state: 'valid', records: [{ state: 'valid' }, { state: 'valid' }] });
|
|
914
|
-
expect(calls.filter((a) => a.includes('--no-merges'))).toHaveLength(
|
|
914
|
+
expect(calls.filter((a) => a.includes('--no-merges'))).toHaveLength(2); // the derivation's full log and its focus log
|
|
915
915
|
expect(calls.filter((a) => a.includes('--is-ancestor'))).toHaveLength(1);
|
|
916
916
|
expect(calls.filter((a) => a.includes('diff') && a.includes(verified))).toHaveLength(1); // the late-file listing
|
|
917
917
|
});
|
|
@@ -389,6 +389,7 @@ describe('registerChangesCommand', () => {
|
|
|
389
389
|
test('registers changes list with its documented flags', () => {
|
|
390
390
|
expect(sub()?.options.map((o) => o.long).sort()).toEqual(['--base', '--format', '--since']);
|
|
391
391
|
expect(sub()?.registeredArguments).toEqual([]);
|
|
392
|
+
expect(sub()?.options.find((o) => o.long === '--base')?.description).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
|
|
392
393
|
});
|
|
393
394
|
test('a run prints the result and leaves the exit code alone', async () => {
|
|
394
395
|
history();
|
|
@@ -129,6 +129,18 @@ describe('deriveFromCommits and deriveModules (no repository)', () => {
|
|
|
129
129
|
expect(result.diagnostic.message).toContain('abc\\ndef');
|
|
130
130
|
expect(result.diagnostic.message.length).toBeLessThanOrEqual(300);
|
|
131
131
|
});
|
|
132
|
+
test('R3.3 (amended) type and scope come from the focus commits; semver and breaking from all', () => {
|
|
133
|
+
const all = [c('docs', 'plans'), c('ci', 'release'), c('docs', 'plans'), c('feat', 'plans', true)];
|
|
134
|
+
const focus = [c('ci', 'release')];
|
|
135
|
+
expect((0, derive_1.deriveFromCommits)(all, focus)).toEqual({ type: 'ci', scope: 'release', semver: 'major', breaking: true });
|
|
136
|
+
});
|
|
137
|
+
test('R3.3 (amended) an empty, unusable or non-array focus falls back to all commits', () => {
|
|
138
|
+
const all = [c('docs', 'plans'), c('ci', 'release')];
|
|
139
|
+
const expected = (0, derive_1.deriveFromCommits)(all);
|
|
140
|
+
for (const focus of [[], [{ type: 'x y', scope: null, breaking: false, subject: '' }], undefined, null, 'x']) {
|
|
141
|
+
expect((0, derive_1.deriveFromCommits)(all, focus)).toEqual(expected);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
132
144
|
});
|
|
133
145
|
describe('deriveFields in a repository', () => {
|
|
134
146
|
let repo;
|
|
@@ -293,11 +305,29 @@ describe('deriveFields in a repository', () => {
|
|
|
293
305
|
const result = (0, derive_1.deriveFields)(nonRepoDir(), 'a'.repeat(40));
|
|
294
306
|
expect(result).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'git-failed' }) });
|
|
295
307
|
});
|
|
296
|
-
test('deriveFields
|
|
308
|
+
test('deriveFields reports git-failed when only the focus log (the one that drops docs/plans) fails', () => {
|
|
297
309
|
const base = branch();
|
|
310
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'feat(c): a');
|
|
311
|
+
const mb = (0, git_1.mergeBase)(repo.root, base);
|
|
312
|
+
// Only the second `git log` carries the docs/plans exclusion: the full log, HEAD and the diff all succeed.
|
|
313
|
+
const git = (0, helpers_1.spyOnGit)((args) => { if (args.includes(':(top,exclude,literal)docs/plans'))
|
|
314
|
+
throw new Error('focus log failed'); });
|
|
315
|
+
try {
|
|
316
|
+
expect((0, derive_1.deriveFields)(repo.root, mb)).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'git-failed' }) });
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
git.restore();
|
|
320
|
+
}
|
|
321
|
+
expect(git.calls.filter((a) => a[0] === 'log')).toHaveLength(2);
|
|
322
|
+
});
|
|
323
|
+
test('deriveFields works from a subdirectory of the repository, and still drops the top-level docs/plans there', () => {
|
|
324
|
+
const base = branch();
|
|
325
|
+
// Two plan-only commits: were docs/plans read relative to cli/ (no `top`), they would choose the scope.
|
|
326
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
|
|
327
|
+
repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend it');
|
|
298
328
|
repo.commit({ 'cli/src/a.ts': '1', 'top.ts': '1' }, 'feat(c): top and sub');
|
|
299
329
|
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'] } });
|
|
330
|
+
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', 'docs/plans'] } });
|
|
301
331
|
});
|
|
302
332
|
test('R3.3 a signed breaking commit is derived even with log.showSignature on', () => {
|
|
303
333
|
const base = branch();
|
|
@@ -432,6 +462,49 @@ describe('deriveFields in a repository', () => {
|
|
|
432
462
|
}
|
|
433
463
|
expect(moved).toBe(true);
|
|
434
464
|
});
|
|
465
|
+
test('R3.3 (amended) plan-only commits do not choose type or scope (the #228 shape)', () => {
|
|
466
|
+
const base = branch();
|
|
467
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
|
|
468
|
+
repo.commit({ '.github/workflows/ci.yml': '1' }, 'ci(release): shard');
|
|
469
|
+
repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend A1');
|
|
470
|
+
repo.commit({ 'docs/plans/p.md': '3' }, 'docs(plans): amend A2');
|
|
471
|
+
repo.commit({ '.github/workflows/release.yml': '1' }, 'ci(release): gate it');
|
|
472
|
+
repo.commit({ 'docs/decisions.md': '1' }, 'docs(decisions): record it');
|
|
473
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'ci', scope: 'release', semver: 'none', breaking: false, modules: ['.github/workflows', 'docs', 'docs/plans'] } });
|
|
474
|
+
});
|
|
475
|
+
test('R3.3 (amended) a plan-only commit still counts for semver and breaking', () => {
|
|
476
|
+
const base = branch();
|
|
477
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'feat(plans)!: plan a breaking change');
|
|
478
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'fix(process): code');
|
|
479
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'process', semver: 'major', breaking: true, modules: ['cli/src', 'docs/plans'] } });
|
|
480
|
+
});
|
|
481
|
+
test('R3.3 (amended) a commit touching docs/plans and code counts for type and scope', () => {
|
|
482
|
+
const base = branch();
|
|
483
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'chore(x): first');
|
|
484
|
+
repo.commit({ 'docs/plans/p.md': '1', 'cli/src/b.ts': '1' }, 'docs(plans): mixed');
|
|
485
|
+
repo.commit({ 'docs/plans/p.md': '2', 'cli/src/c.ts': '1' }, 'docs(plans): mixed again');
|
|
486
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toMatchObject({ ok: true, fields: { type: 'chore', scope: 'plans' } });
|
|
487
|
+
});
|
|
488
|
+
test('R3.3 (amended) a pure planning branch keeps type and scope from its plan commits', () => {
|
|
489
|
+
const base = branch();
|
|
490
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan only');
|
|
491
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'docs', scope: 'plans', semver: 'none', breaking: false, modules: ['docs/plans'] } });
|
|
492
|
+
});
|
|
493
|
+
test('R3.3 (amended) only the top-level docs/plans is excluded, not similarly named directories', () => {
|
|
494
|
+
const base = branch();
|
|
495
|
+
// docs/plans-old decides the type (the only feat) and cli/docs/plans the scope (nested 2 against archive 1): excluding
|
|
496
|
+
// either directory as well would change the result (chore, or the archive/nested tie broken to archive).
|
|
497
|
+
repo.commit({ 'docs/plans-old/p.md': '1' }, 'feat(archive): old plans');
|
|
498
|
+
repo.commit({ 'cli/docs/plans/p.md': '1' }, 'chore(nested): nested plans');
|
|
499
|
+
repo.commit({ 'cli/src/a.ts': '1' }, 'chore(nested): code');
|
|
500
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toMatchObject({ ok: true, fields: { type: 'feat', scope: 'nested' } });
|
|
501
|
+
});
|
|
502
|
+
test('R3.3 (amended) non-conventional code commits leave type and scope to every conventional commit, here the plan', () => {
|
|
503
|
+
const base = branch();
|
|
504
|
+
repo.commit({ 'cli/x.ts': '1' }, 'Update the code');
|
|
505
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
|
|
506
|
+
expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'docs', scope: 'plans', semver: 'none', breaking: false, modules: ['cli', 'docs/plans'] } });
|
|
507
|
+
});
|
|
435
508
|
});
|
|
436
509
|
describe('commitsFromRecords: hostile subjects are never handed to the release parser (R3.3), in linear time', () => {
|
|
437
510
|
// The release parser's header regex backtracks quadratically on blanks followed by a line terminator its `.` cannot cross, so a
|
|
@@ -68,7 +68,7 @@ describe('git functions in a repository', () => {
|
|
|
68
68
|
test('returns null for a directory that does not exist', () => {
|
|
69
69
|
expect((0, git_1.runGit)(path_1.default.join(repo.home, 'missing'), ['status'])).toBeNull();
|
|
70
70
|
});
|
|
71
|
-
test('R3.1 resolves base: explicit, origin/HEAD, main, master', () => {
|
|
71
|
+
test('R3.1 resolves base: explicit, origin/HEAD, origin/main, origin/master, main, master', () => {
|
|
72
72
|
branch();
|
|
73
73
|
expect(baseOf(repo.root)).toBe('main');
|
|
74
74
|
expect(baseOf(repo.root, 'main')).toBe('main');
|
|
@@ -76,6 +76,39 @@ describe('git functions in a repository', () => {
|
|
|
76
76
|
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
77
77
|
expect(baseOf(repo.root)).toBe('refs/remotes/origin/HEAD');
|
|
78
78
|
});
|
|
79
|
+
test('R3.1 without origin/HEAD, origin/main wins over a local main', () => {
|
|
80
|
+
branch();
|
|
81
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
82
|
+
expect(baseOf(repo.root)).toBe('refs/remotes/origin/main');
|
|
83
|
+
});
|
|
84
|
+
test('R3.1 origin/master is used when there is no origin/HEAD nor origin/main, before a local main', () => {
|
|
85
|
+
branch();
|
|
86
|
+
repo.git('update-ref', 'refs/remotes/origin/master', 'main');
|
|
87
|
+
expect(baseOf(repo.root)).toBe('refs/remotes/origin/master');
|
|
88
|
+
});
|
|
89
|
+
test('R3.1 with origin/main and origin/master on different commits and no origin/HEAD, origin/main is the base', () => {
|
|
90
|
+
branch();
|
|
91
|
+
repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
92
|
+
repo.git('update-ref', 'refs/remotes/origin/master', 'main');
|
|
93
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
|
94
|
+
expect(repo.git('rev-parse', 'refs/remotes/origin/main')).not.toBe(repo.git('rev-parse', 'refs/remotes/origin/master'));
|
|
95
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'refs/remotes/origin/main', sha: repo.git('rev-parse', 'refs/remotes/origin/main') });
|
|
96
|
+
});
|
|
97
|
+
test('R3.1 origin/HEAD still wins over origin/main', () => {
|
|
98
|
+
branch();
|
|
99
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
100
|
+
repo.git('update-ref', 'refs/remotes/origin/trunk', 'main');
|
|
101
|
+
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
|
|
102
|
+
expect(baseOf(repo.root)).toBe('refs/remotes/origin/HEAD');
|
|
103
|
+
});
|
|
104
|
+
test('R3.1 a local branch named origin/main is not taken for the remote candidate', () => {
|
|
105
|
+
repo.git('branch', 'origin/main');
|
|
106
|
+
expect(baseOf(repo.root)).toBe('main');
|
|
107
|
+
});
|
|
108
|
+
test('R3.1 an explicit base never falls back to origin/main', () => {
|
|
109
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
110
|
+
expect(baseOf(repo.root, 'nope')).toBeNull();
|
|
111
|
+
});
|
|
79
112
|
test('R3.1 an explicit base wins over origin/HEAD and main', () => {
|
|
80
113
|
branch();
|
|
81
114
|
repo.git('branch', 'release');
|
|
@@ -89,6 +122,55 @@ describe('git functions in a repository', () => {
|
|
|
89
122
|
repo.git('branch', '-m', 'main', 'trunk');
|
|
90
123
|
expect(baseOf(repo.root)).toBe('master');
|
|
91
124
|
});
|
|
125
|
+
test.each(['main', 'master'])('R3.1 (Q10) a tag named %s does not redirect the local default, and the base keeps its short name', (name) => {
|
|
126
|
+
branch();
|
|
127
|
+
if (name === 'master')
|
|
128
|
+
repo.git('branch', '-m', 'main', 'master');
|
|
129
|
+
const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
|
|
130
|
+
repo.git('tag', name, tagged);
|
|
131
|
+
expect(repo.git('rev-parse', name)).toBe(tagged); // git alone prefers the tag
|
|
132
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: name, sha: repo.git('rev-parse', `refs/heads/${name}`) });
|
|
133
|
+
});
|
|
134
|
+
test('R3.1 a tag literally named refs/heads/main is not the absent branch main: the base is master', () => {
|
|
135
|
+
branch();
|
|
136
|
+
repo.git('branch', '-m', 'main', 'master');
|
|
137
|
+
const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
|
|
138
|
+
repo.git('tag', 'refs/heads/main', tagged);
|
|
139
|
+
expect(repo.git('rev-parse', 'refs/heads/main')).toBe(tagged); // git alone falls through to refs/tags/refs/heads/main
|
|
140
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'master', sha: repo.git('rev-parse', 'refs/heads/master') });
|
|
141
|
+
});
|
|
142
|
+
test('R3.1 a tag literally named refs/remotes/origin/main is not the absent remote branch: the base is main', () => {
|
|
143
|
+
branch();
|
|
144
|
+
const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
|
|
145
|
+
repo.git('tag', 'refs/remotes/origin/main', tagged);
|
|
146
|
+
expect(repo.git('rev-parse', 'refs/remotes/origin/main')).toBe(tagged);
|
|
147
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'main', sha: repo.git('rev-parse', 'refs/heads/main') });
|
|
148
|
+
});
|
|
149
|
+
test('R3.1 only an exact refname counts: a branch main/x does not make refs/heads/main exist (a tag of that name is not it)', () => {
|
|
150
|
+
branch();
|
|
151
|
+
repo.git('branch', '-m', 'main', 'master');
|
|
152
|
+
repo.git('branch', 'main/x', 'master'); // `for-each-ref refs/heads/main` lists refs/heads/main/x
|
|
153
|
+
const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
|
|
154
|
+
repo.git('tag', 'refs/heads/main', tagged);
|
|
155
|
+
expect(repo.git('for-each-ref', '--format=%(refname)', 'refs/heads/main')).toBe('refs/heads/main/x');
|
|
156
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'master', sha: repo.git('rev-parse', 'refs/heads/master') });
|
|
157
|
+
});
|
|
158
|
+
test('R3.1 when git cannot list the default refs, no default is taken (fail closed)', () => {
|
|
159
|
+
const git = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref'))
|
|
160
|
+
throw new Error('boom'); });
|
|
161
|
+
try {
|
|
162
|
+
expect((0, git_1.resolveBaseCommit)(repo.root)).toBeNull();
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
git.restore();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
test('R3.1 an explicit base keeps git\'s own resolution (a tag counts)', () => {
|
|
169
|
+
branch();
|
|
170
|
+
const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
|
|
171
|
+
repo.git('tag', 'v1', tagged);
|
|
172
|
+
expect((0, git_1.resolveBaseCommit)(repo.root, 'v1')).toEqual({ base: 'v1', sha: tagged });
|
|
173
|
+
});
|
|
92
174
|
test('R3.1 origin/HEAD that points nowhere is skipped', () => {
|
|
93
175
|
repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/gone');
|
|
94
176
|
expect(baseOf(repo.root)).toBe('main');
|
|
@@ -28,14 +28,15 @@ describe('listChanges: input and base (R5.1, R3.1, R3.2, R3.5)', () => {
|
|
|
28
28
|
adopt();
|
|
29
29
|
expect(listed().base).toBe('main');
|
|
30
30
|
});
|
|
31
|
-
test('the default base costs one rev-parse per candidate
|
|
31
|
+
test('the default base costs one for-each-ref for all candidates and one rev-parse per existing candidate, not a second one to pin it', () => {
|
|
32
32
|
adopt();
|
|
33
33
|
const spy = (0, helpers_1.spyOnGit)();
|
|
34
34
|
listed();
|
|
35
35
|
spy.restore();
|
|
36
36
|
const asked = (ref) => spy.calls.filter((a) => a.includes(`${ref}^{commit}`)).length;
|
|
37
|
-
expect(
|
|
38
|
-
expect(asked('
|
|
37
|
+
expect(spy.calls.filter((a) => a.includes('for-each-ref') && a.includes('refs/heads/main'))).toHaveLength(1);
|
|
38
|
+
expect(asked('refs/remotes/origin/HEAD')).toBe(0); // absent: never handed to rev-parse
|
|
39
|
+
expect(asked('refs/heads/main')).toBe(1); // the local default resolves as the full ref (Q10)
|
|
39
40
|
});
|
|
40
41
|
test('an explicit base is used and reported', () => {
|
|
41
42
|
adopt();
|
|
@@ -568,7 +569,7 @@ describe('listChanges: derived fields (R5.5)', () => {
|
|
|
568
569
|
const spy = (0, helpers_1.spyOnGit)();
|
|
569
570
|
listed();
|
|
570
571
|
spy.restore();
|
|
571
|
-
const lookups = spy.calls.filter((a) => a.includes('for-each-ref'));
|
|
572
|
+
const lookups = spy.calls.filter((a) => a.includes('for-each-ref') && a.includes('refs/tags/')); // the tag lookup, not the base's
|
|
572
573
|
expect(lookups).toHaveLength(1);
|
|
573
574
|
expect(lookups[0]).toContain('--count=500');
|
|
574
575
|
});
|
|
@@ -610,7 +611,7 @@ describe('listChanges: derived fields (R5.5)', () => {
|
|
|
610
611
|
test('a git failure while looking for the tags is git-failed, never a release of null', () => {
|
|
611
612
|
adopt();
|
|
612
613
|
repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
|
|
613
|
-
const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref'))
|
|
614
|
+
const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref') && args.includes('refs/tags/'))
|
|
614
615
|
throw new Error('boom'); });
|
|
615
616
|
const outcome = list();
|
|
616
617
|
spy.restore();
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
3
8
|
const helpers_1 = require("./helpers");
|
|
4
9
|
const command_1 = require("../../../src/core/changes/command");
|
|
10
|
+
const git_1 = require("../../../src/core/changes/git");
|
|
5
11
|
const range_1 = require("../../../src/core/changes/range");
|
|
6
12
|
let repo;
|
|
7
13
|
/** A repository for the groups that need one (the pure recordDirProblem tests need none). */
|
|
@@ -40,11 +46,94 @@ describe('resolveRange', () => {
|
|
|
40
46
|
expect((0, range_1.resolveRange)(repo.root, 'main')).toMatchObject({ ok: false, failure: { code: 'base-unresolved', remedy: 'pass --base <ref>' } });
|
|
41
47
|
expect((0, range_1.resolveRange)(repo.root, 'main').failure.message).toContain('no merge base');
|
|
42
48
|
});
|
|
49
|
+
/** A commit on its own branch from main, made shallow (no parents as far as git can tell): it shares no merge base with feat/x. */
|
|
50
|
+
function shallowCommit() {
|
|
51
|
+
repo.git('checkout', '-q', '-b', 'up', 'main');
|
|
52
|
+
const up = repo.commit({ 'up.ts': '1' }, 'feat: upstream');
|
|
53
|
+
repo.git('checkout', '-q', 'feat/x');
|
|
54
|
+
repo.git('branch', '-D', 'up');
|
|
55
|
+
fs_1.default.writeFileSync(path_1.default.join(repo.root, '.git', 'shallow'), `${up}\n`);
|
|
56
|
+
return up;
|
|
57
|
+
}
|
|
58
|
+
test('R3.1 (Q7) without --base, a shallow origin/main with no merge base gives way to a connected local main', () => {
|
|
59
|
+
const main = (0, helpers_1.branchOff)(repo);
|
|
60
|
+
const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
61
|
+
const up = shallowCommit();
|
|
62
|
+
repo.git('update-ref', 'refs/remotes/origin/main', up);
|
|
63
|
+
expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: main });
|
|
64
|
+
// `changes list` needs no merge base: it keeps the first candidate that names a commit.
|
|
65
|
+
expect((0, range_1.resolveBaseRef)(repo.root, undefined)).toEqual({ ok: true, base: 'refs/remotes/origin/main', sha: up });
|
|
66
|
+
});
|
|
67
|
+
test('R3.1 (Q7) an explicit --base without a merge base is base-unresolved: no fallback to the defaults', () => {
|
|
68
|
+
(0, helpers_1.branchOff)(repo);
|
|
69
|
+
repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
70
|
+
repo.git('branch', 'shallow', shallowCommit());
|
|
71
|
+
expect((0, range_1.resolveRange)(repo.root, 'shallow')).toEqual({ ok: false, failure: { code: 'base-unresolved', message: 'no merge base between "shallow" and HEAD', remedy: 'pass --base <ref>' } });
|
|
72
|
+
});
|
|
73
|
+
test('R3.1 (Q7) without --base, when no default that names a commit has a merge base, the failure names the first of them', () => {
|
|
74
|
+
(0, helpers_1.branchOff)(repo);
|
|
75
|
+
repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
76
|
+
const up = shallowCommit();
|
|
77
|
+
repo.git('update-ref', 'refs/remotes/origin/main', up);
|
|
78
|
+
repo.git('update-ref', 'refs/heads/main', up);
|
|
79
|
+
expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: false, failure: { code: 'base-unresolved', message: 'no merge base between "refs/remotes/origin/main" and HEAD', remedy: 'pass --base <ref>' } });
|
|
80
|
+
});
|
|
81
|
+
test('R3.1 without --base, a tag literally named refs/heads/main is not taken for the absent branch main', () => {
|
|
82
|
+
(0, helpers_1.branchOff)(repo);
|
|
83
|
+
const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
84
|
+
const master = repo.git('rev-parse', 'main');
|
|
85
|
+
repo.git('branch', '-m', 'main', 'master');
|
|
86
|
+
repo.git('tag', 'refs/heads/main', head);
|
|
87
|
+
expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: master });
|
|
88
|
+
});
|
|
89
|
+
test('R3.1 (Q9) the merge base comes from the commit id the base resolved to, not from the base name read again', () => {
|
|
90
|
+
const main = (0, helpers_1.branchOff)(repo);
|
|
91
|
+
const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
|
|
92
|
+
const elsewhere = shallowCommit();
|
|
93
|
+
let moved = false;
|
|
94
|
+
const git = (0, helpers_1.spyOnGit)((args) => {
|
|
95
|
+
if (!moved && args[0] === 'merge-base') {
|
|
96
|
+
moved = true;
|
|
97
|
+
repo.git('update-ref', 'refs/heads/main', elsewhere);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
try {
|
|
101
|
+
expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: main });
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
git.restore();
|
|
105
|
+
}
|
|
106
|
+
expect(moved).toBe(true);
|
|
107
|
+
});
|
|
43
108
|
test('an unreadable HEAD is git-failed', () => {
|
|
44
109
|
expect((0, range_1.resolveRange)((0, helpers_1.nonRepoDir)(repo), undefined)).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
|
|
45
110
|
expect((0, range_1.resolveRange)((0, helpers_1.bad)(undefined), undefined)).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
|
|
46
111
|
});
|
|
47
112
|
});
|
|
113
|
+
describe('BASE_OPTION_HELP (R3.1, Q6)', () => {
|
|
114
|
+
test('names the default candidates in order', () => {
|
|
115
|
+
expect(range_1.BASE_OPTION_HELP).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
|
|
116
|
+
});
|
|
117
|
+
test('is built from the candidate list, not written by hand', () => {
|
|
118
|
+
jest.isolateModules(() => {
|
|
119
|
+
jest.doMock('../../../src/core/changes/git', () => ({
|
|
120
|
+
...jest.requireActual('../../../src/core/changes/git'),
|
|
121
|
+
DEFAULT_BASES: [{ ref: 'refs/remotes/origin/trunk', name: 'refs/remotes/origin/trunk' }, { ref: 'refs/heads/dev', name: 'dev' }],
|
|
122
|
+
}));
|
|
123
|
+
expect(require('../../../src/core/changes/range').BASE_OPTION_HELP).toBe('base branch (default: origin/trunk, dev)');
|
|
124
|
+
});
|
|
125
|
+
jest.dontMock('../../../src/core/changes/git');
|
|
126
|
+
});
|
|
127
|
+
test('the default candidates are the remote ones, then the local branches as full refs that report their short name', () => {
|
|
128
|
+
expect(git_1.DEFAULT_BASES).toEqual([
|
|
129
|
+
{ ref: 'refs/remotes/origin/HEAD', name: 'refs/remotes/origin/HEAD' },
|
|
130
|
+
{ ref: 'refs/remotes/origin/main', name: 'refs/remotes/origin/main' },
|
|
131
|
+
{ ref: 'refs/remotes/origin/master', name: 'refs/remotes/origin/master' },
|
|
132
|
+
{ ref: 'refs/heads/main', name: 'main' },
|
|
133
|
+
{ ref: 'refs/heads/master', name: 'master' },
|
|
134
|
+
]);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
48
137
|
describe('headMovedFailure', () => {
|
|
49
138
|
withRepo();
|
|
50
139
|
test('HEAD moving has its own code, and it is an operational failure (exit 2) like the others', () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
// FH-1 / FH-2 / FH-3: linear plain-scalar comment strip, YAML-aligned
|
|
7
|
+
// space/tab-before-# vs NBSP, and exact `---` closer for matchFrontmatterBlock.
|
|
8
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
9
|
+
const frontmatter_1 = require("../../src/core/frontmatter");
|
|
10
|
+
const helpers_1 = require("./changes/helpers");
|
|
11
|
+
describe('frontmatter hardening (FH-1, FH-2, FH-3)', () => {
|
|
12
|
+
describe('FH-3: matchFrontmatterBlock closes only on exact ---', () => {
|
|
13
|
+
it('keeps keys after a ---x line inside the block', () => {
|
|
14
|
+
const raw = '---\nname: a\n---x: 1\nstatus: b\n---\n\nbody\n';
|
|
15
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
|
|
16
|
+
expect(block).not.toBeNull();
|
|
17
|
+
expect(block).toContain('---x: 1');
|
|
18
|
+
expect(block).toContain('status: b');
|
|
19
|
+
});
|
|
20
|
+
it('closes on --- with trailing spaces', () => {
|
|
21
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)('---\nname: a\n--- \n\nbody\n');
|
|
22
|
+
expect(block).toBe('name: a');
|
|
23
|
+
});
|
|
24
|
+
it('closes on --- with trailing tabs and CRLF', () => {
|
|
25
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)('---\r\nname: a\r\n---\t\t\r\n\r\nbody\r\n');
|
|
26
|
+
expect(block).toBe('name: a');
|
|
27
|
+
});
|
|
28
|
+
it('does not close on ---#comment', () => {
|
|
29
|
+
const raw = '---\nname: a\n---#comment\nstatus: b\n---\n\nbody\n';
|
|
30
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
|
|
31
|
+
expect(block).not.toBeNull();
|
|
32
|
+
expect(block).toContain('---#comment');
|
|
33
|
+
expect(block).toContain('status: b');
|
|
34
|
+
});
|
|
35
|
+
it('yields empty string for empty frontmatter', () => {
|
|
36
|
+
expect((0, frontmatter_1.matchFrontmatterBlock)('---\n---\n')).toBe('');
|
|
37
|
+
});
|
|
38
|
+
it('does not close on --- followed by NBSP', () => {
|
|
39
|
+
const raw = '---\nname: a\n---\u00A0\nstatus: b\n---\n\nbody\n';
|
|
40
|
+
const block = (0, frontmatter_1.matchFrontmatterBlock)(raw);
|
|
41
|
+
expect(block).not.toBeNull();
|
|
42
|
+
expect(block).toContain('---\u00A0');
|
|
43
|
+
expect(block).toContain('status: b');
|
|
44
|
+
});
|
|
45
|
+
it('returns null quickly when open frontmatter would exceed 65536 without a closer', () => {
|
|
46
|
+
const huge = `---\n${'x'.repeat(70_000)}`;
|
|
47
|
+
const started = Date.now();
|
|
48
|
+
expect((0, frontmatter_1.matchFrontmatterBlock)(huge)).toBeNull();
|
|
49
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
50
|
+
});
|
|
51
|
+
it('returns null when a closer exists only after inner content would exceed 65536', () => {
|
|
52
|
+
// Without the allocation bound, this would return a 65537-char block.
|
|
53
|
+
const raw = `---\n${'x'.repeat(65_537)}\n---\n`;
|
|
54
|
+
const started = Date.now();
|
|
55
|
+
expect((0, frontmatter_1.matchFrontmatterBlock)(raw)).toBeNull();
|
|
56
|
+
expect(Date.now() - started).toBeLessThan(500);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('FH-2: comment strip is space/tab only (not NBSP)', () => {
|
|
60
|
+
it('drops trailing comment after space before #', () => {
|
|
61
|
+
expect((0, frontmatter_1.findFrontmatterDescription)('description: hello # drop').value).toBe('hello');
|
|
62
|
+
});
|
|
63
|
+
it('drops trailing comment after tab before #', () => {
|
|
64
|
+
expect((0, frontmatter_1.findFrontmatterDescription)('description: hello\t# drop').value).toBe('hello');
|
|
65
|
+
});
|
|
66
|
+
it('keeps NBSP before # (matches js-yaml)', () => {
|
|
67
|
+
const fm = 'description: hello\u00A0# keep';
|
|
68
|
+
const ours = (0, frontmatter_1.findFrontmatterDescription)(fm).value;
|
|
69
|
+
const doc = js_yaml_1.default.load(fm);
|
|
70
|
+
expect(ours).toBe(doc.description);
|
|
71
|
+
expect(ours).toContain('\u00A0');
|
|
72
|
+
expect(ours).toContain('# keep');
|
|
73
|
+
});
|
|
74
|
+
it('keeps # with no whitespace before it (C#)', () => {
|
|
75
|
+
expect((0, frontmatter_1.findFrontmatterDescription)('description: usar C# y F#').value).toBe('usar C# y F#');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('FH-1: plain description comment strip is linear-time', () => {
|
|
79
|
+
it('scales linearly with long runs of spaces and no #', () => {
|
|
80
|
+
(0, helpers_1.expectLinearScaling)((n) => {
|
|
81
|
+
(0, frontmatter_1.findFrontmatterDescription)('description: a' + ' '.repeat(n) + 'x');
|
|
82
|
+
}, 20_000);
|
|
83
|
+
});
|
|
84
|
+
it('scales linearly with long runs of tabs and no #', () => {
|
|
85
|
+
(0, helpers_1.expectLinearScaling)((n) => {
|
|
86
|
+
(0, frontmatter_1.findFrontmatterDescription)('description: a' + '\t'.repeat(n) + 'x');
|
|
87
|
+
}, 20_000);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|