mandrel 1.60.0 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.agents/README.md +74 -32
  2. package/.agents/docs/SDLC.md +18 -12
  3. package/.agents/docs/configuration.md +61 -4
  4. package/.agents/docs/quality-gates.md +796 -0
  5. package/.agents/docs/workflows.md +3 -4
  6. package/.agents/runtime-deps.json +2 -2
  7. package/.agents/scripts/README.md +1 -1
  8. package/.agents/scripts/agents-bootstrap-github.js +23 -119
  9. package/.agents/scripts/lib/bootstrap/ci-workflow-template.js +46 -0
  10. package/.agents/scripts/lib/bootstrap/gh-preflight.js +7 -9
  11. package/.agents/scripts/lib/bootstrap/manifest.js +21 -1
  12. package/.agents/scripts/lib/bootstrap/merge-methods.js +31 -16
  13. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +32 -11
  14. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  15. package/.agents/scripts/lib/detect-package-manager.js +72 -0
  16. package/.agents/scripts/lib/errors/index.js +4 -4
  17. package/.agents/scripts/lib/label-taxonomy.js +2 -2
  18. package/.agents/scripts/lib/onboard/detect-stack.js +10 -10
  19. package/.agents/scripts/lib/onboard/init-tail.js +218 -0
  20. package/.agents/scripts/lib/onboard/scaffold-docs.js +18 -3
  21. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -6
  22. package/.agents/scripts/lib/worktree/node-modules-strategy.js +5 -2
  23. package/.agents/workflows/agents-update.md +14 -29
  24. package/.agents/workflows/deliver.md +87 -26
  25. package/.agents/workflows/helpers/agents-sync-config.md +3 -2
  26. package/.agents/workflows/helpers/deliver-epic.md +12 -5
  27. package/.agents/workflows/helpers/deliver-stories.md +13 -7
  28. package/.agents/workflows/plan.md +48 -4
  29. package/README.md +18 -30
  30. package/bin/mandrel.js +235 -16
  31. package/docs/CHANGELOG.md +36 -0
  32. package/lib/cli/doctor.js +45 -3
  33. package/lib/cli/init.js +66 -7
  34. package/lib/cli/registry.js +42 -146
  35. package/lib/cli/sync.js +122 -23
  36. package/lib/cli/uninstall.js +42 -7
  37. package/lib/cli/update.js +257 -198
  38. package/lib/cli/version-helpers.js +59 -0
  39. package/package.json +6 -6
  40. package/.agents/workflows/onboard.md +0 -208
  41. package/lib/cli/__tests__/migrate.test.js +0 -268
  42. package/lib/cli/__tests__/sync-local-zone.test.js +0 -247
  43. package/lib/cli/__tests__/sync.test.js +0 -372
  44. package/lib/cli/__tests__/update-changelog-surface.test.js +0 -357
  45. package/lib/cli/__tests__/update-major.test.js +0 -217
  46. package/lib/cli/__tests__/update-reexec.test.js +0 -513
  47. package/lib/cli/__tests__/update.test.js +0 -696
  48. package/lib/cli/__tests__/version-check.test.js +0 -398
  49. package/lib/migrations/__tests__/index.test.js +0 -216
@@ -0,0 +1,59 @@
1
+ // lib/cli/version-helpers.js
2
+ /**
3
+ * Shared semver-ish parse and compare helpers used by both
4
+ * `lib/cli/update.js` and `lib/cli/registry.js`.
5
+ *
6
+ * Both files previously defined local copies of `parseVersion` and
7
+ * `compareVersions`; this module is the single authoritative
8
+ * implementation (Story #4048 B3 — multiplied helpers).
9
+ *
10
+ * Builtins only — this module is imported from both the CLI surface
11
+ * (`lib/cli/`) and the doctor registry which runs before third-party
12
+ * packages are guaranteed to be present.
13
+ */
14
+
15
+ /**
16
+ * Parse a dotted semver-ish string into a numeric tuple. Non-numeric or
17
+ * missing segments coerce to 0 so a partial version still compares sanely.
18
+ *
19
+ * @param {string} version
20
+ * @returns {[number, number, number]}
21
+ */
22
+ export function parseVersion(version) {
23
+ const [major, minor, patch] = String(version).split('.');
24
+ return [
25
+ Number.parseInt(major, 10) || 0,
26
+ Number.parseInt(minor, 10) || 0,
27
+ Number.parseInt(patch, 10) || 0,
28
+ ];
29
+ }
30
+
31
+ /**
32
+ * Compare two version strings. Negative when `a < b`, zero when equal,
33
+ * positive when `a > b` (the standard `Array.sort` comparator contract).
34
+ *
35
+ * @param {string} a
36
+ * @param {string} b
37
+ * @returns {number}
38
+ */
39
+ export function compareVersions(a, b) {
40
+ const pa = parseVersion(a);
41
+ const pb = parseVersion(b);
42
+ for (let i = 0; i < 3; i += 1) {
43
+ if (pa[i] !== pb[i]) return pa[i] - pb[i];
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ /**
49
+ * True when `target`'s major axis is strictly greater than `current`'s —
50
+ * the gated "crosses a major boundary" condition used by the update
51
+ * orchestrator.
52
+ *
53
+ * @param {string} current
54
+ * @param {string} target
55
+ * @returns {boolean}
56
+ */
57
+ export function crossesMajor(current, target) {
58
+ return parseVersion(target)[0] > parseVersion(current)[0];
59
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "mandrel",
3
- "version": "1.60.0",
3
+ "version": "1.62.0",
4
4
  "description": "Claude Code-first opinionated workflow framework: instructions, personas, skills, and SDLC workflows that govern AI coding assistants.",
5
- "main": "index.js",
6
5
  "files": [
7
6
  ".agents/",
8
7
  "bin/",
9
8
  "docs/CHANGELOG.md",
10
- "lib/"
9
+ "lib/",
10
+ "!lib/**/__tests__"
11
11
  ],
12
12
  "publishConfig": {
13
13
  "access": "public",
@@ -84,7 +84,8 @@
84
84
  "knip": "^6.14.0",
85
85
  "lint-staged": "^17.0.4",
86
86
  "markdownlint-cli2": "^0.18.1",
87
- "memfs": "^4.57.2"
87
+ "memfs": "^4.57.2",
88
+ "typescript": ">=5.0.0"
88
89
  },
89
90
  "dependencies": {
90
91
  "ajv": "^8.20.0",
@@ -93,7 +94,6 @@
93
94
  "minimatch": "^10.0.0",
94
95
  "picomatch": "^4.0.4",
95
96
  "string-argv": "^0.3.2",
96
- "typescript": ">=5.0.0",
97
97
  "typhonjs-escomplex": "^0.1.0"
98
98
  },
99
99
  "peerDependencies": {
@@ -101,7 +101,7 @@
101
101
  },
102
102
  "peerDependenciesMeta": {
103
103
  "typescript": {
104
- "optional": false
104
+ "optional": true
105
105
  }
106
106
  }
107
107
  }
@@ -1,208 +0,0 @@
1
- ---
2
- description: >-
3
- Guided first-run onboarding for a freshly installed Mandrel. Detects the
4
- consumer stack, offers to scaffold any missing docsContextFiles, runs
5
- `mandrel doctor` as a readiness gate, and hands off to a started /plan.
6
- The whole path is designed to take about 15 minutes from a clean checkout to
7
- a planned Epic.
8
- ---
9
-
10
- # /onboard
11
-
12
- ## Role
13
-
14
- Onboarding guide. You walk a first-time operator from a freshly installed
15
- Mandrel to their first planned Epic, composing the building blocks shipped by
16
- the guided-onboard Feature (#3514): stack detection, docs scaffolding, the
17
- `mandrel doctor` readiness check, and a started `/plan` handoff.
18
-
19
- ## Overview
20
-
21
- `/onboard` is the **guided first-successful-run path**. It sequences four
22
- phases that each lean on an already-shipped, independently tested building
23
- block, then hands the operator off to planning:
24
-
25
- ```text
26
- /onboard
27
- → Phase 1 — Detect stack (lib/onboard/detect-stack.js#detectStack)
28
- → Phase 2 — Offer docs scaffolding (lib/onboard/scaffold-docs.js#scaffoldDocs)
29
- → Phase 3 — Readiness gate (mandrel doctor → lib/cli/doctor.js)
30
- → Phase 4 — Handoff to /plan (started, not auto-run)
31
- ```
32
-
33
- Each phase is **advisory and resumable**: re-running `/onboard` on an
34
- already-onboarded project re-detects, re-checks, and offers the same handoff
35
- without duplicating any scaffolding (the scaffolder only writes files that are
36
- genuinely missing, and `mandrel doctor` is read-only).
37
-
38
- ### When to use `/onboard`
39
-
40
- | Scenario | Command |
41
- | --- | --- |
42
- | First run after installing Mandrel into a project | `/onboard` |
43
- | Plan a new Epic once onboarded | `/plan <epicId>` or `/plan --idea "<seed>"` |
44
- | Deliver Epic-attached Stories | `/deliver <epicId>` |
45
-
46
- ## Prerequisites
47
-
48
- 1. Mandrel installed and bootstrapped into the project. The zero-to-installed
49
- path is `npx mandrel init`, which installs the `mandrel`
50
- package (when absent), runs `mandrel sync` to materialize the `.agents/`
51
- bundle, and then — on the **configure now** prompt option — execs
52
- `.agents/scripts/bootstrap.js` to provision the project (labels,
53
- board, `.agentrc.json` seed). `/onboard` runs **after** that bootstrap
54
- completes — it does not invoke `bootstrap.js` itself, so the coupling is
55
- indirect: bootstrap owns first-time provisioning, `/onboard` owns the
56
- guided first-successful-run. By the time you reach `/onboard`, the
57
- `.agents/` bundle is present and `mandrel` resolves on the `PATH`.
58
- 2. `GITHUB_TOKEN` available in the project's `.env` (Phase 3 checks this; the
59
- token value is never echoed).
60
-
61
- ## The ~15-minute first-successful-run path
62
-
63
- `/onboard` is tuned so a brand-new operator can go from a clean checkout to a
64
- planned Epic in roughly **15 minutes**. The budget breaks down as:
65
-
66
- | Step | Phase | Rough budget |
67
- | --- | --- | --- |
68
- | Detect the stack and confirm the report | Phase 1 | ~1 min |
69
- | Review the missing-docs offer and accept the scaffold | Phase 2 | ~3 min |
70
- | Run `mandrel doctor` and clear any ✘ checks | Phase 3 | ~5 min |
71
- | Start `/plan` and describe the first Epic idea | Phase 4 | ~6 min |
72
-
73
- If any single phase blows its budget — most often a Phase 3 remedy such as
74
- authenticating `gh` or installing runtime deps — clear that one check and
75
- re-run `/onboard`; the earlier phases are cheap and idempotent, so re-running
76
- costs seconds.
77
-
78
- ### Sample-repo pointer
79
-
80
- If you do not have a project to onboard yet and just want to see the path
81
- end-to-end, point `/onboard` at the **stack-detection sample-repo fixture**
82
- that ships with the framework. `detectStack` is fixture-driven by design (its
83
- filesystem facade reads a real directory), and the unit suite exercises it
84
- against an on-disk sample repo — see
85
- [`tests/onboard/detect-stack.test.js`](../../tests/onboard/detect-stack.test.js),
86
- which builds a sample repo with a lockfile, a `package.json`, and source files
87
- and asserts the detected package manager, test runner, and primary language.
88
- Use that fixture (or any small throwaway repo with a `package.json` and a few
89
- source files) as the target for a dry first run before onboarding a real
90
- project.
91
-
92
- ## Phase 1 — Detect the stack
93
-
94
- Inspect the consumer repository root and report what Mandrel inferred before
95
- touching anything. Use the detection helper shipped by Story #3520:
96
-
97
- ```bash
98
- node -e "import('./.agents/scripts/lib/onboard/detect-stack.js').then(m => console.log(JSON.stringify(m.detectStack(process.cwd()), null, 2)))"
99
- ```
100
-
101
- `detectStack(root)` returns `{ packageManager, testRunner, primaryLanguage }`,
102
- each inferred from on-disk signals (lockfiles, `package.json`, source-file
103
- extensions) and `null` when no signal is found. Relay the report to the
104
- operator so they can confirm Mandrel understood the project. Detection is
105
- **read-only** — it never writes to disk — so a wrong guess is harmless and the
106
- operator can simply correct course in their `.agentrc.json` later.
107
-
108
- ## Phase 2 — Offer to scaffold missing `docsContextFiles`
109
-
110
- Mandrel agents perform a **mandatory read** of every file listed in
111
- `project.docsContextFiles` before each task; a missing entry degrades every
112
- downstream run. Detect which are absent and offer to scaffold stubs, using the
113
- helper shipped by Story #3519:
114
-
115
- 1. **Preview (no writes).** Detect the missing set first:
116
-
117
- ```bash
118
- node -e "import('./.agents/scripts/lib/onboard/scaffold-docs.js').then(m => console.log(JSON.stringify(m.scaffoldDocs({ write: false }), null, 2)))"
119
- ```
120
-
121
- `scaffoldDocs({ write: false })` returns `{ docsRoot, docsContextFiles,
122
- missing, present, created }` without creating any files.
123
-
124
- 2. **Offer.** Show the operator the `missing` list and ask whether to scaffold
125
- stubs. If the list is empty, report "all docsContextFiles present" and skip
126
- to Phase 3.
127
-
128
- 3. **Scaffold (on acceptance).** When the operator accepts, write the stubs:
129
-
130
- ```bash
131
- node -e "import('./.agents/scripts/lib/onboard/scaffold-docs.js').then(m => console.log(JSON.stringify(m.scaffoldDocs({ write: true }), null, 2)))"
132
- ```
133
-
134
- Each missing file is seeded from a dedicated template under
135
- `.agents/templates/docs/<name>` when one ships, otherwise from a generic
136
- placeholder stub. The operator replaces the stub content with real docs
137
- later; the point of the scaffold is that the mandatory-read never resolves
138
- to a missing file.
139
-
140
- This phase is **idempotent**: the scaffolder only writes files that are
141
- actually absent, so re-running `/onboard` after a partial scaffold creates
142
- only the still-missing stubs.
143
-
144
- ## Phase 3 — Readiness gate (`mandrel doctor`)
145
-
146
- Run the doctor as a **readiness gate** before handing off to planning:
147
-
148
- ```bash
149
- mandrel doctor
150
- ```
151
-
152
- `mandrel doctor` (see [`lib/cli/doctor.js`](../../lib/cli/doctor.js)) runs
153
- every check in the registry in order — `node-version`, `git-available`,
154
- `gh-available`, `github-token`, `gh-auth`, `commands-in-sync`, `runtime-deps`,
155
- `agents-materialized`, `agents-drift`, `version-current` — and prints a
156
- `✔`/`✘` line per check. Every failing check prints a `→ <remedy>` line, and
157
- the command exits:
158
-
159
- - **0** with `✅ Ready (N/N checks passed)` — proceed to Phase 4.
160
- - **non-zero** with `❌ Not ready (M/N checks failed)` — **stop**. Work
161
- through the `→` remedies (e.g. authenticate `gh`, set `GITHUB_TOKEN`,
162
- install runtime deps), then re-run `mandrel doctor` until it is green.
163
-
164
- Do **not** hand off to `/plan` while the doctor is red — planning needs a
165
- working `gh` / `GITHUB_TOKEN` and a materialized `.agents/` bundle, exactly
166
- what the gate verifies. The `github-token` check never echoes the token value
167
- (security baseline § Secrets Management).
168
-
169
- ## Phase 4 — Handoff to a started `/plan`
170
-
171
- With a green readiness gate, hand the operator off to planning. `/onboard`
172
- **starts** the handoff — it surfaces the entry point and the idea-refinement
173
- path — but does **not** auto-run `/plan`, because Epic planning authors
174
- GitHub artifacts and must stay under explicit operator control.
175
-
176
- Present the operator with the two `/plan` entry shapes:
177
-
178
- - **From an idea** (no Epic exists yet):
179
-
180
- ```text
181
- /plan --idea "<one-line description of the first thing to build>"
182
- ```
183
-
184
- This enters [`/plan`](helpers/plan-epic.md) at Phase 1 (Idea Refinement),
185
- which refines the seed into a PRD, Tech Spec, and a decomposed
186
- Epic → Story backlog.
187
-
188
- - **From an existing Epic** (a `type::epic` issue already exists):
189
-
190
- ```text
191
- /plan <epicId>
192
- ```
193
-
194
- Stop here and let the operator invoke `/plan` themselves. Once they have
195
- a planned Epic, the natural next step is `/deliver <epicId>` to execute
196
- it — but that is beyond the onboarding path.
197
-
198
- ## Constraints
199
-
200
- - **Read-before-write.** Phases 1 and 3 are read-only; Phase 2 writes only
201
- files that are genuinely missing and only on explicit operator acceptance.
202
- - **Do not auto-run `/plan`.** Phase 4 starts the handoff; the operator
203
- invokes planning. Planning authors GitHub artifacts and stays under human
204
- control.
205
- - **Never echo secrets.** The `github-token` check and any token-related
206
- remedy must not print the token value.
207
- - **Stop on a red doctor.** A non-zero `mandrel doctor` exit blocks the
208
- handoff until the operator clears the failing checks.
@@ -1,268 +0,0 @@
1
- // lib/cli/__tests__/migrate.test.js
2
- /**
3
- * Unit tests for lib/cli/migrate.js — the standalone `mandrel migrate`
4
- * subcommand (Story #3505, Epic #3437).
5
- *
6
- * Every test drives runMigrate through injectable seams (argv, runMigrations,
7
- * registry, ctx, write, writeErr, exit). No real filesystem I/O, no real
8
- * network call, and no shared mutable module state occur (testing-standards
9
- * § Unit: all external I/O MUST be mocked; pure-logic assertions only).
10
- *
11
- * Coverage contract (Story #3505 AC):
12
- * - Module shape: runMigrate named export + default function export.
13
- * - A live run parses --from/--to out of argv and forwards them to
14
- * runMigrations.
15
- * - --dry-run reports the steps that WOULD run and invokes no step's apply
16
- * and no runMigrations call (writes nothing to disk).
17
- * - Missing --from or --to is a usage error (non-zero exit).
18
- */
19
-
20
- import assert from 'node:assert/strict';
21
- import { describe, it } from 'node:test';
22
-
23
- import migrate, { runMigrate } from '../migrate.js';
24
-
25
- // ---------------------------------------------------------------------------
26
- // Capture + seam helpers
27
- // ---------------------------------------------------------------------------
28
-
29
- /** Capture stdout/stderr writes and the exit code. */
30
- function makeCapture() {
31
- const out = [];
32
- const err = [];
33
- let exitCode = null;
34
- return {
35
- out,
36
- err,
37
- get exitCode() {
38
- return exitCode;
39
- },
40
- write: (s) => out.push(s),
41
- writeErr: (s) => err.push(s),
42
- exit: (code) => {
43
- exitCode = code;
44
- },
45
- };
46
- }
47
-
48
- /**
49
- * Build a fixture registry with `detect`/`apply` recorders so the dry-run
50
- * (detect only, no apply) and live (detect → apply) paths are both
51
- * observable. `appliedNeeded` lists the versions whose detect returns true
52
- * (still needs applying); every other step is treated as already-present.
53
- */
54
- function makeFixtureRegistry({ appliedNeeded = ['1.4.0', '1.5.0'] } = {}) {
55
- const calls = [];
56
- const registry = [
57
- { version: '1.3.0', description: 'pre-range step' },
58
- { version: '1.4.0', description: 'rename foo to bar' },
59
- { version: '1.5.0', description: 'move baseline file' },
60
- { version: '1.6.0', description: 'post-range step' },
61
- ].map((step) => ({
62
- ...step,
63
- detect: (_ctx) => {
64
- calls.push(`detect:${step.version}`);
65
- return appliedNeeded.includes(step.version);
66
- },
67
- apply: (_ctx) => {
68
- calls.push(`apply:${step.version}`);
69
- },
70
- }));
71
- return { registry, calls };
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // Module shape
76
- // ---------------------------------------------------------------------------
77
-
78
- describe('migrate module exports', () => {
79
- it('exports runMigrate as a named export', () => {
80
- assert.equal(typeof runMigrate, 'function');
81
- });
82
-
83
- it('exports a default function for bin/mandrel.js dispatch', () => {
84
- assert.equal(typeof migrate, 'function');
85
- });
86
- });
87
-
88
- // ---------------------------------------------------------------------------
89
- // AC — live run forwards parsed --from/--to to runMigrations
90
- // ---------------------------------------------------------------------------
91
-
92
- describe('runMigrate — live run', () => {
93
- it('parses --from/--to and forwards them to runMigrations', () => {
94
- const cap = makeCapture();
95
- const calls = [];
96
- const runMigrations = ({ fromVersion, toVersion }) => {
97
- calls.push(`runMigrations:${fromVersion}->${toVersion}`);
98
- return { applied: ['1.4.0'], skipped: [] };
99
- };
100
-
101
- const result = runMigrate({
102
- argv: ['--from', '1.3.0', '--to', '1.5.0'],
103
- runMigrations,
104
- registry: [],
105
- write: cap.write,
106
- writeErr: cap.writeErr,
107
- exit: cap.exit,
108
- });
109
-
110
- assert.deepEqual(calls, ['runMigrations:1.3.0->1.5.0']);
111
- assert.equal(result.ok, true);
112
- assert.equal(result.action, 'migrated');
113
- assert.deepEqual(result.applied, ['1.4.0']);
114
- assert.equal(cap.exitCode, null);
115
- assert.match(cap.out.join(''), /Applied 1 migration/);
116
- });
117
-
118
- it('accepts the --from=value / --to=value spelling', () => {
119
- const cap = makeCapture();
120
- const calls = [];
121
- const runMigrations = ({ fromVersion, toVersion }) => {
122
- calls.push(`runMigrations:${fromVersion}->${toVersion}`);
123
- return { applied: [], skipped: [] };
124
- };
125
-
126
- runMigrate({
127
- argv: ['--from=1.2.0', '--to=1.9.0'],
128
- runMigrations,
129
- registry: [],
130
- write: cap.write,
131
- writeErr: cap.writeErr,
132
- exit: cap.exit,
133
- });
134
-
135
- assert.deepEqual(calls, ['runMigrations:1.2.0->1.9.0']);
136
- });
137
-
138
- it('reports a no-op when no migrations applied', () => {
139
- const cap = makeCapture();
140
- const runMigrations = () => ({ applied: [], skipped: ['1.4.0'] });
141
-
142
- const result = runMigrate({
143
- argv: ['--from', '1.3.0', '--to', '1.5.0'],
144
- runMigrations,
145
- registry: [],
146
- write: cap.write,
147
- writeErr: cap.writeErr,
148
- exit: cap.exit,
149
- });
150
-
151
- assert.equal(result.ok, true);
152
- assert.deepEqual(result.applied, []);
153
- assert.match(cap.out.join(''), /no migrations to apply/);
154
- });
155
- });
156
-
157
- // ---------------------------------------------------------------------------
158
- // AC — --dry-run reports the plan, invokes no apply, writes nothing
159
- // ---------------------------------------------------------------------------
160
-
161
- describe('runMigrate — --dry-run', () => {
162
- it('reports the in-range steps that would apply / be skipped and never applies', () => {
163
- const cap = makeCapture();
164
- const { registry, calls } = makeFixtureRegistry({
165
- // 1.5.0 is in range but already present (detect → false ⇒ would skip).
166
- appliedNeeded: ['1.4.0'],
167
- });
168
- let runnerCalled = false;
169
-
170
- const result = runMigrate({
171
- argv: ['--from', '1.3.0', '--to', '1.5.0', '--dry-run'],
172
- runMigrations: () => {
173
- runnerCalled = true;
174
- return { applied: [], skipped: [] };
175
- },
176
- registry,
177
- write: cap.write,
178
- writeErr: cap.writeErr,
179
- exit: cap.exit,
180
- });
181
-
182
- // Range filter is fromVersion < v <= toVersion → only 1.4.0 and 1.5.0.
183
- assert.deepEqual(result.wouldApply, ['1.4.0']);
184
- assert.deepEqual(result.wouldSkip, ['1.5.0']);
185
- assert.equal(result.action, 'dry-run');
186
- assert.equal(result.ok, true);
187
-
188
- // Dry-run probes detect on in-range steps only — never apply, never the
189
- // live runner.
190
- assert.deepEqual(calls, ['detect:1.4.0', 'detect:1.5.0']);
191
- assert.equal(runnerCalled, false);
192
- assert.ok(!calls.some((c) => c.startsWith('apply:')));
193
-
194
- // Operator-facing plan output.
195
- const stdout = cap.out.join('');
196
- assert.match(stdout, /dry run v1\.3\.0 → v1\.5\.0/);
197
- assert.match(stdout, /would apply {2}1\.4\.0: rename foo to bar/);
198
- assert.match(stdout, /would skip {3}1\.5\.0: move baseline file/);
199
- assert.match(stdout, /no migrations applied, nothing written/);
200
- assert.equal(cap.exitCode, null);
201
- });
202
-
203
- it('reports an empty plan when no steps fall in range', () => {
204
- const cap = makeCapture();
205
- const { registry, calls } = makeFixtureRegistry();
206
-
207
- const result = runMigrate({
208
- // 1.6.0 < v <= 1.6.0 leaves only the 1.6.0 step out (exclusive lower);
209
- // 9.9.0 → 9.9.0 range catches nothing.
210
- argv: ['--from', '9.9.0', '--to', '9.9.0', '--dry-run'],
211
- runMigrations: () => ({ applied: [], skipped: [] }),
212
- registry,
213
- write: cap.write,
214
- writeErr: cap.writeErr,
215
- exit: cap.exit,
216
- });
217
-
218
- assert.deepEqual(result.wouldApply, []);
219
- assert.deepEqual(result.wouldSkip, []);
220
- assert.deepEqual(calls, []);
221
- assert.match(cap.out.join(''), /no migration steps in range/);
222
- });
223
- });
224
-
225
- // ---------------------------------------------------------------------------
226
- // AC — missing bounds are a usage error
227
- // ---------------------------------------------------------------------------
228
-
229
- describe('runMigrate — usage validation', () => {
230
- it('exits non-zero when --to is missing', () => {
231
- const cap = makeCapture();
232
- let runnerCalled = false;
233
-
234
- const result = runMigrate({
235
- argv: ['--from', '1.3.0'],
236
- runMigrations: () => {
237
- runnerCalled = true;
238
- return { applied: [], skipped: [] };
239
- },
240
- registry: [],
241
- write: cap.write,
242
- writeErr: cap.writeErr,
243
- exit: cap.exit,
244
- });
245
-
246
- assert.equal(result.ok, false);
247
- assert.equal(result.action, 'usage-error');
248
- assert.equal(cap.exitCode, 1);
249
- assert.equal(runnerCalled, false);
250
- assert.match(cap.err.join(''), /both --from .* and --to .* are required/);
251
- });
252
-
253
- it('exits non-zero when --from is missing', () => {
254
- const cap = makeCapture();
255
-
256
- const result = runMigrate({
257
- argv: ['--to', '1.5.0'],
258
- runMigrations: () => ({ applied: [], skipped: [] }),
259
- registry: [],
260
- write: cap.write,
261
- writeErr: cap.writeErr,
262
- exit: cap.exit,
263
- });
264
-
265
- assert.equal(result.action, 'usage-error');
266
- assert.equal(cap.exitCode, 1);
267
- });
268
- });