agentic-workflow-manager 9.20.1 → 9.20.2
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/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/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
|
@@ -475,7 +475,7 @@ function registerChangeCommand(program, deps = {}) {
|
|
|
475
475
|
change
|
|
476
476
|
.command('new <slug>')
|
|
477
477
|
.description('create docs/changes/YYYY-MM-DD-<slug>.md with derived fields and pending narrative')
|
|
478
|
-
.option('--base <ref>',
|
|
478
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
479
479
|
.option('--plan <path>', 'repo-relative plan file to link')
|
|
480
480
|
.option('--issue <url>', 'https issue URL to link')
|
|
481
481
|
.option('--model <model>', 'provenance: model identifier')
|
|
@@ -485,7 +485,7 @@ function registerChangeCommand(program, deps = {}) {
|
|
|
485
485
|
change
|
|
486
486
|
.command('validate [path]')
|
|
487
487
|
.description('validate the Change Record(s) added by this branch, or one record (--strict also checks the sensor receipt)')
|
|
488
|
-
.option('--base <ref>',
|
|
488
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
489
489
|
.option('--strict', 'require a matching, passing sensor receipt for verification.commit')
|
|
490
490
|
.option('--json', 'print a single JSON line')
|
|
491
491
|
.action((target, opts) => inCwd((cwd) => runChangeValidate(cwd, target, opts), wantsJson(opts)));
|
|
@@ -10,6 +10,7 @@ const command_1 = require("../../core/changes/command");
|
|
|
10
10
|
const git_1 = require("../../core/changes/git");
|
|
11
11
|
const list_1 = require("../../core/changes/list");
|
|
12
12
|
const list_parse_1 = require("../../core/changes/list-parse");
|
|
13
|
+
const range_1 = require("../../core/changes/range");
|
|
13
14
|
const render_list_1 = require("../../core/changes/render-list");
|
|
14
15
|
const text_safety_1 = require("../../core/changes/text-safety");
|
|
15
16
|
const FORMATS = ['text', 'json', 'voice'];
|
|
@@ -78,7 +79,7 @@ function registerChangesCommand(program, deps = {}) {
|
|
|
78
79
|
.command('list')
|
|
79
80
|
.description('list the changes of the base branch with their Change Record and derived merge/release state')
|
|
80
81
|
.option('--since <window>', '<n>h, <n>d or YYYY-MM-DD (default 24h)')
|
|
81
|
-
.option('--base <ref>',
|
|
82
|
+
.option('--base <ref>', range_1.BASE_OPTION_HELP)
|
|
82
83
|
.option('--format <format>', 'text | json | voice (default text)')
|
|
83
84
|
.action((opts) => (0, command_1.inCwd)((cwd) => runChangesList(cwd, opts, deps), () => (0, command_1.fail)('not-a-git-repo', 'the working directory is not accessible')));
|
|
84
85
|
}
|
|
@@ -15,6 +15,7 @@ const types_1 = require("./types");
|
|
|
15
15
|
// Pathspecs relative to the repository top, so the result does not depend on the directory git runs in.
|
|
16
16
|
const ALL_FILES = ':/';
|
|
17
17
|
const EXCLUDE_RECORDS = `:(top,exclude,literal)${types_1.RECORD_DIR}`;
|
|
18
|
+
const EXCLUDE_PLANS = ':(top,exclude,literal)docs/plans';
|
|
18
19
|
const PARSER_TYPE = /^\w+$/;
|
|
19
20
|
const RECORD_TYPE = /^[a-z]+$/;
|
|
20
21
|
const MODULE_DEPTH = 3;
|
|
@@ -39,18 +40,22 @@ function usable(commits) {
|
|
|
39
40
|
}
|
|
40
41
|
const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
41
42
|
/**
|
|
42
|
-
* R3.3. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics
|
|
43
|
-
*
|
|
43
|
+
* R3.3 as amended 2026-10-19. `breaking` and `semver` cover every usable commit, with the release parser's exact-type semantics.
|
|
44
|
+
* `type` and `scope` come from the usable `focus` commits (those touching a path outside docs/changes/ and docs/plans/), or from
|
|
45
|
+
* every usable commit when `focus` is omitted or has none. The recorded type is the highest-impact chosen commit's type
|
|
46
|
+
* lowercased, or `other` when that is still not purely letters. Null when no commit is usable.
|
|
44
47
|
*/
|
|
45
|
-
function deriveFromCommits(input) {
|
|
48
|
+
function deriveFromCommits(input, focusInput) {
|
|
46
49
|
const commits = usable(input);
|
|
47
50
|
if (commits.length === 0)
|
|
48
51
|
return null;
|
|
52
|
+
const focused = usable(focusInput);
|
|
53
|
+
const chooser = focused.length > 0 ? focused : commits;
|
|
49
54
|
const breaking = commits.some((c) => c.breaking);
|
|
50
|
-
const pick =
|
|
51
|
-
??
|
|
55
|
+
const pick = chooser.find((c) => c.breaking) ?? chooser.find((c) => c.type === 'feat') ?? chooser.find((c) => c.type === 'fix')
|
|
56
|
+
?? chooser.find((c) => c.type === 'perf') ?? chooser[0];
|
|
52
57
|
const counts = new Map();
|
|
53
|
-
for (const c of
|
|
58
|
+
for (const c of chooser)
|
|
54
59
|
if (c.scope)
|
|
55
60
|
counts.set(c.scope, (counts.get(c.scope) ?? 0) + 1);
|
|
56
61
|
const scope = [...counts.entries()].sort((a, b) => b[1] - a[1] || byCodeUnit(a[0], b[0]))[0]?.[0] ?? null;
|
|
@@ -87,6 +92,8 @@ function commitsFromRecords(records) {
|
|
|
87
92
|
const fail = (code, message) => ({ ok: false, diagnostic: { code, message: (0, text_safety_1.clipMessage)(message) } });
|
|
88
93
|
/**
|
|
89
94
|
* Derives from the non-merge commits in mergeBaseSha..HEAD, ignoring commits and files that only touch docs/changes/.
|
|
95
|
+
* R3.3 amended: `type` and `scope` come from the commits that also touch a path outside docs/plans/ (all of them when no
|
|
96
|
+
* conventional commit does), while `semver` and `breaking` cover every commit.
|
|
90
97
|
* `mergeBaseSha` must be a full commit id (the output of `mergeBase`); anything else is `invalid-input`.
|
|
91
98
|
*/
|
|
92
99
|
function deriveFields(root, mergeBaseSha) {
|
|
@@ -103,14 +110,20 @@ function deriveFields(root, mergeBaseSha) {
|
|
|
103
110
|
// (`merge -s ours`), silently dropping real commits from the range.
|
|
104
111
|
// -z ends every commit with NUL, which a message cannot contain: each record is exactly one commit, so a message that
|
|
105
112
|
// embeds the parser's own separator cannot be read as a second commit.
|
|
106
|
-
const
|
|
113
|
+
const logArgs = ['log', ...git_1.LOG_SAFETY_ARGS, '--no-merges', '--full-history', '--reverse', '-z', `--format=${core_1.GIT_LOG_FORMAT}`, '--end-of-options', range, '--', ALL_FILES, EXCLUDE_RECORDS];
|
|
114
|
+
const raw = (0, git_1.runGit)(root, logArgs);
|
|
107
115
|
if (raw === null)
|
|
108
116
|
return fail('git-failed', `git could not read the commits in ${range}`);
|
|
109
117
|
const records = raw.split('\0').filter((record) => record !== '');
|
|
110
118
|
if (records.length === 0)
|
|
111
119
|
return fail('no-commits', `no non-merge commits outside ${types_1.RECORD_DIR}/ in ${range}`);
|
|
112
120
|
const commits = commitsFromRecords(records);
|
|
113
|
-
|
|
121
|
+
// R3.3 amended: plan-only commits do not choose type or scope, so a second log over the same range drops docs/plans/.
|
|
122
|
+
const focusRaw = (0, git_1.runGit)(root, [...logArgs, EXCLUDE_PLANS]);
|
|
123
|
+
if (focusRaw === null)
|
|
124
|
+
return fail('git-failed', `git could not read the commits in ${range}`);
|
|
125
|
+
const focus = commitsFromRecords(focusRaw.split('\0').filter((record) => record !== ''));
|
|
126
|
+
const derived = deriveFromCommits(commits, focus);
|
|
114
127
|
if (derived === null)
|
|
115
128
|
return fail('no-conventional-commits', 'no commit in the range uses a conventional-commit subject (type(scope): subject)');
|
|
116
129
|
const files = (0, git_1.changedFiles)(root, mergeBaseSha, head);
|
|
@@ -3,11 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.LOG_SAFETY_ARGS = void 0;
|
|
6
|
+
exports.DEFAULT_BASES = exports.LOG_SAFETY_ARGS = void 0;
|
|
7
7
|
exports.runGit = runGit;
|
|
8
8
|
exports.runGitBytes = runGitBytes;
|
|
9
9
|
exports.isSafeRef = isSafeRef;
|
|
10
10
|
exports.isSafeRevision = isSafeRevision;
|
|
11
|
+
exports.baseCandidates = baseCandidates;
|
|
11
12
|
exports.resolveBaseCommit = resolveBaseCommit;
|
|
12
13
|
exports.mergeBase = mergeBase;
|
|
13
14
|
exports.isAncestor = isAncestor;
|
|
@@ -81,14 +82,44 @@ function isSafeRevision(rev) {
|
|
|
81
82
|
return parts.length <= 2 && parts.every(isSafeRef);
|
|
82
83
|
}
|
|
83
84
|
/**
|
|
84
|
-
* R3.1:
|
|
85
|
-
*
|
|
85
|
+
* R3.1 (amended 2026-10-19): the default base candidates, remote first so a stale local branch is the last resort. The local
|
|
86
|
+
* branches resolve as full refs (git would otherwise prefer a tag `main` over the branch) but are reported by their short name.
|
|
87
|
+
*/
|
|
88
|
+
exports.DEFAULT_BASES = [
|
|
89
|
+
{ ref: 'refs/remotes/origin/HEAD', name: 'refs/remotes/origin/HEAD' },
|
|
90
|
+
{ ref: 'refs/remotes/origin/main', name: 'refs/remotes/origin/main' },
|
|
91
|
+
{ ref: 'refs/remotes/origin/master', name: 'refs/remotes/origin/master' },
|
|
92
|
+
{ ref: 'refs/heads/main', name: 'main' },
|
|
93
|
+
{ ref: 'refs/heads/master', name: 'master' },
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* The DEFAULT_BASES whose exact ref exists, in order (one `for-each-ref` for all of them; a broken symref is not listed). Without
|
|
97
|
+
* this check git's rev-parse would fall through to `refs/tags/<ref>`, so a tag literally named `refs/heads/main` could stand in for
|
|
98
|
+
* an absent branch. The patterns are constants; only an exact refname match counts (a pattern also lists the refs below it).
|
|
99
|
+
* Empty when git fails: the defaults then fail closed as unresolved.
|
|
100
|
+
*/
|
|
101
|
+
function existingDefaultBases(root) {
|
|
102
|
+
const out = runGit(root, ['for-each-ref', '--format=%(refname)', '--end-of-options', ...exports.DEFAULT_BASES.map((c) => c.ref)]);
|
|
103
|
+
const present = new Set((out ?? '').split('\n'));
|
|
104
|
+
return exports.DEFAULT_BASES.filter((c) => present.has(c.ref));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* R3.1: the candidates for a base, in order. An explicit base is the only one (no fallback) and keeps git's own resolution;
|
|
108
|
+
* otherwise the DEFAULT_BASES whose exact ref exists. Every caller (`resolveBaseCommit`, `resolveRange`) takes its order from here.
|
|
109
|
+
*/
|
|
110
|
+
function baseCandidates(root, explicit) {
|
|
111
|
+
return explicit !== undefined ? [{ ref: explicit, name: explicit }] : existingDefaultBases(root);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* R3.1: --base, then origin/HEAD, origin/main, origin/master, main, master: the first that names a commit, with that commit's id.
|
|
115
|
+
* An explicit base is the only candidate (no fallback). Null when none does (or the explicit base is unsafe). One `rev-parse` per
|
|
116
|
+
* candidate tried (and, for the defaults, one `for-each-ref`).
|
|
86
117
|
*/
|
|
87
118
|
function resolveBaseCommit(root, explicit) {
|
|
88
|
-
for (const
|
|
89
|
-
const sha = commitSha(root,
|
|
119
|
+
for (const { ref, name } of baseCandidates(root, explicit)) {
|
|
120
|
+
const sha = commitSha(root, ref);
|
|
90
121
|
if (sha !== null)
|
|
91
|
-
return { base:
|
|
122
|
+
return { base: name, sha };
|
|
92
123
|
}
|
|
93
124
|
return null;
|
|
94
125
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.HEAD_MOVED = void 0;
|
|
3
|
+
exports.HEAD_MOVED = exports.BASE_OPTION_HELP = void 0;
|
|
4
4
|
exports.resolveBaseRef = resolveBaseRef;
|
|
5
5
|
exports.resolveRange = resolveRange;
|
|
6
6
|
exports.headMovedFailure = headMovedFailure;
|
|
@@ -9,34 +9,49 @@ const git_1 = require("./git");
|
|
|
9
9
|
const text_safety_1 = require("./text-safety");
|
|
10
10
|
const types_1 = require("./types");
|
|
11
11
|
const REMEDY_BASE = 'pass --base <ref>';
|
|
12
|
+
/** R3.1: the `--base` help text of `change new`, `change validate` and `changes list`: `DEFAULT_BASES` (git.ts) in order, remotes without `refs/remotes/`. */
|
|
13
|
+
exports.BASE_OPTION_HELP = `base branch (default: ${git_1.DEFAULT_BASES.map((c) => c.name.replace(/^refs\/remotes\//, '')).join(', ')})`;
|
|
12
14
|
exports.HEAD_MOVED = { code: 'head-moved', message: 'HEAD moved during derivation' };
|
|
13
15
|
const KINDS = ['ok', 'absent', 'unsafe', 'error'];
|
|
14
16
|
const failure = (code, message, remedy) => ({ ok: false, failure: remedy === undefined ? { code, message } : { code, message, remedy } });
|
|
17
|
+
const unsafeBase = (explicitBase) => failure('invalid-input', `--base "${(0, text_safety_1.esc)(explicitBase)}" is not a safe git ref`);
|
|
15
18
|
/**
|
|
16
|
-
* R3.1/R3.2/R3.5
|
|
17
|
-
*
|
|
19
|
+
* R3.1/R3.2/R3.5, for `changes list`: the base ref: `explicitBase` (checked with `isSafeRef` before git sees it: `invalid-input`),
|
|
20
|
+
* else the first default candidate that names a commit (no merge base is needed); `base-unresolved` when none does. The order of the
|
|
21
|
+
* candidates has one source, `baseCandidates` (git.ts), shared with `resolveRange`.
|
|
18
22
|
*/
|
|
19
23
|
function resolveBaseRef(root, explicitBase) {
|
|
20
24
|
if (explicitBase !== undefined && !(0, git_1.isSafeRef)(explicitBase))
|
|
21
|
-
return
|
|
25
|
+
return unsafeBase(explicitBase);
|
|
22
26
|
const resolved = (0, git_1.resolveBaseCommit)(root, explicitBase);
|
|
23
27
|
return resolved === null ? failure('base-unresolved', 'could not resolve a base branch', REMEDY_BASE) : { ok: true, ...resolved };
|
|
24
28
|
}
|
|
25
29
|
/**
|
|
26
|
-
* R3.1/R3.2/R3.5
|
|
27
|
-
* An explicit base
|
|
30
|
+
* R3.1/R3.2/R3.5, for `change new` and `change validate`: pins HEAD, then resolves the base and its merge base with HEAD, over the
|
|
31
|
+
* same `baseCandidates` (git.ts) as `resolveBaseRef` but also requiring a merge base. An explicit `--base` (checked with `isSafeRef`
|
|
32
|
+
* before git sees it) is the only candidate. Without it (R3.1 amended, Q7), the first default candidate that names a commit AND has
|
|
33
|
+
* a merge base with HEAD: a shallow remote ref that does not reach HEAD gives way to the next.
|
|
34
|
+
* The merge base is computed from the commit id the candidate resolved to, never from its name read a second time (Q9).
|
|
28
35
|
*/
|
|
29
36
|
function resolveRange(root, explicitBase) {
|
|
30
37
|
const head = (0, git_1.headSha)(root);
|
|
31
38
|
if (head === null)
|
|
32
39
|
return failure('git-failed', 'git could not read HEAD');
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
if (explicitBase !== undefined && !(0, git_1.isSafeRef)(explicitBase))
|
|
41
|
+
return unsafeBase(explicitBase);
|
|
42
|
+
let named = null;
|
|
43
|
+
for (const { ref, name } of (0, git_1.baseCandidates)(root, explicitBase)) {
|
|
44
|
+
const sha = (0, git_1.commitSha)(root, ref);
|
|
45
|
+
if (sha === null)
|
|
46
|
+
continue;
|
|
47
|
+
named ??= name;
|
|
48
|
+
const mergeBaseSha = (0, git_1.mergeBase)(root, sha);
|
|
49
|
+
if (mergeBaseSha !== null)
|
|
50
|
+
return { ok: true, head, mergeBase: mergeBaseSha };
|
|
51
|
+
}
|
|
52
|
+
if (named === null)
|
|
53
|
+
return failure('base-unresolved', 'could not resolve a base branch', REMEDY_BASE);
|
|
54
|
+
return failure('base-unresolved', `no merge base between "${(0, text_safety_1.esc)(named)}" and HEAD`, REMEDY_BASE);
|
|
40
55
|
}
|
|
41
56
|
/** `HEAD_MOVED` when HEAD is no longer `head` (or cannot be read), else null: a verdict about a moving target is never given. */
|
|
42
57
|
function headMovedFailure(root, head) {
|
|
@@ -781,7 +781,7 @@ describe('awm change new: base and derivation diagnostics (R3.1, R3.2, R3.5)', (
|
|
|
781
781
|
receiptForHead();
|
|
782
782
|
expectFailure(run('ok', { base: 'nope' }), 'base-unresolved', 'pass --base <ref>');
|
|
783
783
|
});
|
|
784
|
-
test('no
|
|
784
|
+
test('no default base resolves: base-unresolved', () => {
|
|
785
785
|
repo.git('branch', '-m', 'main', 'trunk');
|
|
786
786
|
receiptForHead();
|
|
787
787
|
expectFailure(run('ok'), 'base-unresolved', 'pass --base <ref>');
|
|
@@ -794,6 +794,29 @@ describe('awm change new: base and derivation diagnostics (R3.1, R3.2, R3.5)', (
|
|
|
794
794
|
run('change-record', { base: 'mid' });
|
|
795
795
|
expect(frontmatter(read())).toMatchObject({ type: 'fix', scope: 'core', semver: 'patch', modules: ['cli/src'] });
|
|
796
796
|
});
|
|
797
|
+
test('a #228-shaped branch (plan commits first, then ci(release) commits) records type ci and scope release', () => {
|
|
798
|
+
repo.git('checkout', '-q', '-b', 'ci/shard', 'main');
|
|
799
|
+
repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
|
|
800
|
+
repo.commit({ '.github/workflows/ci.yml': '1' }, 'ci(release): shard');
|
|
801
|
+
repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend A1');
|
|
802
|
+
repo.commit({ 'docs/plans/p.md': '3' }, 'docs(plans): amend A2');
|
|
803
|
+
repo.commit({ '.github/workflows/release.yml': '1' }, 'ci(release): gate it');
|
|
804
|
+
receiptForHead();
|
|
805
|
+
expect(run('change-record').code).toBe(0);
|
|
806
|
+
expect(frontmatter(read())).toMatchObject({ type: 'ci', scope: 'release', semver: 'none', breaking: false, modules: ['.github/workflows', 'docs/plans'] });
|
|
807
|
+
});
|
|
808
|
+
test('without origin/HEAD, a current origin/main is the base, not a stale local main', () => {
|
|
809
|
+
repo.git('checkout', '-q', '-b', 'upstream', 'main');
|
|
810
|
+
repo.commit({ 'other/u.ts': '1' }, 'feat(other)!: merged upstream');
|
|
811
|
+
repo.git('update-ref', 'refs/remotes/origin/main', 'upstream');
|
|
812
|
+
repo.git('checkout', '-q', '-b', 'feat/y', 'upstream');
|
|
813
|
+
repo.git('branch', '-D', 'upstream');
|
|
814
|
+
repo.commit({ 'cli/src/m.ts': '1' }, 'fix(core): mine');
|
|
815
|
+
receiptForHead();
|
|
816
|
+
expect(run('change-record').code).toBe(0);
|
|
817
|
+
// Against the stale local main, the upstream breaking feat would be in the range.
|
|
818
|
+
expect(frontmatter(read())).toMatchObject({ type: 'fix', scope: 'core', semver: 'patch', breaking: false, modules: ['cli/src'] });
|
|
819
|
+
});
|
|
797
820
|
test('a branch with no commits beyond the base is no-commits', () => {
|
|
798
821
|
repo.git('checkout', '-q', 'main');
|
|
799
822
|
expectFailure(run('ok'), 'no-commits');
|
|
@@ -1096,6 +1119,10 @@ describe('registerChangeCommand', () => {
|
|
|
1096
1119
|
const change = program().commands.find((c) => c.name() === 'change');
|
|
1097
1120
|
const sub = change?.commands.find((c) => c.name() === 'new');
|
|
1098
1121
|
expect(sub?.options.map((o) => o.long).sort()).toEqual(['--base', '--issue', '--model', '--plan', '--refresh', '--session']);
|
|
1122
|
+
for (const name of ['new', 'validate']) {
|
|
1123
|
+
const base = change?.commands.find((c) => c.name() === name)?.options.find((o) => o.long === '--base');
|
|
1124
|
+
expect(base?.description).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
|
|
1125
|
+
}
|
|
1099
1126
|
});
|
|
1100
1127
|
test('the change command describes both of its subcommands', () => {
|
|
1101
1128
|
expect(program().commands.find((c) => c.name() === 'change')?.description()).toBe('create and validate the Change Record of the current branch');
|
|
@@ -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', () => {
|
|
@@ -5,25 +5,161 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
const child_process_1 = require("child_process");
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
8
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
9
11
|
const CLI_ROOT = path_1.default.resolve(__dirname, '..', '..');
|
|
10
12
|
const REPO_ROOT = path_1.default.resolve(CLI_ROOT, '..');
|
|
11
13
|
function readWorkflow(name) {
|
|
12
14
|
return fs_1.default.readFileSync(path_1.default.join(REPO_ROOT, '.github', 'workflows', name), 'utf8').replace(/\r\n/g, '\n');
|
|
13
15
|
}
|
|
14
|
-
|
|
16
|
+
const HOT_IGNORE = String.raw `node_modules|[/\\]cli[/\\]tests[/\\]commands[/\\]watch[/\\]track-(finalize|freeze)\.test\.ts$`;
|
|
17
|
+
const CHANGES_PATTERN = String.raw `[/\\]cli[/\\]tests[/\\](core[/\\]changes|commands[/\\]changes?)[/\\]`;
|
|
18
|
+
const HOT_STEP = 'Test hot Windows ARM suites';
|
|
19
|
+
const CHANGES_STEP = 'Test Change Record Windows ARM suites';
|
|
20
|
+
const CHANGES_RUN = `npx jest --runInBand --bail --testPathPatterns='${CHANGES_PATTERN}'`;
|
|
21
|
+
const REMAINDER_COMMENT = " # The ARM runner's slow suites run in two separate jobs (watch track-finalize/freeze, and the Change Record suites). Keep runInBand within each job; no test file may run twice or be dropped.\n";
|
|
22
|
+
const HOT_RUN = 'npx jest --runTestsByPath tests/commands/watch/track-finalize.test.ts tests/commands/watch/track-freeze.test.ts --runInBand --bail';
|
|
23
|
+
const RELEASE_GATE = ['test', 'windows-arm-changes', 'windows-arm-hot'];
|
|
24
|
+
const SHARD_SETUP = [
|
|
25
|
+
{ uses: 'actions/checkout@v4' },
|
|
26
|
+
{ uses: 'actions/setup-node@v4', with: { 'node-version': '22' } },
|
|
27
|
+
{ name: 'Install', 'working-directory': 'cli', run: 'npm ci' },
|
|
28
|
+
{ name: 'Build', 'working-directory': 'cli', run: 'npm run native:build && npm run build' },
|
|
29
|
+
{ name: 'Build test-only native addon', 'working-directory': 'cli', run: 'npm run native:test-build' },
|
|
30
|
+
];
|
|
31
|
+
// Jest compiles --testPathPatterns with the `i` flag but --testPathIgnorePatterns
|
|
32
|
+
// without it, so a directory differing only in case would run in both the shard
|
|
33
|
+
// and the remainder. Returns every directory that falls in that gap.
|
|
34
|
+
function caseOnlyShardPaths(directories, pattern) {
|
|
35
|
+
const insensitive = new RegExp(pattern, 'i');
|
|
36
|
+
const sensitive = new RegExp(pattern);
|
|
37
|
+
return directories.filter((directory) => {
|
|
38
|
+
const candidate = `${directory}/`;
|
|
39
|
+
return insensitive.test(candidate) && !sensitive.test(candidate);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const SHARD_DIRECTORIES = [['core', 'changes'], ['commands', 'change'], ['commands', 'changes']];
|
|
43
|
+
// Each real shard directory must exist with its exact case, hold at least one
|
|
44
|
+
// suite and match CHANGES_PATTERN, so the case-only walk cannot pass vacuously.
|
|
45
|
+
function shardDirectoryProblems(testsRoot) {
|
|
46
|
+
const shard = new RegExp(CHANGES_PATTERN);
|
|
47
|
+
const problems = [];
|
|
48
|
+
for (const segments of SHARD_DIRECTORIES) {
|
|
49
|
+
const label = segments.join('/');
|
|
50
|
+
let current = testsRoot;
|
|
51
|
+
let found = true;
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
// readdirSync keeps the on-disk case even on case-insensitive file systems.
|
|
54
|
+
if (!fs_1.default.existsSync(current) || !fs_1.default.readdirSync(current).includes(segment)) {
|
|
55
|
+
found = false;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
current = path_1.default.join(current, segment);
|
|
59
|
+
}
|
|
60
|
+
if (!found) {
|
|
61
|
+
problems.push(`${label}: missing with this exact case`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!fs_1.default.readdirSync(current).some((entry) => entry.endsWith('.test.ts'))) {
|
|
65
|
+
problems.push(`${label}: holds no *.test.ts`);
|
|
66
|
+
}
|
|
67
|
+
if (!shard.test(`${current}${path_1.default.sep}`)) {
|
|
68
|
+
problems.push(`${label}: does not match CHANGES_PATTERN`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return problems;
|
|
72
|
+
}
|
|
73
|
+
function parseJobs(source) {
|
|
74
|
+
const parsed = js_yaml_1.default.load(source);
|
|
75
|
+
expect(parsed?.jobs).toBeDefined();
|
|
76
|
+
return parsed.jobs;
|
|
77
|
+
}
|
|
78
|
+
function getJob(jobs, name) {
|
|
79
|
+
const job = jobs[name];
|
|
80
|
+
expect(job).toBeDefined();
|
|
81
|
+
expect(Array.isArray(job.steps)).toBe(true);
|
|
82
|
+
return job;
|
|
83
|
+
}
|
|
84
|
+
function keysOf(value) {
|
|
85
|
+
return Object.keys(value);
|
|
86
|
+
}
|
|
87
|
+
// The release job publishes only when every test job passed: no if, no
|
|
88
|
+
// continue-on-error, and needs is exactly the three gating jobs in any order.
|
|
89
|
+
function assertReleaseGate(jobs) {
|
|
90
|
+
const release = getJob(jobs, 'release');
|
|
91
|
+
expect(keysOf(release)).not.toContain('if');
|
|
92
|
+
expect(keysOf(release)).not.toContain('continue-on-error');
|
|
93
|
+
for (const step of release.steps) {
|
|
94
|
+
expect(keysOf(step)).not.toContain('continue-on-error');
|
|
95
|
+
}
|
|
96
|
+
expect(Array.isArray(release.needs)).toBe(true);
|
|
97
|
+
const needs = release.needs;
|
|
98
|
+
expect(needs).toHaveLength(RELEASE_GATE.length);
|
|
99
|
+
expect([...needs].sort()).toEqual(RELEASE_GATE);
|
|
100
|
+
}
|
|
101
|
+
// Gate semantics are asserted on the parsed workflow, so key quoting or escaping
|
|
102
|
+
// cannot hide an if or a continue-on-error. Text is checked only where the exact
|
|
103
|
+
// text is the contract: the run lines and the A1 comment.
|
|
104
|
+
function assertArmPartition(source, gatesRelease) {
|
|
15
105
|
const workflow = source.replace(/\r\n/g, '\n');
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
expect(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
106
|
+
const jobs = parseJobs(source);
|
|
107
|
+
const names = keysOf(jobs);
|
|
108
|
+
const test = getJob(jobs, 'test');
|
|
109
|
+
const hot = getJob(jobs, 'windows-arm-hot');
|
|
110
|
+
const changes = getJob(jobs, 'windows-arm-changes');
|
|
111
|
+
expect(names.indexOf('windows-arm-hot')).toBeGreaterThan(names.indexOf('test'));
|
|
112
|
+
expect(names.indexOf('windows-arm-changes')).toBe(names.indexOf('windows-arm-hot') + 1);
|
|
113
|
+
// No gating job tolerates failure, at job or step level.
|
|
114
|
+
for (const job of [test, hot, changes]) {
|
|
115
|
+
expect(keysOf(job)).not.toContain('continue-on-error');
|
|
116
|
+
for (const step of job.steps) {
|
|
117
|
+
expect(keysOf(step)).not.toContain('continue-on-error');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The ARM shards never skip, at job or step level.
|
|
121
|
+
for (const job of [hot, changes]) {
|
|
122
|
+
expect(keysOf(job)).not.toContain('if');
|
|
123
|
+
for (const step of job.steps) {
|
|
124
|
+
expect(keysOf(step)).not.toContain('if');
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// In the matrix, only the two test steps are guarded, with complementary targets.
|
|
128
|
+
const guarded = test.steps.filter((step) => keysOf(step).includes('if'));
|
|
129
|
+
expect(guarded).toHaveLength(2);
|
|
130
|
+
const [regular, remainder] = guarded;
|
|
131
|
+
expect(regular.name).toMatch(/^Tests?$/);
|
|
132
|
+
expect(regular.if).toBe("matrix.target != 'win32-arm64'");
|
|
133
|
+
expect(regular['working-directory']).toBe('cli');
|
|
134
|
+
expect(regular.run).toBe('npx jest --runInBand --bail');
|
|
135
|
+
expect(remainder.name).toMatch(/^Tests? \(Windows ARM remainder\)$/);
|
|
136
|
+
expect(remainder.if).toBe("matrix.target == 'win32-arm64'");
|
|
137
|
+
expect(remainder['working-directory']).toBe('cli');
|
|
138
|
+
const ignorePattern = String(remainder.run).match(/^npx jest --runInBand --bail --testPathIgnorePatterns='([^']+)'$/)?.[1];
|
|
139
|
+
expect(ignorePattern).toBe(`${HOT_IGNORE}|${CHANGES_PATTERN}`);
|
|
140
|
+
expect(test.steps.filter((step) => step.uses === 'actions/upload-artifact@v4')).toHaveLength(1);
|
|
141
|
+
// A1 comment, directly above the remainder step.
|
|
142
|
+
expect(workflow.split(REMAINDER_COMMENT)).toHaveLength(2);
|
|
143
|
+
const aroundRemainder = workflow.split(/ - name: Tests? \(Windows ARM remainder\)\n/);
|
|
144
|
+
expect(aroundRemainder).toHaveLength(2);
|
|
145
|
+
expect(aroundRemainder[0].endsWith(REMAINDER_COMMENT)).toBe(true);
|
|
146
|
+
// Both shards share the same setup; only their test step differs.
|
|
147
|
+
expect(hot['runs-on']).toBe('windows-11-arm');
|
|
148
|
+
expect(hot.steps.slice(0, -1)).toEqual(SHARD_SETUP);
|
|
149
|
+
expect(hot.steps.at(-1)).toEqual({ name: HOT_STEP, 'working-directory': 'cli', run: HOT_RUN });
|
|
150
|
+
expect(changes.steps.at(-1)).toEqual({ name: CHANGES_STEP, 'working-directory': 'cli', run: CHANGES_RUN });
|
|
151
|
+
expect({ ...changes, steps: changes.steps.slice(0, -1) }).toEqual({ ...hot, steps: hot.steps.slice(0, -1) });
|
|
152
|
+
if (gatesRelease) {
|
|
153
|
+
expect(changes.permissions).toEqual({ contents: 'read' });
|
|
154
|
+
}
|
|
155
|
+
for (const job of [hot, changes]) {
|
|
156
|
+
expect(job.steps.some((step) => String(step.uses ?? '').includes('upload-artifact'))).toBe(false);
|
|
157
|
+
expect(JSON.stringify(job)).not.toContain('secure-fs-win32-arm64');
|
|
158
|
+
}
|
|
159
|
+
const lines = workflow.split('\n');
|
|
160
|
+
expect(lines).toContain(` run: ${HOT_RUN}`);
|
|
161
|
+
expect(lines).toContain(` run: ${CHANGES_RUN}`);
|
|
162
|
+
// The remainder ignores exactly the hot suites plus what the Change Record shard runs.
|
|
27
163
|
const ignored = new RegExp(ignorePattern);
|
|
28
164
|
for (const separator of ['/', '\\']) {
|
|
29
165
|
const root = ['repo', 'cli', 'tests'].join(separator);
|
|
@@ -32,18 +168,100 @@ function assertArmPartition(source) {
|
|
|
32
168
|
expect(ignored.test([root, 'other', `${suite}.test.ts`].join(separator))).toBe(false);
|
|
33
169
|
}
|
|
34
170
|
}
|
|
35
|
-
const
|
|
36
|
-
expect(
|
|
37
|
-
expect(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
171
|
+
const shardPattern = String(changes.steps.at(-1)?.run).match(/--testPathPatterns='([^']+)'$/)?.[1];
|
|
172
|
+
expect(shardPattern).toBe(CHANGES_PATTERN);
|
|
173
|
+
expect(ignorePattern).toBe(`${HOT_IGNORE}|${shardPattern}`);
|
|
174
|
+
const shard = new RegExp(shardPattern, 'i');
|
|
175
|
+
for (const separator of ['/', '\\']) {
|
|
176
|
+
const root = ['repo', 'cli', 'tests'].join(separator);
|
|
177
|
+
for (const file of [['core', 'changes', 'x.test.ts'], ['commands', 'change', 'new.test.ts'], ['commands', 'changes', 'list.test.ts']]) {
|
|
178
|
+
const candidate = [root, ...file].join(separator);
|
|
179
|
+
expect(shard.test(candidate)).toBe(true);
|
|
180
|
+
expect(ignored.test(candidate)).toBe(true);
|
|
181
|
+
}
|
|
182
|
+
for (const file of [['core', 'changesets', 'x.test.ts'], ['commands', 'changesets', 'x.test.ts'], ['commands', 'watch', 'track-finalize.test.ts']]) {
|
|
183
|
+
expect(shard.test([root, ...file].join(separator))).toBe(false);
|
|
184
|
+
}
|
|
185
|
+
expect(ignored.test([root, 'core', 'changesets', 'x.test.ts'].join(separator))).toBe(false);
|
|
186
|
+
}
|
|
187
|
+
if (gatesRelease) {
|
|
188
|
+
assertReleaseGate(jobs);
|
|
189
|
+
// CI-2 keeps the comment on the release job's needs line.
|
|
190
|
+
const releaseText = workflow.slice(workflow.search(/^ release:\n/m));
|
|
191
|
+
expect(releaseText.match(/^ needs: [^\n]*$/m)?.[0]).toMatch(/ # rojo en cualquier plataforma o shard ARM no publica$/);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
expect(names).not.toContain('release');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Rewrites the release job's inline `needs` list in the workflow text.
|
|
198
|
+
function rewriteReleaseNeeds(workflow, rewrite) {
|
|
199
|
+
const header = workflow.search(/^ release:\n/m);
|
|
200
|
+
expect(header).toBeGreaterThanOrEqual(0);
|
|
201
|
+
const head = workflow.slice(0, header);
|
|
202
|
+
const tail = workflow.slice(header).replace(/^( needs: )\[([^\]\n]*)\]/m, (_line, prefix, list) => `${prefix}[${rewrite(list.split(',').map((job) => job.trim())).join(', ')}]`);
|
|
203
|
+
return head + tail;
|
|
204
|
+
}
|
|
205
|
+
function permutations(items) {
|
|
206
|
+
if (items.length <= 1)
|
|
207
|
+
return [items];
|
|
208
|
+
return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)]).map((rest) => [item, ...rest]));
|
|
209
|
+
}
|
|
210
|
+
// Every bypass of the partition or the release gate must make assertArmPartition
|
|
211
|
+
// throw: a failed assertion or a mutated text that no longer parses as YAML.
|
|
212
|
+
function assertMutationsRejected(workflow, gates) {
|
|
213
|
+
const jobLevel = (job, line) => workflow.replace(new RegExp(`^ ${job}:\\n`, 'm'), ` ${job}:\n ${line}\n`);
|
|
214
|
+
const inStep = (step, line) => workflow.replace(` - name: ${step}\n`, ` - name: ${step}\n ${line}\n`);
|
|
215
|
+
const mutations = [
|
|
216
|
+
// Swapped matrix guards.
|
|
217
|
+
workflow.replace(/if: matrix\.target (?:!=|==) 'win32-arm64'/g, (guard) => guard.includes('!=') ? "if: matrix.target == 'win32-arm64'" : "if: matrix.target != 'win32-arm64'"),
|
|
218
|
+
// A second producer of the native artifact.
|
|
219
|
+
workflow.replace('uses: actions/upload-artifact@v4', 'uses: actions/upload-artifact@v4\n - uses: actions/upload-artifact@v4'),
|
|
220
|
+
// A remainder that drops more than the shards run.
|
|
221
|
+
workflow.replace(/(--testPathIgnorePatterns='[^']+)(?=')/, '$1|runner\\.test\\.ts$'),
|
|
222
|
+
// A shard pattern that differs from the remainder's exclusion.
|
|
223
|
+
workflow.replace(/(--testPathPatterns='[^']+)changes\?/, '$1changes'),
|
|
224
|
+
// Masked shard runs.
|
|
225
|
+
workflow.replace(/(run: npx jest --runTestsByPath [^\n]+ --runInBand --bail)(?=\n)/, '$1; exit 0'),
|
|
226
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathPatterns='[^']+')(?=\n)/, '$1; exit 0'),
|
|
227
|
+
// Skipped steps or jobs, in any key quoting.
|
|
228
|
+
inStep(HOT_STEP, 'if: false'),
|
|
229
|
+
inStep(HOT_STEP, '"if": always()'),
|
|
230
|
+
inStep(HOT_STEP, "'if': false"),
|
|
231
|
+
inStep(CHANGES_STEP, 'if: false'),
|
|
232
|
+
inStep(CHANGES_STEP, '"if": always()'),
|
|
233
|
+
inStep(CHANGES_STEP, "'if': false"),
|
|
234
|
+
inStep('Upload native secure-fs artifact', "'if': false"),
|
|
235
|
+
jobLevel('windows-arm-hot', '"if": always()'),
|
|
236
|
+
jobLevel('windows-arm-hot', "'if': false"),
|
|
237
|
+
jobLevel('windows-arm-changes', '"if": always()'),
|
|
238
|
+
jobLevel('windows-arm-changes', "'if': false"),
|
|
239
|
+
// Tolerated failures, in any key quoting, escaping or expression form.
|
|
240
|
+
jobLevel('test', "continue-on-error: ${{ matrix.target == 'win32-arm64' }}"),
|
|
241
|
+
jobLevel('test', '"continue-on-error": true'),
|
|
242
|
+
jobLevel('test', '"continue\\x2Don-error": true'),
|
|
243
|
+
jobLevel('windows-arm-hot', 'continue-on-error: true'),
|
|
244
|
+
jobLevel('windows-arm-hot', 'continue-on-error: ${{ true }}'),
|
|
245
|
+
jobLevel('windows-arm-hot', '"continue-on-error": true'),
|
|
246
|
+
jobLevel('windows-arm-changes', 'continue-on-error: true'),
|
|
247
|
+
jobLevel('windows-arm-changes', 'continue-on-error: ${{ true }}'),
|
|
248
|
+
jobLevel('windows-arm-changes', '"continue-on-error": true'),
|
|
249
|
+
inStep(HOT_STEP, '"continue-on-error": true'),
|
|
250
|
+
inStep(CHANGES_STEP, "'continue-on-error': true"),
|
|
251
|
+
workflow.replace(/^( run: npx jest --runInBand --bail\n)/m, '$1 continue-on-error: true\n'),
|
|
252
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: true\n'),
|
|
253
|
+
workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: ${{ true }}\n'),
|
|
254
|
+
];
|
|
255
|
+
if (gates) {
|
|
256
|
+
mutations.push(rewriteReleaseNeeds(workflow, (jobs) => jobs.filter((job) => job !== 'windows-arm-changes')), rewriteReleaseNeeds(workflow, (jobs) => [...jobs, 'windows-arm-changes']), jobLevel('release', 'if: always()'), jobLevel('release', 'if: ${{ !cancelled() }}'), jobLevel('release', '"if": always()'), jobLevel('release', "'if': false"), jobLevel('release', 'continue-on-error: true'), jobLevel('release', '"continue-on-error": true'),
|
|
257
|
+
// The needs comment is part of CI-2.
|
|
258
|
+
workflow.slice(0, workflow.search(/^ release:\n/m))
|
|
259
|
+
+ workflow.slice(workflow.search(/^ release:\n/m)).replace(/^( needs: [^\n]*?) # rojo en cualquier plataforma o shard ARM no publica$/m, '$1'));
|
|
260
|
+
}
|
|
261
|
+
for (const mutated of mutations) {
|
|
262
|
+
expect(mutated).not.toBe(workflow);
|
|
263
|
+
expect(() => assertArmPartition(mutated, gates)).toThrow();
|
|
264
|
+
}
|
|
47
265
|
}
|
|
48
266
|
describe('native release artifacts', () => {
|
|
49
267
|
it('runs each platform suite once without weakening publication', () => {
|
|
@@ -54,44 +272,75 @@ describe('native release artifacts', () => {
|
|
|
54
272
|
expect(workflow).toContain('run: npx jest --runInBand --bail');
|
|
55
273
|
}
|
|
56
274
|
const release = readWorkflow('release.yml');
|
|
57
|
-
|
|
275
|
+
assertReleaseGate(parseJobs(release));
|
|
58
276
|
for (const target of ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'win32-x64', 'win32-arm64']) {
|
|
59
277
|
expect(release).toContain(`target: ${target}`);
|
|
60
278
|
}
|
|
61
279
|
expect(release).toContain('cancel-in-progress: false');
|
|
62
280
|
});
|
|
63
|
-
it('partitions the
|
|
281
|
+
it('partitions the hot and Change Record ARM shards without duplicating the native artifact', () => {
|
|
64
282
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
65
283
|
const workflow = readWorkflow(name);
|
|
66
|
-
assertArmPartition(workflow);
|
|
284
|
+
assertArmPartition(workflow, name === 'release.yml');
|
|
67
285
|
}
|
|
68
286
|
});
|
|
69
287
|
it('checks the same partition after a Windows CRLF checkout', () => {
|
|
70
288
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
71
289
|
const workflow = readWorkflow(name);
|
|
72
|
-
assertArmPartition(workflow.replace(/\n/g, '\r\n'));
|
|
290
|
+
assertArmPartition(workflow.replace(/\n/g, '\r\n'), name === 'release.yml');
|
|
73
291
|
}
|
|
74
292
|
});
|
|
75
|
-
it('rejects swapped matrix guards, non-gating
|
|
293
|
+
it('rejects swapped matrix guards, non-gating ARM shards, drifted partitions and duplicate artifacts', () => {
|
|
76
294
|
for (const name of ['ci.yml', 'release.yml']) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
295
|
+
assertMutationsRejected(readWorkflow(name), name === 'release.yml');
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
it('keeps the whole self-test green when release needs is reordered', () => {
|
|
299
|
+
const workflow = readWorkflow('release.yml');
|
|
300
|
+
let current = [];
|
|
301
|
+
rewriteReleaseNeeds(workflow, (jobs) => (current = jobs));
|
|
302
|
+
const reorderedJobs = permutations(current).find((jobs) => jobs.join() !== current.join());
|
|
303
|
+
expect(reorderedJobs).toBeDefined();
|
|
304
|
+
const reordered = rewriteReleaseNeeds(workflow, () => reorderedJobs);
|
|
305
|
+
expect(reordered).not.toBe(workflow);
|
|
306
|
+
expect(() => assertArmPartition(reordered, true)).not.toThrow();
|
|
307
|
+
expect(() => assertMutationsRejected(reordered, true)).not.toThrow();
|
|
308
|
+
});
|
|
309
|
+
it('reports a shard directory that is missing, renamed by case or holds no suite', () => {
|
|
310
|
+
const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-arm-shard-'));
|
|
311
|
+
try {
|
|
312
|
+
const testsRoot = path_1.default.join(tmp, 'cli', 'tests');
|
|
313
|
+
for (const segments of SHARD_DIRECTORIES) {
|
|
314
|
+
fs_1.default.mkdirSync(path_1.default.join(testsRoot, ...segments), { recursive: true });
|
|
315
|
+
fs_1.default.writeFileSync(path_1.default.join(testsRoot, ...segments, 'x.test.ts'), '');
|
|
87
316
|
}
|
|
88
|
-
expect(()
|
|
89
|
-
|
|
90
|
-
expect(()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(()
|
|
94
|
-
|
|
317
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([]);
|
|
318
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'core', 'changes'), path_1.default.join(testsRoot, 'core', 'Changes'));
|
|
319
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([expect.stringContaining('core/changes')]);
|
|
320
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'core', 'Changes'), path_1.default.join(testsRoot, 'core', 'changes'));
|
|
321
|
+
fs_1.default.renameSync(path_1.default.join(testsRoot, 'commands', 'change', 'x.test.ts'), path_1.default.join(testsRoot, 'commands', 'change', 'helpers.ts'));
|
|
322
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([expect.stringContaining('commands/change')]);
|
|
323
|
+
fs_1.default.rmSync(path_1.default.join(testsRoot, 'commands', 'changes'), { recursive: true });
|
|
324
|
+
expect(shardDirectoryProblems(testsRoot)).toHaveLength(2);
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
fs_1.default.rmSync(tmp, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
it('flags test directories that only the case-insensitive shard pattern selects', () => {
|
|
331
|
+
expect(caseOnlyShardPaths(['repo/cli/tests/core/Changes'], CHANGES_PATTERN)).toEqual(['repo/cli/tests/core/Changes']);
|
|
332
|
+
expect(caseOnlyShardPaths(['repo\\cli\\tests\\commands\\CHANGE'], CHANGES_PATTERN)).toEqual(['repo\\cli\\tests\\commands\\CHANGE']);
|
|
333
|
+
expect(caseOnlyShardPaths(['repo/cli/tests/core/changes', 'repo/cli/tests/commands/change', 'repo/cli/tests/core/Changesets'], CHANGES_PATTERN)).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
it('has no test directory selected by the shard but kept by the remainder through case alone', () => {
|
|
336
|
+
const testsRoot = path_1.default.join(CLI_ROOT, 'tests');
|
|
337
|
+
const directories = fs_1.default.readdirSync(testsRoot, { recursive: true })
|
|
338
|
+
.map((relative) => path_1.default.join(testsRoot, relative))
|
|
339
|
+
.filter((candidate) => fs_1.default.statSync(candidate).isDirectory());
|
|
340
|
+
expect(shardDirectoryProblems(testsRoot)).toEqual([]);
|
|
341
|
+
const flagged = caseOnlyShardPaths(directories, CHANGES_PATTERN);
|
|
342
|
+
if (flagged.length > 0) {
|
|
343
|
+
throw new Error(`Case-only Change Record directories would run in both ARM shards: ${flagged.join(', ')}`);
|
|
95
344
|
}
|
|
96
345
|
});
|
|
97
346
|
it('keeps native prebuilds generated instead of versioned source', () => {
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|