mandrel 2.36.0 → 2.37.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 (88) hide show
  1. package/.agents/docs/configuration.md +25 -0
  2. package/.agents/rules/testing-standards.md +56 -0
  3. package/.agents/scripts/boot-sweep.js +7 -1
  4. package/.agents/scripts/check-context-budget.js +195 -103
  5. package/.agents/scripts/check-cyclomatic.js +112 -42
  6. package/.agents/scripts/check-generated-validator.js +202 -0
  7. package/.agents/scripts/check-lifecycle-lint.js +2 -68
  8. package/.agents/scripts/check-schema-references.js +2 -28
  9. package/.agents/scripts/drain-pending-cleanup.js +18 -1
  10. package/.agents/scripts/lib/baselines/components.js +32 -2
  11. package/.agents/scripts/lib/baselines/env-overrides.js +1 -1
  12. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  13. package/.agents/scripts/lib/bootstrap/install-ledger.js +1 -1
  14. package/.agents/scripts/lib/bootstrap/prompt.js +1 -1
  15. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +2 -2
  16. package/.agents/scripts/lib/checks/loop-health.js +1 -1
  17. package/.agents/scripts/lib/cli-args.js +0 -2
  18. package/.agents/scripts/lib/close-validation/gates.js +1 -1
  19. package/.agents/scripts/lib/close-validation/process.js +1 -1
  20. package/.agents/scripts/lib/config/sync-agentrc.js +1 -1
  21. package/.agents/scripts/lib/config-settings-schema.js +46 -6
  22. package/.agents/scripts/lib/coverage-baseline.js +2 -2
  23. package/.agents/scripts/lib/cpu-pool.js +90 -10
  24. package/.agents/scripts/lib/crap-utils.js +6 -2
  25. package/.agents/scripts/lib/cyclomatic-ceiling.js +28 -6
  26. package/.agents/scripts/lib/cyclomatic-scope.js +144 -0
  27. package/.agents/scripts/lib/dependency-version.js +86 -0
  28. package/.agents/scripts/lib/duplicate-search.js +1 -1
  29. package/.agents/scripts/lib/errors/index.js +1 -1
  30. package/.agents/scripts/lib/feedback-loop/graduator-core.js +2 -2
  31. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +1 -1
  32. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  33. package/.agents/scripts/lib/generated/agentrc-validator.js +16 -0
  34. package/.agents/scripts/lib/install-cmd-parser.js +1 -1
  35. package/.agents/scripts/lib/knip-entry-sync.js +1 -57
  36. package/.agents/scripts/lib/maintainability-utils.js +108 -10
  37. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  38. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +70 -9
  39. package/.agents/scripts/lib/orchestration/pr-watch.js +1 -30
  40. package/.agents/scripts/lib/orchestration/reassert-status-column.js +2 -2
  41. package/.agents/scripts/lib/orchestration/review-providers/codex.js +3 -3
  42. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +1 -1
  43. package/.agents/scripts/lib/orchestration/review-providers/native.js +9 -2
  44. package/.agents/scripts/lib/orchestration/review-providers/review-depth.js +2 -2
  45. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -3
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +52 -1
  47. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +2 -5
  48. package/.agents/scripts/lib/orchestration/story-follow-ups.js +1 -1
  49. package/.agents/scripts/lib/orchestration/ticket-lease.js +34 -9
  50. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -1
  51. package/.agents/scripts/lib/preflight-runner.js +3 -3
  52. package/.agents/scripts/lib/qa/qa-session.js +1 -1
  53. package/.agents/scripts/lib/runtime-deps/manifest.js +1 -7
  54. package/.agents/scripts/lib/runtime-deps/scan-imports.js +3 -58
  55. package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +284 -25
  56. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  57. package/.agents/scripts/lib/source-text/strip-js-comments.js +115 -0
  58. package/.agents/scripts/lib/test-isolate/runner.js +3 -3
  59. package/.agents/scripts/lib/test-runner-contract.js +14 -6
  60. package/.agents/scripts/lib/test-tiers.js +135 -18
  61. package/.agents/scripts/lib/transpile.js +16 -3
  62. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +169 -31
  63. package/.agents/scripts/lib/worktree/node-modules-strategy.js +2 -5
  64. package/.agents/scripts/pr-watch-with-update.js +305 -137
  65. package/.agents/scripts/providers/github/tickets.js +109 -16
  66. package/.agents/scripts/quality-preview.js +162 -70
  67. package/.agents/scripts/run-test-profile.js +8 -5
  68. package/.agents/scripts/run-tests.js +79 -14
  69. package/.agents/scripts/single-story-close.js +0 -2
  70. package/.agents/scripts/single-story-init.js +5 -1
  71. package/docs/CHANGELOG.md +32 -0
  72. package/lib/cli/migrate.js +100 -94
  73. package/lib/migrations/helpers/retire-agentrc-key.js +200 -0
  74. package/lib/migrations/index.js +32 -33
  75. package/lib/migrations/steps/2.1.0-retire-mi-drop-knobs.js +21 -80
  76. package/lib/migrations/steps/2.1.0-retire-verify-concurrency-cap.js +15 -87
  77. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +13 -71
  78. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +13 -101
  79. package/lib/migrations/steps/2.32.0-retire-lint-baseline-command.js +13 -100
  80. package/package.json +7 -3
  81. package/.agents/scripts/lib/audit-suite/frontmatter-lint.js +0 -32
  82. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +0 -37
  83. package/.agents/scripts/lib/cli/parse-numeric.js +0 -60
  84. package/.agents/scripts/lib/close-validation/telemetry.js +0 -79
  85. package/.agents/scripts/lib/orchestration/label-transitions.js +0 -44
  86. package/.agents/scripts/lib/orchestration/parked-follow-ons.js +0 -147
  87. package/.agents/scripts/lib/orchestration/phase-runner.js +0 -88
  88. package/.agents/scripts/lib/orchestration/recut.js +0 -56
@@ -0,0 +1,115 @@
1
+ // .agents/scripts/lib/source-text/strip-js-comments.js
2
+ /**
3
+ * The one string-literal-aware JavaScript comment stripper.
4
+ *
5
+ * Several guards in this repository grep source text for a pattern that must
6
+ * appear in real code rather than in a docblock — a CLI citation, an import
7
+ * specifier, a schema path. Each grew its own stripper, and the four
8
+ * implementations disagreed on what a stripped block comment leaves behind:
9
+ * one preserved newlines only, one deleted the comment outright (losing line
10
+ * numbers), one blanked it to whitespace, and one used a regex that did not
11
+ * honour string literals at all. A fix to any of them silently missed the
12
+ * others, and a guard whose own rationale mentions the pattern it greps for
13
+ * can satisfy itself if the stripping is wrong.
14
+ *
15
+ * **Semantics: comment bodies become equivalent whitespace.** Every character
16
+ * of a comment is replaced by a space except newlines, which survive. That is
17
+ * the most information-preserving of the four behaviours and the only one that
18
+ * is safe for all of them:
19
+ *
20
+ * - line and column positions are unchanged, so a guard can report a
21
+ * `file:line` that matches the original source;
22
+ * - a regex looking for code cannot match comment text, because none
23
+ * survives;
24
+ * - the output is the same length as the input, so byte offsets hold.
25
+ *
26
+ * String and template literals are copied through verbatim, so a `//` inside a
27
+ * URL or a `/*` inside a message is not mistaken for a comment opener. Escape
28
+ * sequences are honoured, so an escaped quote does not end the literal early.
29
+ *
30
+ * An unterminated comment or literal runs to end of input rather than
31
+ * throwing: this is a lint helper reading files that may be mid-edit, and a
32
+ * crash there would be a worse failure than a slightly over-stripped tail.
33
+ *
34
+ * Builtins only — no dependency, so the guards that run before a consumer's
35
+ * install can use it.
36
+ */
37
+
38
+ /**
39
+ * Replace every non-newline character of `text` with a space.
40
+ *
41
+ * @param {string} text
42
+ * @returns {string}
43
+ */
44
+ function blank(text) {
45
+ return text.replace(/[^\n]/g, ' ');
46
+ }
47
+
48
+ /**
49
+ * Index just past the string or template literal opening at `start`.
50
+ * Runs to end of input when the literal is never closed.
51
+ *
52
+ * @param {string} text
53
+ * @param {number} start - index of the opening quote
54
+ * @returns {number}
55
+ */
56
+ function endOfLiteral(text, start) {
57
+ const quote = text[start];
58
+ let i = start + 1;
59
+ while (i < text.length) {
60
+ const ch = text[i];
61
+ if (ch === '\\') {
62
+ i += 2;
63
+ continue;
64
+ }
65
+ if (ch === quote) return i + 1;
66
+ i += 1;
67
+ }
68
+ return text.length;
69
+ }
70
+
71
+ /**
72
+ * Strip JavaScript comments, replacing their bodies with equivalent
73
+ * whitespace so line numbers, columns and byte offsets all survive.
74
+ *
75
+ * @param {string} source - JavaScript source text. Nullish is treated as empty.
76
+ * @returns {string} the source with every comment body blanked to whitespace
77
+ */
78
+ export function stripJsComments(source) {
79
+ const text = String(source ?? '');
80
+ let out = '';
81
+ let i = 0;
82
+
83
+ while (i < text.length) {
84
+ const two = text.slice(i, i + 2);
85
+
86
+ if (two === '//') {
87
+ const newline = text.indexOf('\n', i);
88
+ const stop = newline === -1 ? text.length : newline;
89
+ out += blank(text.slice(i, stop));
90
+ i = stop;
91
+ continue;
92
+ }
93
+
94
+ if (two === '/*') {
95
+ const close = text.indexOf('*/', i + 2);
96
+ const stop = close === -1 ? text.length : close + 2;
97
+ out += blank(text.slice(i, stop));
98
+ i = stop;
99
+ continue;
100
+ }
101
+
102
+ const ch = text[i];
103
+ if (ch === "'" || ch === '"' || ch === '`') {
104
+ const stop = endOfLiteral(text, i);
105
+ out += text.slice(i, stop);
106
+ i = stop;
107
+ continue;
108
+ }
109
+
110
+ out += ch;
111
+ i += 1;
112
+ }
113
+
114
+ return out;
115
+ }
@@ -58,7 +58,7 @@ const NODE_TEST_BASE = [
58
58
  * @param {typeof spawn} [opts.spawnFn]
59
59
  * @returns {Promise<IsolatedResult>}
60
60
  */
61
- export function runFileIsolated({
61
+ function runFileIsolated({
62
62
  repoRoot,
63
63
  file,
64
64
  envOutDir,
@@ -195,7 +195,7 @@ function sanitizeEnv(source) {
195
195
  * @param {typeof spawn} [opts.spawnFn]
196
196
  * @returns {Promise<{ results: SuiteResult[], exitCode: number, stdout: string, stderr: string }>}
197
197
  */
198
- export function runSuite({
198
+ function runSuite({
199
199
  repoRoot,
200
200
  files,
201
201
  concurrency = 8,
@@ -266,7 +266,7 @@ export function runSuite({
266
266
  * @param {(r: IsolatedResult) => void} [opts.onResult]
267
267
  * @returns {Promise<IsolatedResult[]>}
268
268
  */
269
- export async function runIsolatedPool({
269
+ async function runIsolatedPool({
270
270
  repoRoot,
271
271
  files,
272
272
  envOutDir,
@@ -90,13 +90,20 @@ export const TEST_RUNNER_FLAGS = Object.freeze([
90
90
  * the spawns `runTierPreflight` actually issues — which is the thing that
91
91
  * has to be true.
92
92
  */
93
+ const STATE_PROBE_ONLY = Object.freeze(['.agents/scripts/test-wrapper.js']);
94
+
93
95
  const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
94
96
  full: Object.freeze([
95
- '.agents/scripts/test-wrapper.js',
97
+ ...STATE_PROBE_ONLY,
96
98
  '.agents/scripts/validate-skills.js',
97
99
  ]),
98
- quick: Object.freeze(['.agents/scripts/test-wrapper.js']),
99
- integration: Object.freeze(['.agents/scripts/test-wrapper.js']),
100
+ quick: STATE_PROBE_ONLY,
101
+ integration: STATE_PROBE_ONLY,
102
+ // Story #5111: the `e2e` tier gets the state probe like every other
103
+ // non-full tier. An absent entry is not a neutral default — the lookup
104
+ // below falls through to `[]`, so a tier missing from this map runs *no*
105
+ // preflight at all and the omission is invisible in a green run.
106
+ e2e: STATE_PROBE_ONLY,
100
107
  });
101
108
 
102
109
  /**
@@ -108,7 +115,7 @@ const TIER_PREFLIGHT_SCRIPTS = Object.freeze({
108
115
  * executes, which the npm hook does not under `ignore-scripts=true`.
109
116
  *
110
117
  * @param {object} [opts]
111
- * @param {'full' | 'quick' | 'integration'} [opts.tier]
118
+ * @param {'full' | 'quick' | 'integration' | 'e2e'} [opts.tier]
112
119
  * @param {string} [opts.repoRoot] Absolute repository root.
113
120
  * @param {typeof spawnSync} [opts.spawn] Injected in tests.
114
121
  * @param {string} [opts.execPath] Node binary to spawn; injected in tests.
@@ -127,8 +134,9 @@ export function runTierPreflight({
127
134
  stdio: 'inherit',
128
135
  });
129
136
  if (run.error) throw run.error;
130
- const status = run.status ?? 1;
131
- if (status !== 0) return status;
137
+ // A signal-killed child reports `status: null`; treat that as the
138
+ // reserved failure code rather than letting it read as "not zero, no code".
139
+ if (run.status !== 0) return run.status ?? 1;
132
140
  }
133
141
  return 0;
134
142
  }
@@ -23,6 +23,46 @@ export const INTEGRATION_INCLUDE = [
23
23
 
24
24
  const matchesIntegration = picomatch(INTEGRATION_INCLUDE, { dot: true });
25
25
 
26
+ /**
27
+ * Globs for the `e2e` tier — real-binary suites under `tests/e2e/` that pack
28
+ * this repository, install it into a temp consumer and drive the shipped
29
+ * `mandrel` binary end to end.
30
+ *
31
+ * They are the most expensive files the repository owns by an order of
32
+ * magnitude: one `npm pack` plus real `npm install` spawns per file, measured
33
+ * (Story #5111) at ~7 s of system time and ~17 s of summed install wall clock
34
+ * for `update-chain.integration.test.js` alone. Every one of those seconds was
35
+ * charged to `npm test` — i.e. to every pre-push hook and every local
36
+ * iteration — for a signal that only changes when the release-shaped install
37
+ * path changes.
38
+ *
39
+ * So they get their own tier and leave every other one: `full` (`npm test`),
40
+ * `quick` and `integration` all exclude them, and `npm run test:e2e` is how
41
+ * they run. CI runs that tier as its own job on every PR, so the
42
+ * release-shaped path keeps its per-PR signal.
43
+ *
44
+ * Deliberately not exported: a second reader of this list is a second place
45
+ * for the tier definition to drift. `listTestFilesForTier('e2e', root)` is the
46
+ * public answer to "which files are e2e", and the tests assert through it.
47
+ */
48
+ const E2E_INCLUDE = ['tests/e2e/**/*.test.js'];
49
+
50
+ const matchesE2E = picomatch(E2E_INCLUDE, { dot: true });
51
+
52
+ /** Tier names `parseTierArgv` accepts, in the order `--help` lists them. */
53
+ const TIERS = ['full', 'quick', 'integration', 'e2e'];
54
+
55
+ /**
56
+ * `node --test` flags the runner forwards verbatim to the child.
57
+ *
58
+ * Everything else that looks like a flag is a mistake — a typo, a retired
59
+ * option, or a flag meant for `npm` that landed after the `--` separator —
60
+ * and `parseTierArgv` rejects it rather than passing it to `node --test`,
61
+ * which reads an unknown `--flag` as a *file pattern* and silently runs a
62
+ * suite that matches nothing while exiting 0.
63
+ */
64
+ const PASSTHROUGH_FLAGS = ['--test-name-pattern', '--test-only'];
65
+
26
66
  /**
27
67
  * Repo-relative roots the tier walker scans for test files (names ending in
28
68
  * `.test.js`).
@@ -47,12 +87,22 @@ const TEST_WALK_ROOTS = ['tests', 'lib', '.agents/scripts'];
47
87
  * globs are scoped to `__tests__` subtrees so they only match colocated
48
88
  * tests, never the shipped source modules themselves.
49
89
  *
50
- * Exported because the full tier has two runners, not one: `run-tests.js`
51
- * (via `listTestFilesForTier`) and `run-coverage.js`. Story #4922 the
52
- * coverage runner used to restate `tests/**` on its own, so the 47 colocated
53
- * `__tests__` files ran under `npm test` but were absent from the measured
54
- * surface, leaving the coverage and CRAP numbers computed over code the
55
- * measuring run never executed. Consume this constant; never restate a glob.
90
+ * Exported because it is the **measured surface**: `run-coverage.js` — the
91
+ * required CI job, and the run every coverage / CRAP baseline is scored from
92
+ * consumes it directly. Story #4922: the coverage runner used to restate
93
+ * `tests/**` on its own, so the 47 colocated `__tests__` files ran under
94
+ * `npm test` but were absent from the measured surface, leaving the coverage
95
+ * and CRAP numbers computed over code the measuring run never executed.
96
+ * Consume this constant; never restate a glob.
97
+ *
98
+ * Story #5111 made this a strict **superset** of the `full` runner tier: the
99
+ * measured surface still includes `tests/e2e/**`, while `npm test` no longer
100
+ * runs it. That asymmetry is deliberate and load-bearing. The e2e suites drive
101
+ * the shipped binary through `bin/mandrel.js` and `lib/cli/update.js` in real
102
+ * child processes, and c8's `NODE_V8_COVERAGE` is inherited by those children —
103
+ * so dropping them from the measured run would deflate exactly the CLI files
104
+ * they exist to exercise and red the coverage ratchet on code nobody touched.
105
+ * Cheapening the pre-push loop must not cost the measurement its subject.
56
106
  */
57
107
  export const FULL_TIER_GLOBS = [
58
108
  'tests/**/*.test.js',
@@ -81,10 +131,47 @@ function walkTestFiles(dir, prefix, fsLike) {
81
131
  return out;
82
132
  }
83
133
 
134
+ /**
135
+ * Split the walked set into the `e2e` tier and the remainder every other tier
136
+ * is drawn from, so an e2e file belongs to exactly one tier and `npm test`
137
+ * never pays for it.
138
+ *
139
+ * @param {string[]} all
140
+ * @returns {{ e2e: string[], rest: string[] }}
141
+ */
142
+ function partitionE2E(all) {
143
+ const e2e = all.filter((file) => matchesE2E(file));
144
+ const e2eSet = new Set(e2e);
145
+ return { e2e, rest: all.filter((file) => !e2eSet.has(file)) };
146
+ }
147
+
148
+ /**
149
+ * Split the non-e2e remainder into the slow `integration` tier and the `quick`
150
+ * complement — the historical partition, unchanged.
151
+ *
152
+ * @param {string[]} rest
153
+ * @param {'quick' | 'integration'} tier
154
+ * @returns {string[]}
155
+ */
156
+ function splitBySpeed(rest, tier) {
157
+ const integration = rest.filter((file) => matchesIntegration(file));
158
+ if (tier === 'integration') {
159
+ return integration;
160
+ }
161
+ const integrationSet = new Set(integration);
162
+ return rest.filter((file) => !integrationSet.has(file));
163
+ }
164
+
84
165
  /**
85
166
  * List repo-relative test file paths for a tier.
86
167
  *
87
- * @param {'full' | 'quick' | 'integration'} tier
168
+ * `full` used to return {@link FULL_TIER_GLOBS} verbatim and let `node --test`
169
+ * expand them. It enumerates files instead since Story #5111, because the one
170
+ * thing a glob list cannot express is an exclusion: `node --test` has no
171
+ * negative pattern, so "everything except `tests/e2e/**`" is only sayable as
172
+ * a file set. The measured surface keeps the globs (see `FULL_TIER_GLOBS`).
173
+ *
174
+ * @param {'full' | 'quick' | 'integration' | 'e2e'} tier
88
175
  * @param {string} repoRoot
89
176
  * @param {typeof fs} [fsLike]
90
177
  * @returns {string[]}
@@ -93,34 +180,64 @@ export function listTestFilesForTier(tier, repoRoot, fsLike = fs) {
93
180
  const all = TEST_WALK_ROOTS.flatMap((root) =>
94
181
  walkTestFiles(path.join(repoRoot, root), root, fsLike),
95
182
  ).sort();
96
- if (tier === 'full') {
97
- return [...FULL_TIER_GLOBS];
183
+ const { e2e, rest } = partitionE2E(all);
184
+ if (tier === 'e2e') {
185
+ return e2e;
98
186
  }
99
- const integration = all.filter((file) => matchesIntegration(file));
100
- if (tier === 'integration') {
101
- return integration;
187
+ if (tier === 'full') {
188
+ return rest;
102
189
  }
103
- const integrationSet = new Set(integration);
104
- return all.filter((file) => !integrationSet.has(file));
190
+ return splitBySpeed(rest, tier);
191
+ }
192
+
193
+ /**
194
+ * Reject argv tokens that look like flags but are neither `--tier` (already
195
+ * consumed) nor a sanctioned `node --test` pass-through.
196
+ *
197
+ * Silence was the old behaviour and the reason this exists: the runner
198
+ * forwarded every unrecognized token verbatim, and `node --test` treats an
199
+ * unknown `--flag` as another **file pattern**. A typo therefore produced a
200
+ * run that matched nothing, printed a plausible-looking summary and exited 0 —
201
+ * a green that proved nothing.
202
+ *
203
+ * @param {string[]} rest
204
+ * @throws {Error} naming both the accepted tiers and the accepted flags.
205
+ */
206
+ function assertKnownFlags(rest) {
207
+ const unknown = rest.filter(
208
+ (arg) =>
209
+ arg.startsWith('--') &&
210
+ !PASSTHROUGH_FLAGS.includes(arg) &&
211
+ !PASSTHROUGH_FLAGS.some((flag) => arg.startsWith(`${flag}=`)),
212
+ );
213
+ if (unknown.length === 0) return;
214
+ throw new Error(
215
+ `[run-tests] unrecognized argument(s): ${unknown.join(', ')}. ` +
216
+ `Accepted: --tier <${TIERS.join('|')}>, ${PASSTHROUGH_FLAGS.join(', ')}, --help. ` +
217
+ 'Unrecognized flags are not forwarded: `node --test` would read them as ' +
218
+ 'file patterns and exit 0 having run nothing.',
219
+ );
105
220
  }
106
221
 
107
222
  /**
108
- * Parse `--tier <name>` from argv. Unknown tiers throw.
223
+ * Parse `--tier <name>` from argv. Unknown tiers and unknown flags throw.
109
224
  *
110
225
  * @param {string[]} argv
111
- * @returns {{ tier: 'full' | 'quick' | 'integration', rest: string[] }}
226
+ * @returns {{ tier: 'full' | 'quick' | 'integration' | 'e2e', rest: string[] }}
112
227
  */
113
228
  export function parseTierArgv(argv) {
114
229
  const tierIdx = argv.indexOf('--tier');
115
230
  if (tierIdx === -1) {
231
+ assertKnownFlags(argv);
116
232
  return { tier: 'full', rest: argv };
117
233
  }
118
234
  const tier = argv[tierIdx + 1];
119
- if (!tier || !['full', 'quick', 'integration'].includes(tier)) {
235
+ if (!tier || !TIERS.includes(tier)) {
120
236
  throw new Error(
121
- `[run-tests] --tier requires one of: full, quick, integration (got ${JSON.stringify(tier)})`,
237
+ `[run-tests] --tier requires one of: ${TIERS.join(', ')} (got ${JSON.stringify(tier)})`,
122
238
  );
123
239
  }
124
240
  const rest = argv.filter((_, i) => i !== tierIdx && i !== tierIdx + 1);
241
+ assertKnownFlags(rest);
125
242
  return { tier, rest };
126
243
  }
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { createRequire, SourceMap } from 'node:module';
3
3
  import path from 'node:path';
4
+ import { resolveDependencyVersion } from './dependency-version.js';
4
5
  import { Logger } from './Logger.js';
5
6
 
6
7
  const require = createRequire(import.meta.url);
@@ -22,18 +23,30 @@ function loadTypeScript() {
22
23
  }
23
24
  }
24
25
 
26
+ let _tsVersion = null;
27
+
25
28
  /**
26
29
  * Resolve the `typescript` package version, used to stamp baselines so
27
30
  * consumers can detect transpiler drift. Returns `'0.0.0'` when the
28
31
  * dependency is unresolvable — callers treat that sentinel as "unknown
29
32
  * environment" and may refuse to persist a baseline that includes TS rows.
30
33
  *
34
+ * **The version is read from the package manifest, never by evaluating the
35
+ * compiler** (Story #5109) — see
36
+ * [`dependency-version.js`](dependency-version.js) for how, and for what it
37
+ * cost to do it the other way. The manifest returns the identical string, so
38
+ * the stamp (and therefore every committed baseline envelope) is
39
+ * byte-identical either way. The compiler itself is still loaded — lazily, by
40
+ * `transpileIfNeeded` — the first time a `.ts`/`.tsx` input is actually
41
+ * transpiled.
42
+ *
31
43
  * @returns {string}
32
44
  */
33
45
  export function resolveTsTranspilerVersion() {
34
- const ts = loadTypeScript();
35
- if (ts && typeof ts.version === 'string') return ts.version;
36
- return '0.0.0';
46
+ if (_tsVersion === null) {
47
+ _tsVersion = resolveDependencyVersion('typescript', require) ?? '0.0.0';
48
+ }
49
+ return _tsVersion;
37
50
  }
38
51
 
39
52
  function isTypeScriptPath(filePath) {