agentic-workflow-manager 9.22.0 → 9.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/src/commands/change/index.js +2 -2
  2. package/dist/src/commands/init.js +8 -4
  3. package/dist/src/commands/registry/add.js +9 -3
  4. package/dist/src/commands/sync.js +11 -5
  5. package/dist/src/commands/update.js +23 -12
  6. package/dist/src/core/changes/derive.js +6 -3
  7. package/dist/src/core/changes/git.js +1 -2
  8. package/dist/src/core/changes/worktree.js +15 -3
  9. package/dist/src/core/init/steps.js +3 -8
  10. package/dist/src/core/registries.js +20 -15
  11. package/dist/src/core/registry-git-errors.js +107 -0
  12. package/dist/src/core/registry-git.js +93 -0
  13. package/dist/src/core/registry-sync-report.js +74 -0
  14. package/dist/src/core/versioning.js +3 -6
  15. package/dist/src/index.js +5 -8
  16. package/dist/tests/commands/change/new.test.js +103 -57
  17. package/dist/tests/commands/change/validate.test.js +164 -108
  18. package/dist/tests/commands/changes/list.test.js +41 -22
  19. package/dist/tests/commands/job/gate-reconcile.test.js +13 -2
  20. package/dist/tests/commands/registry-sync-honesty.test.js +175 -0
  21. package/dist/tests/commands/sensors/exec.test.js +3 -1
  22. package/dist/tests/commands/update.test.js +8 -8
  23. package/dist/tests/commands/watch/supervisor-loop.test.js +21 -5
  24. package/dist/tests/core/changes/helpers.js +15 -3
  25. package/dist/tests/core/changes/list-paging.test.js +131 -110
  26. package/dist/tests/core/changes/list.test.js +133 -83
  27. package/dist/tests/core/changes/receipt.test.js +18 -6
  28. package/dist/tests/core/diagnostics/provider-checks.test.js +3 -0
  29. package/dist/tests/core/init/steps-registry-sync.test.js +6 -8
  30. package/dist/tests/core/registry-git.test.js +153 -0
  31. package/dist/tests/helpers/commit-seeded-registry.js +18 -0
  32. package/dist/tests/integration/codex-provider-isolated.test.js +7 -2
  33. package/dist/tests/integration/copilot-init-isolated.test.js +7 -4
  34. package/dist/tests/structural/active-documentation.test.js +0 -41
  35. package/dist/tests/structural/bundle-skill-reference-contract.test.js +0 -12
  36. package/dist/tests/structural/cli-reference-matches-commander.test.js +94 -0
  37. package/dist/tests/structural/native-release-artifacts.test.js +116 -107
  38. package/dist/tests/structural/sensor-documentation-contract.test.js +0 -47
  39. package/dist/tests/structural/sensor-portability-contract.test.js +0 -7
  40. package/dist/tests/structural/simple-git-v4-pin.test.js +49 -0
  41. package/package.json +2 -2
  42. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  43. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  44. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  45. package/prebuilds/win32-x64/secure_fs.node +0 -0
  46. package/dist/tests/structural/cloud-admission-docs-contract.test.js +0 -54
  47. package/dist/tests/structural/documentation-phase-is-mechanized.test.js +0 -29
  48. package/dist/tests/structural/operator-environments-docs-contract.test.js +0 -70
  49. package/dist/tests/structural/process-model-description-contract.test.js +0 -34
  50. package/dist/tests/structural/project-declared-sensors-docs-contract.test.js +0 -104
  51. package/dist/tests/structural/r4-plan-currentness-docs.test.js +0 -37
  52. package/dist/tests/structural/sensor-execution-optimization-docs-contract.test.js +0 -102
  53. package/dist/tests/structural/sensor-slice-decision-contract.test.js +0 -63
@@ -42,6 +42,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
42
42
  const fs_1 = __importDefault(require("fs"));
43
43
  const os_1 = __importDefault(require("os"));
44
44
  const path_1 = __importDefault(require("path"));
45
+ const commit_seeded_registry_1 = require("../helpers/commit-seeded-registry");
45
46
  describe('codex provider — isolated home E2E (Task 9)', () => {
46
47
  let tmpHome;
47
48
  let tmpWork;
@@ -79,8 +80,10 @@ describe('codex provider — isolated home E2E (Task 9)', () => {
79
80
  });
80
81
  /** Hand-seeds a registry content root with everything a real `awm init` needs:
81
82
  * hooks (Claude + Codex), the bootstrap skill, one baseline bundle providing
82
- * both a skill and a Codex-native agent artifact. No git repo involved — the
83
- * registry cache is content-root-shaped, exactly what a real clone produces. */
83
+ * both a skill and a Codex-native agent artifact. Content-root-shaped like a
84
+ * real clone, plus a local `.git` commit so `machine.registryCache.present`
85
+ * is true and `stepCache` skips network sync (SG-7 hard-fails any sync error,
86
+ * and `https://example.invalid/...` is intentionally unreachable). */
84
87
  function seedPublicRegistryFixture(root) {
85
88
  fs_1.default.mkdirSync(path_1.default.join(root, 'hooks'), { recursive: true });
86
89
  fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/session-start'), '#!/bin/sh\necho "{}"\n', { mode: 0o755 });
@@ -101,6 +104,8 @@ describe('codex provider — isolated home E2E (Task 9)', () => {
101
104
  name: 'dev-core', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
102
105
  dependsOn: [], skills: ['development-process'], workflows: [], agents: ['development-process'],
103
106
  }));
107
+ // Local commit only — no origin. Present+.git makes stepCache skip sync.
108
+ (0, commit_seeded_registry_1.commitSeededRegistry)(root);
104
109
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
105
110
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.invalid/baseline.git' }], null, 2));
106
111
  }
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  const fs_1 = __importDefault(require("fs"));
37
37
  const os_1 = __importDefault(require("os"));
38
38
  const path_1 = __importDefault(require("path"));
39
+ const commit_seeded_registry_1 = require("../helpers/commit-seeded-registry");
39
40
  describe('copilot provider — isolated home E2E (devCore global-scope guard regression)', () => {
40
41
  let tmpHome;
41
42
  let tmpWork;
@@ -67,10 +68,11 @@ describe('copilot provider — isolated home E2E (devCore global-scope guard reg
67
68
  process.env.AWM_HOME = originalAwmHome;
68
69
  });
69
70
  /** Same fixture shape as codex-provider-isolated.test.ts's seedPublicRegistryFixture:
70
- * a real content-root registry (no git repo needed) with the bootstrap skill and one
71
- * baseline bundle. Copilot needs no hooks (providers/index.ts has no `hooks` config
72
- * for it) and no native agent renderer (`agent: null`), so the fixture is intentionally
73
- * smaller than Codex's. */
71
+ * a real content-root registry with the bootstrap skill and one baseline bundle,
72
+ * plus a local `.git` commit so SG-7 does not hard-fail init on an unreachable
73
+ * example.invalid remote (stepCache skips when registryCache.present). Copilot
74
+ * needs no hooks (providers/index.ts has no `hooks` config for it) and no native
75
+ * agent renderer (`agent: null`), so the fixture is intentionally smaller than Codex's. */
74
76
  function seedPublicRegistryFixture(root) {
75
77
  fs_1.default.mkdirSync(path_1.default.join(root, 'hooks'), { recursive: true });
76
78
  fs_1.default.writeFileSync(path_1.default.join(root, 'hooks/session-start'), '#!/bin/sh\necho "{}"\n', { mode: 0o755 });
@@ -88,6 +90,7 @@ describe('copilot provider — isolated home E2E (devCore global-scope guard reg
88
90
  name: 'dev-core', description: '', version: '1.0.0', scope: 'baseline', visibility: 'public',
89
91
  dependsOn: [], skills: ['development-process'], workflows: [], agents: [],
90
92
  }));
93
+ (0, commit_seeded_registry_1.commitSeededRegistry)(root);
91
94
  fs_1.default.mkdirSync(path_1.default.join(tmpHome, '.awm'), { recursive: true });
92
95
  fs_1.default.writeFileSync(path_1.default.join(tmpHome, '.awm/registries.json'), JSON.stringify([{ name: 'baseline', remote: 'https://example.invalid/baseline.git' }], null, 2));
93
96
  }
@@ -75,12 +75,6 @@ describe('active documentation contract', () => {
75
75
  }
76
76
  }
77
77
  });
78
- it('documents the two-stage boundary and official bundle scopes (R3, R8)', () => {
79
- expect(read('docs/installation.md')).toContain('Prepare the machine');
80
- expect(read('docs/project-setup.md')).toContain('Initialize a project');
81
- expect(read('docs/configuration.md')).toContain('`dev` and `product`');
82
- expect(read('docs/project-setup.md')).toContain('`frontend`');
83
- });
84
78
  it('documents every init provider and option (R5, R14.2)', () => {
85
79
  const config = read('docs/configuration.md');
86
80
  for (const agent of providers_1.AGENT_TARGETS) {
@@ -90,39 +84,4 @@ describe('active documentation contract', () => {
90
84
  expect(config).toContain(`\`${flag}\``);
91
85
  }
92
86
  });
93
- it('assigns canonical topics instead of copying provider paths (R12)', () => {
94
- expect(read('docs/configuration.md')).toContain('[Support matrix](support-matrix.md)');
95
- expect(read('docs/configuration.md')).not.toMatch(/~\/\.(?:claude|codex|cursor|gemini)/);
96
- });
97
- it('keeps generated provider support prose in the canonical language (R2)', () => {
98
- const matrix = read('docs/support-matrix.md');
99
- expect(matrix).toContain('### Where each artifact is installed');
100
- expect(matrix).toContain('### Agent profiles, workflows, hooks, and context');
101
- expect(matrix).not.toContain('Dónde aterriza cada artefacto');
102
- expect(matrix).not.toContain('Perfiles de agente, workflows, hooks y contexto');
103
- });
104
- it('keeps R3 canonical documentation in English and reachable from the hub', () => {
105
- const hub = read('docs/README.md');
106
- expect(hub).toContain('decisions.md');
107
- for (const file of [
108
- 'docs/framework.md', 'docs/configuration.md', 'docs/project-setup.md',
109
- 'docs/runbook.md', 'docs/cli-reference.md', 'docs/support-matrix.md',
110
- 'docs/architecture.md',
111
- ]) {
112
- expect(read(file)).not.toContain('### Metodología');
113
- }
114
- });
115
- it('keeps provider tiers, registries, and hook targets technically precise (R2)', () => {
116
- const matrix = read('docs/support-matrix.md');
117
- const runbook = read('docs/runbook.md');
118
- const cliReference = read('docs/cli-reference.md');
119
- expect(matrix).toContain('Re-anchors process guidance');
120
- expect(matrix).toContain('Only `awm sensors run` is an enforceable deterministic gate');
121
- expect(runbook).toContain('A registry needs at least one content directory');
122
- expect(runbook).toMatch(/`catalog\.json` is required only when the registry\s+publishes bundles/);
123
- expect(cliReference).toContain('`claude-code` and `codex`');
124
- expect(cliReference).toContain('`--agent` is an alias for `--target`');
125
- expect(cliReference).toContain('`dev` and `product` baseline bundles');
126
- expect(runbook).toContain('bundles/<bundle-name>/bundle.json');
127
- });
128
87
  });
@@ -36,18 +36,6 @@ describe('bundle skill-reference contract', () => {
36
36
  expect(skills).toEqual(['mi-proceso']);
37
37
  expect(skills.every((skill) => typeof skill === 'string')).toBe(true);
38
38
  });
39
- it('places the CLI 9 migration boundary immediately after the bundle example', () => {
40
- const guide = read(GUIDE);
41
- const bundleFence = '```json\n{\n "name": "mi-proceso"';
42
- const start = guide.indexOf(bundleFence);
43
- const end = guide.indexOf('```', start) + 3;
44
- const following = guide.slice(end, guide.indexOf('## 5.', end));
45
- expect(start).toBeGreaterThanOrEqual(0);
46
- expect(following).toMatch(/CLI 9[\s\S]*(?:legacy object|objeto legacy)[\s\S]*\{\s*name\s*,\s*onSignal:\s*true\s*\}/i);
47
- expect(following).toMatch(/ignor(?:e|a)[\s\S]*boolean[\s\S]*onSignal/i);
48
- expect(following).toMatch(/warning/i);
49
- expect(following).toMatch(/CLI 10[\s\S]*(?:rejects?|rechaza)[\s\S]*(?:objects?|objetos?)/i);
50
- });
51
39
  it('keeps every JSON fence in the touched guide parseable', () => {
52
40
  for (const json of jsonFences(read(GUIDE))) {
53
41
  expect(() => JSON.parse(json)).not.toThrow();
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
8
+ const index_1 = require("../../src/index");
9
+ /**
10
+ * The CLI reference is what agents read to drive `awm`, so it must never name a
11
+ * command or a flag the CLI does not have. This is the one documentation check:
12
+ * it compares the docs against the Commander tree instead of pinning prose, so
13
+ * rewording a paragraph never breaks it, and a renamed or removed flag always does.
14
+ */
15
+ const ROOT = path_1.default.resolve(__dirname, '../../..');
16
+ const DOCS = ['docs/cli-reference.md', 'README.md'];
17
+ function tree(command) {
18
+ const children = new Map();
19
+ for (const sub of command.commands) {
20
+ const node = tree(sub);
21
+ children.set(sub.name(), node);
22
+ for (const alias of sub.aliases())
23
+ children.set(alias, node);
24
+ }
25
+ return { command, children };
26
+ }
27
+ /** Every `awm ...` invocation inside inline code spans or fenced code blocks. */
28
+ function invocations(markdown) {
29
+ const code = [];
30
+ for (const fence of markdown.matchAll(/```[^\n]*\n([\s\S]*?)```/g))
31
+ code.push(...fence[1].split('\n'));
32
+ const prose = markdown.replace(/```[^\n]*\n[\s\S]*?```/g, '');
33
+ for (const span of prose.matchAll(/`([^`\n]+)`/g))
34
+ code.push(span[1]);
35
+ const out = [];
36
+ for (const line of code) {
37
+ for (const match of line.matchAll(/(?:^|[\s(;&|$])awm\s+([^|;&`#\n]*)/g))
38
+ out.push(match[1].trim());
39
+ }
40
+ return out;
41
+ }
42
+ function longFlags(command) {
43
+ const flags = new Set(['--help']);
44
+ for (let c = command; c; c = c.parent) {
45
+ for (const option of c.options)
46
+ if (option.long)
47
+ flags.add(option.long);
48
+ }
49
+ return flags;
50
+ }
51
+ function check(root, doc, invocation) {
52
+ const tokens = invocation.split(/\s+/).filter(Boolean);
53
+ const first = tokens[0];
54
+ // `awm <command>`, `awm …`, `awm --version` and similar placeholders name no command.
55
+ if (!first || /^[<[A-Z-]/.test(first) || first === '...' || first === '…')
56
+ return [];
57
+ if (!root.children.has(first))
58
+ return [{ doc, invocation, problem: `unknown command "${first}"` }];
59
+ // Options may precede a subcommand (`awm watch --provider x recover-admission`),
60
+ // so descend on every non-flag token that names a child of the current command.
61
+ let node = root;
62
+ const names = [];
63
+ for (const token of tokens) {
64
+ if (token.startsWith('-'))
65
+ continue;
66
+ const next = node.children.get(token);
67
+ if (next) {
68
+ node = next;
69
+ names.push(token);
70
+ }
71
+ }
72
+ const known = longFlags(node.command);
73
+ return tokens
74
+ .flatMap((token) => Array.from(token.matchAll(/(?:^|[[(|])(--[a-z][a-z0-9-]*)/g), (m) => m[1]))
75
+ .filter((flag) => !known.has(flag))
76
+ .map((flag) => ({ doc, invocation, problem: `"awm ${names.join(' ')}" has no ${flag}` }));
77
+ }
78
+ describe('documented awm commands and flags exist in the CLI', () => {
79
+ const root = tree(index_1.program);
80
+ it.each(DOCS)('%s names only real commands and flags', (doc) => {
81
+ const markdown = fs_1.default.readFileSync(path_1.default.join(ROOT, doc), 'utf8');
82
+ const found = invocations(markdown);
83
+ expect(found.length).toBeGreaterThan(0);
84
+ const drift = found.flatMap((invocation) => check(root, doc, invocation));
85
+ expect(drift).toEqual([]);
86
+ });
87
+ it('detects drift (self-check against a planted invocation)', () => {
88
+ expect(check(root, 'planted', 'sensors run --no-such-flag')).toEqual([
89
+ { doc: 'planted', invocation: 'sensors run --no-such-flag', problem: '"awm sensors run" has no --no-such-flag' },
90
+ ]);
91
+ expect(check(root, 'planted', 'no-such-command --x')).toHaveLength(1);
92
+ expect(check(root, 'planted', 'sensors run --slice --no-cache')).toEqual([]);
93
+ });
94
+ });
@@ -13,14 +13,23 @@ const REPO_ROOT = path_1.default.resolve(CLI_ROOT, '..');
13
13
  function readWorkflow(name) {
14
14
  return fs_1.default.readFileSync(path_1.default.join(REPO_ROOT, '.github', 'workflows', name), 'utf8').replace(/\r\n/g, '\n');
15
15
  }
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'];
16
+ // Windows spawns processes about 3x slower than Linux, so its git-heavy suites run as parallel shards
17
+ // (job `windows-shards`, both Windows runners) while the matrix `test` job runs the Windows remainder.
18
+ const SHARDS = [
19
+ { name: 'watch', pattern: String.raw `[/\\]cli[/\\]tests[/\\]commands[/\\]watch[/\\]` },
20
+ { name: 'changes-core', pattern: String.raw `[/\\]cli[/\\]tests[/\\]core[/\\]changes[/\\]` },
21
+ { name: 'changes-commands', pattern: String.raw `[/\\]cli[/\\]tests[/\\]commands[/\\]changes?[/\\]` },
22
+ ];
23
+ const SHARD_JOB = 'windows-shards';
24
+ const WINDOWS_RUNNERS = ['windows-2022', 'windows-11-arm'];
25
+ const REMAINDER_IGNORE = ['node_modules', ...SHARDS.map((shard) => shard.pattern)].join('|');
26
+ const SHARD_STEP = 'Test Windows shard';
27
+ const SHARD_RUN = "npx jest --runInBand --bail --testPathPatterns='${{ matrix.shard.pattern }}'";
28
+ const REGULAR_GUARD = "${{ !startsWith(matrix.target, 'win32-') }}";
29
+ const REMAINDER_GUARD = "startsWith(matrix.target, 'win32-')";
30
+ const REMAINDER_COMMENT = ' # On both Windows runners the git-heavy suites run in the windows-shards job (watch, core Change Record, Change Record commands). Keep runInBand within each job; no test file may run twice or be dropped.\n';
31
+ const RELEASE_GATE = ['test', SHARD_JOB];
32
+ const RELEASE_COMMENT = ' # rojo en cualquier plataforma o shard Windows no publica';
24
33
  const SHARD_SETUP = [
25
34
  { uses: 'actions/checkout@v4' },
26
35
  { uses: 'actions/setup-node@v4', with: { 'node-version': '22' } },
@@ -29,7 +38,7 @@ const SHARD_SETUP = [
29
38
  { name: 'Build test-only native addon', 'working-directory': 'cli', run: 'npm run native:test-build' },
30
39
  ];
31
40
  // 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
41
+ // without it, so a directory differing only in case would run in both a shard
33
42
  // and the remainder. Returns every directory that falls in that gap.
34
43
  function caseOnlyShardPaths(directories, pattern) {
35
44
  const insensitive = new RegExp(pattern, 'i');
@@ -39,11 +48,10 @@ function caseOnlyShardPaths(directories, pattern) {
39
48
  return insensitive.test(candidate) && !sensitive.test(candidate);
40
49
  });
41
50
  }
42
- const SHARD_DIRECTORIES = [['core', 'changes'], ['commands', 'change'], ['commands', 'changes']];
51
+ const SHARD_DIRECTORIES = [['commands', 'watch'], ['core', 'changes'], ['commands', 'change'], ['commands', 'changes']];
43
52
  // 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.
53
+ // suite and match exactly one shard, so the case-only walk cannot pass vacuously.
45
54
  function shardDirectoryProblems(testsRoot) {
46
- const shard = new RegExp(CHANGES_PATTERN);
47
55
  const problems = [];
48
56
  for (const segments of SHARD_DIRECTORIES) {
49
57
  const label = segments.join('/');
@@ -64,8 +72,8 @@ function shardDirectoryProblems(testsRoot) {
64
72
  if (!fs_1.default.readdirSync(current).some((entry) => entry.endsWith('.test.ts'))) {
65
73
  problems.push(`${label}: holds no *.test.ts`);
66
74
  }
67
- if (!shard.test(`${current}${path_1.default.sep}`)) {
68
- problems.push(`${label}: does not match CHANGES_PATTERN`);
75
+ if (SHARDS.filter((shard) => new RegExp(shard.pattern).test(`${current}${path_1.default.sep}`)).length !== 1) {
76
+ problems.push(`${label}: does not match exactly one shard`);
69
77
  }
70
78
  }
71
79
  return problems;
@@ -85,7 +93,7 @@ function keysOf(value) {
85
93
  return Object.keys(value);
86
94
  }
87
95
  // 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.
96
+ // continue-on-error, and needs is exactly the gating jobs in any order.
89
97
  function assertReleaseGate(jobs) {
90
98
  const release = getJob(jobs, 'release');
91
99
  expect(keysOf(release)).not.toContain('if');
@@ -96,99 +104,103 @@ function assertReleaseGate(jobs) {
96
104
  expect(Array.isArray(release.needs)).toBe(true);
97
105
  const needs = release.needs;
98
106
  expect(needs).toHaveLength(RELEASE_GATE.length);
99
- expect([...needs].sort()).toEqual(RELEASE_GATE);
107
+ expect([...needs].sort()).toEqual([...RELEASE_GATE].sort());
108
+ }
109
+ /** Every *.test.ts under cli/tests, as the absolute path Jest matches, in both separators. */
110
+ function suitePaths() {
111
+ const testsRoot = path_1.default.join(CLI_ROOT, 'tests');
112
+ const relative = fs_1.default.readdirSync(testsRoot, { recursive: true }).filter((file) => file.endsWith('.test.ts'));
113
+ expect(relative.length).toBeGreaterThan(100);
114
+ return relative.flatMap((file) => {
115
+ const posix = ['repo', 'cli', 'tests', ...file.split(/[/\\]/)];
116
+ return [posix.join('/'), posix.join('\\')];
117
+ });
100
118
  }
101
119
  // Gate semantics are asserted on the parsed workflow, so key quoting or escaping
102
120
  // 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) {
121
+ // text is the contract: the run lines and the comments.
122
+ function assertWindowsPartition(source, gatesRelease) {
105
123
  const workflow = source.replace(/\r\n/g, '\n');
106
124
  const jobs = parseJobs(source);
107
125
  const names = keysOf(jobs);
108
126
  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);
127
+ const shards = getJob(jobs, SHARD_JOB);
128
+ expect(names.indexOf(SHARD_JOB)).toBe(names.indexOf('test') + 1);
113
129
  // No gating job tolerates failure, at job or step level.
114
- for (const job of [test, hot, changes]) {
130
+ for (const job of [test, shards]) {
115
131
  expect(keysOf(job)).not.toContain('continue-on-error');
116
132
  for (const step of job.steps) {
117
133
  expect(keysOf(step)).not.toContain('continue-on-error');
118
134
  }
119
135
  }
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
- }
136
+ // The shards never skip, at job or step level.
137
+ expect(keysOf(shards)).not.toContain('if');
138
+ for (const step of shards.steps) {
139
+ expect(keysOf(step)).not.toContain('if');
126
140
  }
127
141
  // In the matrix, only the two test steps are guarded, with complementary targets.
128
142
  const guarded = test.steps.filter((step) => keysOf(step).includes('if'));
129
143
  expect(guarded).toHaveLength(2);
130
144
  const [regular, remainder] = guarded;
131
145
  expect(regular.name).toMatch(/^Tests?$/);
132
- expect(regular.if).toBe("matrix.target != 'win32-arm64'");
146
+ expect(regular.if).toBe(REGULAR_GUARD);
133
147
  expect(regular['working-directory']).toBe('cli');
134
148
  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'");
149
+ expect(remainder.name).toMatch(/^Tests? \(Windows remainder\)$/);
150
+ expect(remainder.if).toBe(REMAINDER_GUARD);
137
151
  expect(remainder['working-directory']).toBe('cli');
138
152
  const ignorePattern = String(remainder.run).match(/^npx jest --runInBand --bail --testPathIgnorePatterns='([^']+)'$/)?.[1];
139
- expect(ignorePattern).toBe(`${HOT_IGNORE}|${CHANGES_PATTERN}`);
153
+ expect(ignorePattern).toBe(REMAINDER_IGNORE);
140
154
  expect(test.steps.filter((step) => step.uses === 'actions/upload-artifact@v4')).toHaveLength(1);
141
- // A1 comment, directly above the remainder step.
155
+ // The shards run on exactly the Windows runners of the matrix (each Windows target runs every shard).
156
+ const matrix = test.strategy.matrix.include;
157
+ const windowsRunners = matrix.filter((entry) => entry.target.startsWith('win32-')).map((entry) => entry.os);
158
+ const shardStrategy = shards.strategy;
159
+ expect(shardStrategy['fail-fast']).toBe(false);
160
+ expect([...shardStrategy.matrix.os].sort()).toEqual([...windowsRunners].sort());
161
+ expect([...windowsRunners].sort()).toEqual([...WINDOWS_RUNNERS].sort());
162
+ expect(keysOf(shardStrategy.matrix).sort()).toEqual(['os', 'shard']);
163
+ expect(shardStrategy.matrix.shard).toEqual(SHARDS);
164
+ expect(shards['runs-on']).toBe('${{ matrix.os }}');
165
+ // Comment directly above the remainder step.
142
166
  expect(workflow.split(REMAINDER_COMMENT)).toHaveLength(2);
143
- const aroundRemainder = workflow.split(/ - name: Tests? \(Windows ARM remainder\)\n/);
167
+ const aroundRemainder = workflow.split(/ - name: Tests? \(Windows remainder\)\n/);
144
168
  expect(aroundRemainder).toHaveLength(2);
145
169
  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) });
170
+ // The shard job builds like the matrix and only then runs its shard; it never produces an artifact.
171
+ expect(shards.steps.slice(0, -1)).toEqual(SHARD_SETUP);
172
+ expect(shards.steps.at(-1)).toEqual({ name: SHARD_STEP, 'working-directory': 'cli', run: SHARD_RUN });
152
173
  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');
174
+ expect(shards.permissions).toEqual({ contents: 'read' });
158
175
  }
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.
176
+ expect(shards.steps.some((step) => String(step.uses ?? '').includes('upload-artifact'))).toBe(false);
177
+ expect(JSON.stringify(shards)).not.toContain('secure-fs-win32');
178
+ expect(workflow.split('\n')).toContain(` run: ${SHARD_RUN}`);
179
+ // The partition, checked on every real suite: on Windows each file runs in exactly one place.
163
180
  const ignored = new RegExp(ignorePattern);
181
+ const selectors = shardStrategy.matrix.shard.map((shard) => new RegExp(shard.pattern, 'i'));
182
+ const misplaced = suitePaths().filter((file) => {
183
+ const places = selectors.filter((selector) => selector.test(file)).length + (ignored.test(file) ? 0 : 1);
184
+ return places !== 1;
185
+ });
186
+ expect(misplaced).toEqual([]);
164
187
  for (const separator of ['/', '\\']) {
165
188
  const root = ['repo', 'cli', 'tests'].join(separator);
166
- for (const suite of ['track-finalize', 'track-freeze']) {
167
- expect(ignored.test([root, 'commands', 'watch', `${suite}.test.ts`].join(separator))).toBe(true);
168
- expect(ignored.test([root, 'other', `${suite}.test.ts`].join(separator))).toBe(false);
169
- }
170
- }
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']]) {
189
+ for (const file of [['commands', 'watch', 'track-finalize.test.ts'], ['core', 'changes', 'x.test.ts'], ['commands', 'change', 'new.test.ts'], ['commands', 'changes', 'list.test.ts']]) {
178
190
  const candidate = [root, ...file].join(separator);
179
- expect(shard.test(candidate)).toBe(true);
191
+ expect(selectors.filter((selector) => selector.test(candidate))).toHaveLength(1);
180
192
  expect(ignored.test(candidate)).toBe(true);
181
193
  }
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);
194
+ for (const file of [['core', 'changesets', 'x.test.ts'], ['commands', 'changesets', 'x.test.ts'], ['commands', 'watcher', 'x.test.ts'], ['core', 'watch', 'x.test.ts']]) {
195
+ const candidate = [root, ...file].join(separator);
196
+ expect(selectors.some((selector) => selector.test(candidate))).toBe(false);
197
+ expect(ignored.test(candidate)).toBe(false);
184
198
  }
185
- expect(ignored.test([root, 'core', 'changesets', 'x.test.ts'].join(separator))).toBe(false);
186
199
  }
187
200
  if (gatesRelease) {
188
201
  assertReleaseGate(jobs);
189
- // CI-2 keeps the comment on the release job's needs line.
190
202
  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$/);
203
+ expect(releaseText.match(/^ needs: [^\n]*$/m)?.[0]).toMatch(new RegExp(`${RELEASE_COMMENT}$`));
192
204
  }
193
205
  else {
194
206
  expect(names).not.toContain('release');
@@ -207,60 +219,55 @@ function permutations(items) {
207
219
  return [items];
208
220
  return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)]).map((rest) => [item, ...rest]));
209
221
  }
210
- // Every bypass of the partition or the release gate must make assertArmPartition
222
+ // Every bypass of the partition or the release gate must make assertWindowsPartition
211
223
  // throw: a failed assertion or a mutated text that no longer parses as YAML.
212
224
  function assertMutationsRejected(workflow, gates) {
213
225
  const jobLevel = (job, line) => workflow.replace(new RegExp(`^ ${job}:\\n`, 'm'), ` ${job}:\n ${line}\n`);
214
226
  const inStep = (step, line) => workflow.replace(` - name: ${step}\n`, ` - name: ${step}\n ${line}\n`);
215
227
  const mutations = [
216
228
  // 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'"),
229
+ workflow.replace(REGULAR_GUARD, 'PLACEHOLDER').replace(`if: ${REMAINDER_GUARD}`, `if: ${REGULAR_GUARD}`).replace('PLACEHOLDER', REMAINDER_GUARD),
218
230
  // A second producer of the native artifact.
219
231
  workflow.replace('uses: actions/upload-artifact@v4', 'uses: actions/upload-artifact@v4\n - uses: actions/upload-artifact@v4'),
220
232
  // A remainder that drops more than the shards run.
221
233
  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'),
234
+ // A shard pattern that drifts from the remainder's exclusion.
235
+ workflow.replace(/(pattern: '[^']+commands\[\/\\\\\]changes)\?/, '$1'),
236
+ // A dropped shard, and a dropped Windows runner.
237
+ workflow.replace(/ - name: changes-core\n pattern: '[^']+'\n/, ''),
238
+ workflow.replace('os: [windows-2022, windows-11-arm]', 'os: [windows-11-arm]'),
239
+ // Fail-fast would cancel sibling shards and hide their results.
240
+ workflow.replace(/( windows-shards:\n strategy:\n fail-fast: )false/, '$1true'),
241
+ // Masked shard run.
226
242
  workflow.replace(/(run: npx jest --runInBand --bail --testPathPatterns='[^']+')(?=\n)/, '$1; exit 0'),
227
243
  // 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"),
244
+ inStep(SHARD_STEP, 'if: false'),
245
+ inStep(SHARD_STEP, '"if": always()'),
246
+ inStep(SHARD_STEP, "'if': false"),
234
247
  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"),
248
+ jobLevel(SHARD_JOB, '"if": always()'),
249
+ jobLevel(SHARD_JOB, "'if': false"),
239
250
  // Tolerated failures, in any key quoting, escaping or expression form.
240
- jobLevel('test', "continue-on-error: ${{ matrix.target == 'win32-arm64' }}"),
251
+ jobLevel('test', "continue-on-error: ${{ startsWith(matrix.target, 'win32-') }}"),
241
252
  jobLevel('test', '"continue-on-error": true'),
242
253
  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"),
254
+ jobLevel(SHARD_JOB, 'continue-on-error: true'),
255
+ jobLevel(SHARD_JOB, 'continue-on-error: ${{ true }}'),
256
+ jobLevel(SHARD_JOB, '"continue-on-error": true'),
257
+ inStep(SHARD_STEP, '"continue-on-error": true'),
251
258
  workflow.replace(/^( run: npx jest --runInBand --bail\n)/m, '$1 continue-on-error: true\n'),
252
259
  workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: true\n'),
253
260
  workflow.replace(/(run: npx jest --runInBand --bail --testPathIgnorePatterns='[^']+'\n)/, '$1 continue-on-error: ${{ true }}\n'),
254
261
  ];
255
262
  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'),
263
+ mutations.push(rewriteReleaseNeeds(workflow, (jobs) => jobs.filter((job) => job !== SHARD_JOB)), rewriteReleaseNeeds(workflow, (jobs) => [...jobs, SHARD_JOB]), 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
264
  // The needs comment is part of CI-2.
258
265
  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'));
266
+ + workflow.slice(workflow.search(/^ release:\n/m)).replace(new RegExp(`^( needs: [^\\n]*?)${RELEASE_COMMENT}$`, 'm'), '$1'));
260
267
  }
261
268
  for (const mutated of mutations) {
262
269
  expect(mutated).not.toBe(workflow);
263
- expect(() => assertArmPartition(mutated, gates)).toThrow();
270
+ expect(() => assertWindowsPartition(mutated, gates)).toThrow();
264
271
  }
265
272
  }
266
273
  describe('native release artifacts', () => {
@@ -278,19 +285,19 @@ describe('native release artifacts', () => {
278
285
  }
279
286
  expect(release).toContain('cancel-in-progress: false');
280
287
  });
281
- it('partitions the hot and Change Record ARM shards without duplicating the native artifact', () => {
288
+ it('partitions the Windows shards and remainder without duplicating the native artifact', () => {
282
289
  for (const name of ['ci.yml', 'release.yml']) {
283
290
  const workflow = readWorkflow(name);
284
- assertArmPartition(workflow, name === 'release.yml');
291
+ assertWindowsPartition(workflow, name === 'release.yml');
285
292
  }
286
293
  });
287
294
  it('checks the same partition after a Windows CRLF checkout', () => {
288
295
  for (const name of ['ci.yml', 'release.yml']) {
289
296
  const workflow = readWorkflow(name);
290
- assertArmPartition(workflow.replace(/\n/g, '\r\n'), name === 'release.yml');
297
+ assertWindowsPartition(workflow.replace(/\n/g, '\r\n'), name === 'release.yml');
291
298
  }
292
299
  });
293
- it('rejects swapped matrix guards, non-gating ARM shards, drifted partitions and duplicate artifacts', () => {
300
+ it('rejects swapped matrix guards, non-gating Windows shards, drifted partitions and duplicate artifacts', () => {
294
301
  for (const name of ['ci.yml', 'release.yml']) {
295
302
  assertMutationsRejected(readWorkflow(name), name === 'release.yml');
296
303
  }
@@ -303,11 +310,11 @@ describe('native release artifacts', () => {
303
310
  expect(reorderedJobs).toBeDefined();
304
311
  const reordered = rewriteReleaseNeeds(workflow, () => reorderedJobs);
305
312
  expect(reordered).not.toBe(workflow);
306
- expect(() => assertArmPartition(reordered, true)).not.toThrow();
313
+ expect(() => assertWindowsPartition(reordered, true)).not.toThrow();
307
314
  expect(() => assertMutationsRejected(reordered, true)).not.toThrow();
308
315
  });
309
316
  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-'));
317
+ const tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-windows-shard-'));
311
318
  try {
312
319
  const testsRoot = path_1.default.join(tmp, 'cli', 'tests');
313
320
  for (const segments of SHARD_DIRECTORIES) {
@@ -328,9 +335,11 @@ describe('native release artifacts', () => {
328
335
  }
329
336
  });
330
337
  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([]);
338
+ const [watch, core, commands] = SHARDS.map((shard) => shard.pattern);
339
+ expect(caseOnlyShardPaths(['repo/cli/tests/core/Changes'], core)).toEqual(['repo/cli/tests/core/Changes']);
340
+ expect(caseOnlyShardPaths(['repo\\cli\\tests\\commands\\CHANGE'], commands)).toEqual(['repo\\cli\\tests\\commands\\CHANGE']);
341
+ expect(caseOnlyShardPaths(['repo/cli/tests/commands/Watch'], watch)).toEqual(['repo/cli/tests/commands/Watch']);
342
+ expect(caseOnlyShardPaths(['repo/cli/tests/core/changes', 'repo/cli/tests/commands/change', 'repo/cli/tests/commands/watch', 'repo/cli/tests/core/Changesets'], core)).toEqual([]);
334
343
  });
335
344
  it('has no test directory selected by the shard but kept by the remainder through case alone', () => {
336
345
  const testsRoot = path_1.default.join(CLI_ROOT, 'tests');
@@ -338,9 +347,9 @@ describe('native release artifacts', () => {
338
347
  .map((relative) => path_1.default.join(testsRoot, relative))
339
348
  .filter((candidate) => fs_1.default.statSync(candidate).isDirectory());
340
349
  expect(shardDirectoryProblems(testsRoot)).toEqual([]);
341
- const flagged = caseOnlyShardPaths(directories, CHANGES_PATTERN);
350
+ const flagged = SHARDS.flatMap((shard) => caseOnlyShardPaths(directories, shard.pattern));
342
351
  if (flagged.length > 0) {
343
- throw new Error(`Case-only Change Record directories would run in both ARM shards: ${flagged.join(', ')}`);
352
+ throw new Error(`Case-only directories would run in both a Windows shard and the remainder: ${flagged.join(', ')}`);
344
353
  }
345
354
  });
346
355
  it('keeps native prebuilds generated instead of versioned source', () => {