mandrel 2.58.0 → 2.59.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 (42) hide show
  1. package/.agents/README.md +6 -3
  2. package/.agents/docs/SDLC.md +6 -7
  3. package/.agents/docs/quality-gates.md +1 -1
  4. package/.agents/instructions.md +2 -3
  5. package/.agents/runtime-deps.json +7 -2
  6. package/.agents/schemas/crap-baseline.schema.json +1 -1
  7. package/.agents/schemas/crap-report.schema.json +1 -1
  8. package/.agents/scripts/install-matrix-assert.js +48 -3
  9. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
  10. package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
  11. package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
  12. package/.agents/scripts/lib/crap-engine.js +2 -2
  13. package/.agents/scripts/lib/crap-utils.js +21 -5
  14. package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
  15. package/.agents/scripts/lib/escomplex-kernel.js +298 -0
  16. package/.agents/scripts/lib/maintainability-engine.js +3 -3
  17. package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
  20. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
  21. package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
  22. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
  23. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
  24. package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
  25. package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
  26. package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
  27. package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
  28. package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
  29. package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
  30. package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
  31. package/.agents/scripts/plan-persist.js +0 -11
  32. package/.agents/skills/skills.index.json +1 -11
  33. package/.agents/workflows/audit-to-stories.md +14 -11
  34. package/.agents/workflows/helpers/plan-reference.md +18 -7
  35. package/.agents/workflows/mandrel-plan.md +14 -13
  36. package/README.md +3 -3
  37. package/docs/CHANGELOG.md +8 -0
  38. package/lib/cli/registry.js +45 -25
  39. package/package.json +7 -2
  40. package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
  41. package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
  42. package/.agents/skills/core/scope-triage/SKILL.md +0 -48
package/.agents/README.md CHANGED
@@ -292,9 +292,12 @@ the script's location to your repo root). The required set is enumerated in
292
292
  a single vendored manifest that ships inside the bundle:
293
293
 
294
294
  - **[`runtime-deps.json`](runtime-deps.json)** — the single source of
295
- truth. Its `dependencies` block lists the **required** packages (`ajv`,
296
- `ajv-formats`, `js-yaml`, `minimatch`, `picomatch`,
297
- `typhonjs-escomplex`); its `optionalDependencies` block lists packages
295
+ truth. Its `dependencies` block lists the **required** packages — `ajv`,
296
+ `ajv-formats`, `js-yaml`, `minimatch`, `picomatch`, plus the complexity
297
+ kernel's closure (`@babel/parser`, `typhonjs-escomplex-commons`,
298
+ `escomplex-plugin-metrics-module`, `escomplex-plugin-syntax-babylon`,
299
+ `typhonjs-ast-walker`, and `babel-runtime`, which those packages require
300
+ without declaring); its `optionalDependencies` block lists packages
298
301
  used behind graceful-degradation paths (`typescript` for TS-source
299
302
  scoring in the maintainability engine, `chokidar` for `quality:watch`,
300
303
  `@commitlint/load` for commit-subject sizing).
@@ -36,7 +36,7 @@ From zero to shipped:
36
36
  handoff via `--emit-plan-seed`), and `/mandrel-plan --tickets 123[,456…]` (analyze
37
37
  existing issue(s), preferring an N=1 rewrite). `/mandrel-plan` is a **single path**
38
38
  — interrogate → author → persist, bracketed by two HITL gates and a single
39
- critic gate — with no Epic/Story router, scope-triage verdict, or
39
+ critic gate — with no Epic/Story router, split-routing verdict, or
40
40
  `deliveryShape`. Duplicate search targets open **Stories**, never Epics. The
41
41
  step-by-step lives in [`mandrel-plan.md`](../workflows/mandrel-plan.md).
42
42
 
@@ -211,15 +211,14 @@ the SDLC depends on:
211
211
  Epic-era Tech Spec / Acceptance Table / clarity-gate / decompose /
212
212
  reconciler machinery. `plan-persist.js` runs the deterministic gates
213
213
  (ticket validator, split policy, reachability, budget) and — for N>1 —
214
- `assertAcceptancePartition` so every acceptance criterion belongs to
215
- exactly one Story.
214
+ the same-wave collision refusal, so a split whose siblings the dispatcher
215
+ would serialize anyway is rejected before the first `createIssue`.
216
216
  - **Handoff.** Persist creates the Story issue(s) at `agent::ready` and
217
217
  names the delivery command: `/mandrel-deliver <storyId> [<storyId> ...]`.
218
218
 
219
- Optional split advisory notes come from
220
- [`core/scope-triage`](../skills/core/scope-triage/SKILL.md); there is no
221
- `epic|story` routing verdict, scorer, schema field, or label transition
222
- behind them.
219
+ There is no `epic|story` routing verdict, scorer, schema field, or label
220
+ transition anywhere on the path: sizing is the authoring model's cohesion
221
+ judgment, and the one deterministic split gate is the collision refusal.
223
222
 
224
223
  Audit findings enter planning through
225
224
  [`/audit-to-stories`](../workflows/audit-to-stories.md), which groups and
@@ -397,7 +397,7 @@ fails the run.
397
397
 
398
398
  A sibling per-method gate alongside the maintainability ratchet. CRAP
399
399
  scores each JavaScript method via `c² · (1 − cov)³ + c`, combining
400
- `typhonjs-escomplex` cyclomatic complexity with per-method coverage from
400
+ kernel-derived cyclomatic complexity with per-method coverage from
401
401
  the `coverage/coverage-final.json` artifact your test runner already
402
402
  produces. No new runtime dependencies. Runs at three sites:
403
403
  `close-validation` (story close), `ci.yml` (push + PR), and
@@ -194,8 +194,7 @@ anything under it.
194
194
  `/mandrel-plan` sizes each Story as a **capability slice a frontier model
195
195
  delivers and self-verifies in one pass** — a broad footprint is normal
196
196
  when the change is cohesive, and no plan-time ceiling scores it; do not
197
- re-slice it into per-module fragments. On a `⚠️ COMPLEXITY WARNING` or
198
- out-of-scope task: **plan first** (numbered cohesive sub-steps in a
199
- `<!-- DECOMPOSITION -->`
197
+ re-slice it into per-module fragments. On an out-of-scope task: **plan
198
+ first** (numbered cohesive sub-steps in a `<!-- DECOMPOSITION -->`
200
199
  block), **commit incrementally** per sub-step, and **fail fast** — STOP
201
200
  and report if any sub-step fails validation.
@@ -1,12 +1,17 @@
1
1
  {
2
- "description": "Single source of truth for the third-party npm packages the .agents/ framework scripts import at runtime. This manifest ships *inside* .agents/ so it travels with the mandrel package into consumer projects. Consumers must provide these packages in the node_modules resolvable from their repository root (the framework scripts free-ride on the consumer's install). The `dependencies` block is fail-fast enforced by the preflight guard (.agents/scripts/lib/runtime-deps/ensure-installed.js); the `optionalDependencies` block lists packages that are imported behind graceful-degradation paths (try/catch or dev-only watch tooling) and are therefore declared but never preflight-blocked. A drift test (tests/scripts/runtime-deps-drift.test.js) asserts every third-party import under .agents/scripts/** is declared here. Version ranges mirror the framework's own root package.json.",
2
+ "description": "Single source of truth for the third-party npm packages the .agents/ framework scripts import at runtime. This manifest ships *inside* .agents/ so it travels with the mandrel package into consumer projects. Consumers must provide these packages in the node_modules resolvable from their repository root (the framework scripts free-ride on the consumer's install). The `dependencies` block is fail-fast enforced by the preflight guard (.agents/scripts/lib/runtime-deps/ensure-installed.js); the `optionalDependencies` block lists packages that are imported behind graceful-degradation paths (try/catch or dev-only watch tooling) and are therefore declared but never preflight-blocked. A drift test (tests/scripts/runtime-deps-drift.test.js) asserts every third-party import under .agents/scripts/** is declared here. Version ranges mirror the framework's own root package.json. Two entries need their reason recorded. `@babel/parser` is pinned to ^7 because the kernel's fixed plugin list does not parse under 8.x, and the range is documentation rather than enforcement \u2014 `.agents/` resolves from the consumer's own node_modules \u2014 so the kernel asserts the resolved major at load. `babel-runtime` is declared even though no framework script imports it: four of the metric-core packages require it and none of them declares it, so it resolved only by being hoisted from the parse/dispatch plumbing this framework used to depend on. Declaring it makes the closure explicit and preflight-enforced. The import-vs-manifest drift guard only flags imported-but-undeclared, so a declared-but-unimported peer repair like this is legal by design.",
3
3
  "dependencies": {
4
+ "@babel/parser": "^7.29.3",
4
5
  "ajv": "^8.20.0",
5
6
  "ajv-formats": "^3.0.1",
7
+ "babel-runtime": "^6.26.0",
8
+ "escomplex-plugin-metrics-module": "^0.1.0",
9
+ "escomplex-plugin-syntax-babylon": "^0.1.0",
6
10
  "js-yaml": "^4.3.1",
7
11
  "minimatch": "^10.0.0",
8
12
  "picomatch": "^4.0.4",
9
- "typhonjs-escomplex": "^0.1.0"
13
+ "typhonjs-ast-walker": "^0.2.1",
14
+ "typhonjs-escomplex-commons": "^0.1.1"
10
15
  },
11
16
  "optionalDependencies": {
12
17
  "@commitlint/load": "^21.0.0",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "escomplexVersion": {
20
20
  "type": "string",
21
- "description": "Version of the typhonjs-escomplex dependency used to produce this baseline. A mismatch against the installed dependency fails the gate closed (different kernel semantics are not negotiable)."
21
+ "description": "Version of the package that computes the metrics (escomplex-plugin-metrics-module) used to produce this baseline. Informational: the axis that compared it was removed in Story #5336 — the v2 envelope does not carry the field, so it could not fire. scoringSemantics is the axis that rejects an incompatible scorer."
22
22
  },
23
23
  "tsTranspilerVersion": {
24
24
  "type": "string",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "escomplexVersion": {
16
16
  "type": "string",
17
- "description": "typhonjs-escomplex version used for the scan."
17
+ "description": "Version of the package that computes the metrics (escomplex-plugin-metrics-module) used for the scan."
18
18
  },
19
19
  "summary": {
20
20
  "type": "object",
@@ -59,17 +59,62 @@ import path from 'node:path';
59
59
  * `.agents/runtime-deps.json` and provided by the consumer's install of
60
60
  * `mandrel` (npm hoists them into node_modules), but they MUST NOT
61
61
  * appear in the consumer's *declared* package.json dependencies. Kept in sync
62
- * with `.agents/runtime-deps.json` `dependencies` keys.
62
+ * with `.agents/runtime-deps.json` `dependencies` keys — the whole set, so
63
+ * this list documents the closure; {@link SHAREABLE_RUNTIME_DEPS} carries the
64
+ * policy about which of them a consumer may also declare.
63
65
  */
64
66
  const FRAMEWORK_RUNTIME_DEPS = [
67
+ '@babel/parser',
65
68
  'ajv',
66
69
  'ajv-formats',
70
+ 'babel-runtime',
71
+ 'escomplex-plugin-metrics-module',
72
+ 'escomplex-plugin-syntax-babylon',
67
73
  'js-yaml',
68
74
  'minimatch',
69
75
  'picomatch',
70
- 'typhonjs-escomplex',
76
+ 'typhonjs-ast-walker',
77
+ 'typhonjs-escomplex-commons',
71
78
  ];
72
79
 
80
+ /**
81
+ * Runtime dependencies the framework declares but does **not** claim
82
+ * exclusively, so a consumer declaring one is not evidence of a mutated
83
+ * manifest.
84
+ *
85
+ * This check exists to catch a consumer's `package.json` being written into
86
+ * with framework-internal packages. That inference only holds for packages
87
+ * nobody else would plausibly declare. `@babel/parser` and `babel-runtime`
88
+ * fail that test completely: a repository with its own Babel pipeline, AST
89
+ * tooling or legacy transpile output declares them for its own reasons, and
90
+ * failing `manifest-clean` for that would be a false positive on an ordinary
91
+ * consumer rather than a caught mutation.
92
+ *
93
+ * Kept as an explicit list rather than a heuristic: adding a widely-used
94
+ * package to the framework's runtime closure should be a deliberate decision
95
+ * to stop policing it, recorded here.
96
+ */
97
+ const SHAREABLE_RUNTIME_DEPS = ['@babel/parser', 'babel-runtime'];
98
+
99
+ /**
100
+ * Is this framework dependency's presence in a consumer manifest evidence of a
101
+ * mutated manifest?
102
+ *
103
+ * Only for packages the framework claims exclusively. A shareable one is
104
+ * declared by ordinary repositories for their own reasons.
105
+ *
106
+ * @param {string} dep
107
+ * @param {Record<string, string>} declared Consumer's merged declared deps.
108
+ * @returns {boolean}
109
+ */
110
+ function isLeak(dep, declared) {
111
+ if (!(dep in declared)) return false;
112
+ return !SHAREABLE_RUNTIME_DEPS.includes(dep);
113
+ }
114
+
115
+ /** Exported for the manifest-mirror drift assertion. */
116
+ export { FRAMEWORK_RUNTIME_DEPS, SHAREABLE_RUNTIME_DEPS };
117
+
73
118
  /** The verdict marker `mandrel doctor` prints when every check passes. */
74
119
  const DOCTOR_READY_MARKER = '✅ Ready';
75
120
 
@@ -157,7 +202,7 @@ export function checkManifestClean({ consumer, packageName, fs = nodeFs }) {
157
202
  ...(manifest.peerDependencies ?? {}),
158
203
  };
159
204
 
160
- const leaked = FRAMEWORK_RUNTIME_DEPS.filter((dep) => dep in declared);
205
+ const leaked = FRAMEWORK_RUNTIME_DEPS.filter((dep) => isLeak(dep, declared));
161
206
  if (leaked.length > 0) {
162
207
  return {
163
208
  ok: false,
@@ -9,7 +9,7 @@
9
9
  * - Problem Statement (aggregated severity profile)
10
10
  * - Recommended Direction (rollup of recommendations by dimension)
11
11
  * - Key Assumptions (carries the source-report links forward)
12
- * - MVP Scope (the proposed Stories, one bullet per group)
12
+ * - MVP Scope (the findings themselves, flat — Story #5332)
13
13
  * - Key Files (explicit file paths so `/mandrel-plan` authoring has concrete
14
14
  * anchors)
15
15
  * - Not Doing (out-of-scope items by convention)
@@ -19,7 +19,6 @@
19
19
 
20
20
  import { SEVERITIES } from '../findings/severity.js';
21
21
  import { auditLabelFooterForFindings } from './audit-label-taxonomy.js';
22
- import { formatEpicGrouping } from './epic-grouping-directive.js';
23
22
  import {
24
23
  renderFingerprintFooter,
25
24
  renderSemanticKeyFooter,
@@ -93,35 +92,56 @@ function formatRecommendedDirection(findings) {
93
92
  return lines.join('\n');
94
93
  }
95
94
 
96
- function formatMVPScope(groups) {
95
+ /**
96
+ * The findings, flat (Story #5332).
97
+ *
98
+ * This section used to render one numbered bullet per `groupFindings` group
99
+ * under a `## Grouping` directive — a partition the seed had already decided
100
+ * before the planner read a word of it, and at a grain (`groupFindings`'s) the
101
+ * planner's cohesion judgment never got to review. The measured result was a
102
+ * sweep of 44 findings arriving as 18 Stories. The seed now states what was
103
+ * found and lets N reach the planner undecided; container grouping is Gate
104
+ * #3's call at persist, where N is known.
105
+ *
106
+ * @param {object[]} findings
107
+ * @returns {string}
108
+ */
109
+ function formatFindingsList(findings) {
110
+ return findings
111
+ .map((f) => {
112
+ const label = DIMENSION_LABEL[f.dimension] ?? f.dimension;
113
+ const file = f.files?.[0] ? ` (\`${f.files[0]}\`)` : '';
114
+ const severity = f.severity ? `${f.severity} · ` : '';
115
+ return `- **${f.title}** — ${severity}${label}${file}`;
116
+ })
117
+ .join('\n');
118
+ }
119
+
120
+ /**
121
+ * The machine-readable dedup identity, one footer set per group.
122
+ *
123
+ * Deliberately **not** folded into one footer over the whole sweep, and
124
+ * deliberately not attached to a visible bullet. Each group's fingerprint and
125
+ * location-based semantic-key footers are the identity the next sweep matches
126
+ * on (Story #4626), and the `audit::*` labels are the reason it ever looks at
127
+ * the issue at all — an indexed sweep answers exact lookups from the labelled
128
+ * pool without reaching the provider, so a Story missing the labels is
129
+ * invisible however good its fingerprints (Story #5307). They are HTML
130
+ * comments, so they carry no partition to the planner's eye while staying
131
+ * byte-identical to what the standalone-Stories path emits.
132
+ *
133
+ * @param {object[]} groups
134
+ * @returns {string}
135
+ */
136
+ function formatDedupFooters(groups) {
97
137
  return groups
98
- .map((g, idx) => {
99
- const dims = g.dimensions.join(' / ');
100
- const file = g.files[0] ? ` (\`${g.files[0]}\`)` : '';
101
- // Carry each group's fingerprint (and location-based semantic-key)
102
- // footer into the seed so a Story authored from it via `/mandrel-plan` inherits
103
- // the dedup identity — without this the recommended `/mandrel-plan --seed-file`
104
- // path is invisible to the next sweep's dedup (Story #4626). The footers
105
- // are HTML comments, so they never render in the visible one-pager but
106
- // stay machine-readable for the dedup probe.
138
+ .map((g) => {
107
139
  const findings = Array.isArray(g.findings) ? g.findings : [];
108
- const footers = [
140
+ return [
109
141
  renderFingerprintFooter(findings),
110
142
  renderSemanticKeyFooter(findings),
111
- // ...and the `audit::*` labels the dedup corpus is listed by.
112
- //
113
- // Without them a Story the chained planning path files is absent from
114
- // the pool an indexed sweep matches against, and with an index in play
115
- // the exact lookup is answered from that pool without ever reaching
116
- // the provider — so the fingerprint footer above cannot rescue it. The
117
- // two footers are therefore a pair: one carries the identity, the
118
- // other carries the reason the next sweep ever looks at this issue
119
- // (Story #5307).
120
143
  auditLabelFooterForFindings(findings),
121
- ]
122
- .map((f) => ` ${f}`)
123
- .join('\n');
124
- return `${idx + 1}. **${g.title}** — ${dims}${file}\n${footers}`;
144
+ ].join('\n');
125
145
  })
126
146
  .join('\n');
127
147
  }
@@ -165,10 +185,10 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
165
185
  }
166
186
  const problem = formatProblemStatement(findings);
167
187
  const direction = formatRecommendedDirection(findings);
168
- const scope = formatMVPScope(groups);
188
+ const scope = formatFindingsList(findings);
169
189
  const files = formatKeyFiles(groups);
170
190
  const assumptions = formatKeyAssumptions(sourceReports);
171
- const grouping = formatEpicGrouping(groups);
191
+ const dedupFooters = formatDedupFooters(groups);
172
192
 
173
193
  return [
174
194
  '# Idea Seed: Audit Remediation',
@@ -187,16 +207,14 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
187
207
  '',
188
208
  '## MVP Scope',
189
209
  '',
190
- scope || '_(no proposed stories)_',
210
+ scope || '_(no findings)_',
211
+ '',
212
+ dedupFooters,
191
213
  '',
192
214
  '## Key Files',
193
215
  '',
194
216
  files,
195
217
  '',
196
- '## Grouping',
197
- '',
198
- grouping,
199
- '',
200
218
  '## Not Doing',
201
219
  '',
202
220
  '- Findings with severity below the operator-selected threshold.',
@@ -22,7 +22,6 @@
22
22
 
23
23
  import path from 'node:path';
24
24
  import { readBaselineAtRef } from '../../baseline-loader.js';
25
- import { resolveEscomplexVersion } from '../../crap-utils.js';
26
25
  import { loadBaseline } from '../../gates/baseline-store.js';
27
26
  import {
28
27
  loadFile as loadBaselineFile,
@@ -38,11 +37,6 @@ import {
38
37
  * epic-ref callers always know their own path); without one the reader
39
38
  * resolves the configured location for the `crap` kind itself.
40
39
  *
41
- * `escomplexVersion` is back-filled from the running scorer exactly as the
42
- * deleted projection stamped it — the v2 envelope does not carry the field, and
43
- * `escomplex-mismatch` is a fatal axis, so omitting it would fail every
44
- * baseline closed on a value that was never on disk.
45
- *
46
40
  * Returns `null` on any read/parse/schema failure; the preview gate maps that
47
41
  * to "no baseline" and fails open, as it always did.
48
42
  *
@@ -69,7 +63,6 @@ function readCrapBaselineFromTree({ baselinePath, projectRoot } = {}) {
69
63
  }
70
64
  return {
71
65
  kernelVersion: envelope.kernelVersion,
72
- escomplexVersion: resolveEscomplexVersion(),
73
66
  scoringSemantics: envelope.scoringSemantics ?? null,
74
67
  tsTranspilerVersion:
75
68
  typeof envelope.tsTranspilerVersion === 'string'
@@ -135,7 +128,6 @@ export function loadCrapBaseline({
135
128
  // No-epicRef path delegates to readFromTree which already applies the
136
129
  // shape-check + tsTranspilerVersion back-fill, so a tree read returns either
137
130
  // a valid envelope or null. Epic-ref path bypasses that helper — shape-check
138
- // + back-fill happens here.
139
131
  if (!epicRef) return parsed;
140
132
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
133
  return null;
@@ -53,11 +53,29 @@ export { loadCrapBaseline } from './_crap-read.js';
53
53
  const __filename = fileURLToPath(import.meta.url);
54
54
 
55
55
  /**
56
- * Resolve the running `typhonjs-escomplex` version by walking up from this
57
- * module's directory and reading the nearest
58
- * `node_modules/typhonjs-escomplex/package.json`. Returns `'0.0.0'` when
59
- * the dependency cannot be found — callers treat that sentinel as
60
- * "unknown environment" and the writer refuses to persist a baseline.
56
+ * Package whose resolved version is stamped as the CRAP scorer identity.
57
+ *
58
+ * `escomplex-plugin-metrics-module` owns the Halstead and cyclomatic math. The
59
+ * displaced `typhonjs-escomplex` shell contributed a parser and a plugin bus
60
+ * and never computed a metric, so stamping it described the shell.
61
+ */
62
+ const SCORER_PACKAGE = 'escomplex-plugin-metrics-module';
63
+
64
+ /**
65
+ * Resolve the running scorer's version by walking up from this module's
66
+ * directory and reading the nearest `node_modules/<SCORER_PACKAGE>/package.json`.
67
+ *
68
+ * The package read is `escomplex-plugin-metrics-module`, which computes the
69
+ * metrics. It was `typhonjs-escomplex` until Story #5336 replaced that shell's
70
+ * parse and dispatch layers; reading a package that no longer exists would
71
+ * yield the sentinel below on every fresh install, and the writer would refuse
72
+ * to persist a baseline. (Locally it can look fine anyway — the walk-up escapes
73
+ * a worktree into the parent checkout, whose `node_modules` may still hold the
74
+ * removed package. CI's fresh clone has no such parent.)
75
+ *
76
+ * Returns `'0.0.0'` when the dependency cannot be found — callers treat that
77
+ * sentinel as "unknown environment" and the writer refuses to persist a
78
+ * baseline.
61
79
  *
62
80
  * @returns {string}
63
81
  */
@@ -68,7 +86,7 @@ export function kernelVersion() {
68
86
  const pkgPath = path.join(
69
87
  dir,
70
88
  'node_modules',
71
- 'typhonjs-escomplex',
89
+ SCORER_PACKAGE,
72
90
  'package.json',
73
91
  );
74
92
  if (fs.existsSync(pkgPath)) {
@@ -695,7 +713,14 @@ export function assessComparisonBasis(compareResult, opts = {}) {
695
713
  * transpiler's sourcemap. `startLine` is half the row identity key, so a
696
714
  * transpiler change makes the rows incomparable rather than merely stale; see
697
715
  * the `ts-transpiler-drift` axis below for the two exemptions that bound it.
698
- * `escomplexVersion` mismatch has always failed closed.
716
+ *
717
+ * There is no `escomplexVersion` axis. One existed, declared `fatal`, and
718
+ * could not fire in either direction: the v2 envelope does not carry the
719
+ * field, so the loaded-envelope pass excluded it as vacuous, and the peer pass
720
+ * back-filled the value from the running scorer before comparing it against
721
+ * the running scorer. A gate that presents as fatal and cannot fail is worse
722
+ * than an absent one, so it was removed rather than repaired — `scoringSemantics`
723
+ * is the axis that actually rejects an incompatible scorer.
699
724
  */
700
725
  /**
701
726
  * The one re-seed recipe every coordinate-invalidating axis ends on. Three
@@ -713,14 +738,6 @@ export const CRAP_COMPAT_AXES = [
713
738
  // missing-baseline and kernel-drift checks live in exactly one place;
714
739
  // each per-kind table composes them in with its own kind label.
715
740
  missingBaselineAxis('CRAP'),
716
- {
717
- name: 'escomplex-mismatch',
718
- severity: 'fatal',
719
- check: ({ baseline, runningEscomplexVersion }) =>
720
- baseline && baseline.escomplexVersion !== runningEscomplexVersion
721
- ? `[CRAP] scorer changed from ${baseline.escomplexVersion} to ${runningEscomplexVersion} — run 'npm run crap:update'`
722
- : null,
723
- },
724
741
  kernelDriftAxis('CRAP'),
725
742
  {
726
743
  name: 'scoring-semantics-drift',
@@ -874,9 +891,9 @@ export function evaluateBaselineCompatibility(ctx) {
874
891
  * predating both questions. The fourth (Story #4969) is about the `method`
875
892
  * half — a baseline still carrying ordinal-keyed anonymous rows.
876
893
  *
877
- * `escomplex-mismatch` and `kernel-drift` stay out — the v2 envelope carries
878
- * no `escomplexVersion`, so that axis would compare `undefined` to `undefined`
879
- * and pass vacuously, which is worse than not running it.
894
+ * `kernel-drift` stays out: it is a warn-level axis, and this pass turns a
895
+ * message into a fail-closed error. (`escomplex-mismatch` was the other
896
+ * exclusion until it was removed outright — see `CRAP_COMPAT_AXES`.)
880
897
  */
881
898
  const LOADED_ENVELOPE_AXES = [
882
899
  'scoring-semantics-drift',
@@ -1,4 +1,3 @@
1
- import escomplex from 'typhonjs-escomplex';
2
1
  import { coverageForMethodInEntry } from './coverage-utils.js';
3
2
  // `finalizeMethodRowsWithBaseline` (Story #4981) lives in
4
3
  // crap-baseline-join.js — `resolveRawRow`, the per-row policy it shares with
@@ -15,6 +14,7 @@ import {
15
14
  } from './crap-coordinates.js';
16
15
  import { deriveMethodIdentities } from './crap-method-identity.js';
17
16
  import { install as installAstCompat } from './escomplex-ast-compat.js';
17
+ import { analyzeModule } from './escomplex-kernel.js';
18
18
 
19
19
  export { COORDINATE_ORIGINAL, COORDINATE_TRANSPILED, crapFormula };
20
20
 
@@ -256,7 +256,7 @@ export function calculateCrapForSource(
256
256
  ) {
257
257
  let report;
258
258
  try {
259
- report = escomplex.analyzeModule(source);
259
+ report = analyzeModule(source);
260
260
  } catch {
261
261
  return UNSCORABLE;
262
262
  }
@@ -1,6 +1,5 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import escomplex from 'typhonjs-escomplex';
4
3
  import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
5
4
  import { findCoverageEntry } from './coverage-utils.js';
6
5
  import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
@@ -11,6 +10,7 @@ import {
11
10
  shouldSkipFileForNoCoverage,
12
11
  } from './crap-baseline-join.js';
13
12
  import { COORDINATE_ORIGINAL, methodRowsFromReport } from './crap-engine.js';
13
+ import { analyzeModule } from './escomplex-kernel.js';
14
14
  import { Logger } from './Logger.js';
15
15
  import { scanDirectory } from './maintainability-utils.js';
16
16
  import {
@@ -36,8 +36,24 @@ export { resolveTsTranspilerVersion };
36
36
  const SCHEMA_REF = '.agents/schemas/crap-baseline.schema.json';
37
37
 
38
38
  /**
39
- * Resolve the running `typhonjs-escomplex` version by walking up from `cwd`
40
- * and reading the nearest `node_modules/typhonjs-escomplex/package.json`.
39
+ * Package whose resolved version is stamped as the scorer identity.
40
+ *
41
+ * `escomplex-plugin-metrics-module` computes the metrics. The retired
42
+ * `typhonjs-escomplex` facade did not, so stamping it described the shell
43
+ * rather than the scorer.
44
+ */
45
+ const SCORER_PACKAGE = 'escomplex-plugin-metrics-module';
46
+
47
+ /**
48
+ * Resolve the running scorer's version by walking up from `cwd` and reading the
49
+ * nearest `node_modules/<SCORER_PACKAGE>/package.json`.
50
+ *
51
+ * The package read is `escomplex-plugin-metrics-module`, which owns the
52
+ * Halstead and maintainability math — not the displaced `typhonjs-escomplex`
53
+ * shell, which contributed a parser and a plugin bus and never a metric. The
54
+ * stamp is supposed to answer "could this scorer have produced different
55
+ * numbers", so it has to name the package that computes them.
56
+ *
41
57
  * Returns `'0.0.0'` when the dependency cannot be found — callers treat that
42
58
  * sentinel as "unknown environment" and may refuse to persist a baseline.
43
59
  *
@@ -51,7 +67,7 @@ export function resolveEscomplexVersion(cwd = process.cwd()) {
51
67
  const pkgPath = path.join(
52
68
  dir,
53
69
  'node_modules',
54
- 'typhonjs-escomplex',
70
+ SCORER_PACKAGE,
55
71
  'package.json',
56
72
  );
57
73
  if (fs.existsSync(pkgPath)) {
@@ -266,7 +282,7 @@ export function checkResolutionFloor(resolution, floor) {
266
282
  function analyzeOnce(source, coverageForFile, mapLine = null) {
267
283
  let report;
268
284
  try {
269
- report = escomplex.analyzeModule(source);
285
+ report = analyzeModule(source);
270
286
  } catch {
271
287
  return { report: null, miScore: 0, crapRows: [], parseError: true };
272
288
  }
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * ## The upstream defect
6
6
  *
7
- * `typhonjs-escomplex` parses with `@typhonjs/babel-parser`, so every AST it
8
- * analyses is a **Babel** AST. But `typhonjs-escomplex-commons`'
7
+ * The kernel parses with `@babel/parser`, so every AST it analyses is a
8
+ * **Babel** AST. But `typhonjs-escomplex-commons`'
9
9
  * `utils/ast/astSyntax.js` — the code generator that `ASTGenerator` drives —
10
10
  * was written against **ESTree**. The two disagree on node names
11
11
  * (`OptionalMemberExpression` vs a `MemberExpression` with `optional: true`)
@@ -62,6 +62,21 @@ import { createRequire } from 'node:module';
62
62
 
63
63
  const require = createRequire(import.meta.url);
64
64
 
65
+ /**
66
+ * The package whose resolution anchors every `typhonjs-escomplex-commons` deep
67
+ * import in this module.
68
+ *
69
+ * `escomplex-plugin-syntax-babylon` is the package that actually reads the
70
+ * `astSyntax` table during a metric traversal, so its copy of `commons` is the
71
+ * only one worth patching.
72
+ *
73
+ * Module-local on purpose. A test proves the binding by resolving from this
74
+ * package's own root itself — which is the assertion worth making, since our
75
+ * copy and the plugin's coincide under a hoisting installer and an
76
+ * "ours === theirs" check would pass vacuously.
77
+ */
78
+ const ANCHOR_PACKAGE = 'escomplex-plugin-syntax-babylon';
79
+
65
80
  /** Marker set on every function this module installs, for idempotency. */
66
81
  const PATCH_MARKER = Symbol.for('mandrel.escomplexAstCompat');
67
82
 
@@ -77,25 +92,32 @@ let installResult = null;
77
92
  * back to today's behaviour (unscorable files, now reported explicitly by
78
93
  * the engine rather than silently scored 0).
79
94
  *
80
- * The patch must land on the *same* `typhonjs-escomplex-commons` instance the
81
- * kernel loads, so `commons` is resolved **through `typhonjs-escomplex`'s own
82
- * resolution** rather than from here. Resolving it directly would be a coin
83
- * flip: under a hoisting installer it usually finds the same copy, but under
84
- * pnpm's isolated layout — or as soon as anything declares `commons` directly —
85
- * it can find a *different* physical copy, and the patch then lands on a table
86
- * nobody reads while `install()` cheerfully reports success. Anchoring makes
87
- * that failure mode unreachable.
95
+ * The patch must land on the *same* `astSyntax` table the metric traversal
96
+ * reads, and that table is resolved by **`escomplex-plugin-syntax-babylon`**,
97
+ * from its own location — `PluginSyntaxBabylon` requires
98
+ * `typhonjs-escomplex-commons/dist/utils/ast/ASTGenerator` and `ASTState` binds
99
+ * the table it finds. So `commons` is anchored through the syntax plugin's
100
+ * resolution, not through this module's and not through the kernel's.
101
+ *
102
+ * Resolving it from here, or from the kernel, would be a coin flip: under a
103
+ * hoisting installer every copy usually coincides, but under pnpm's isolated
104
+ * layout — or as soon as anything declares `commons` at a different version —
105
+ * the plugin can read a *different* physical copy, and the patch then lands on
106
+ * a table nobody reads while `install()` cheerfully reports success. Anchoring
107
+ * on the reader makes that unreachable. Note this is why a test asserting
108
+ * "our copy === the patched copy" proves nothing: under hoisting it passes
109
+ * vacuously — a test must resolve from the plugin's own root instead.
88
110
  *
89
111
  * `requireFn` is the test seam: a cross-checkout verification harness passes
90
- * its own `createRequire` so the anchor starts from that checkout's escomplex.
112
+ * its own `createRequire` so the anchor starts from that checkout's plugin.
91
113
  *
92
114
  * @param {NodeJS.Require} [requireFn]
93
115
  * @returns {Record<string, Function>|null}
94
116
  */
95
117
  function resolveSyntaxTable(requireFn = require) {
96
118
  try {
97
- const fromKernel = createRequire(requireFn.resolve('typhonjs-escomplex'));
98
- const mod = fromKernel(
119
+ const fromReader = createRequire(requireFn.resolve(ANCHOR_PACKAGE));
120
+ const mod = fromReader(
99
121
  'typhonjs-escomplex-commons/dist/utils/ast/astSyntax.js',
100
122
  );
101
123
  const table = mod?.default ?? mod;
@@ -337,8 +359,8 @@ export function install(options = {}) {
337
359
  /**
338
360
  * `ASTUtil` is only needed by the `OptionalCallExpression` handler, and only at
339
361
  * call time — resolving it lazily keeps `install()` free of a second deep
340
- * import that could fail at module load. Anchored through the kernel for the
341
- * same reason as {@link resolveSyntaxTable}.
362
+ * import that could fail at module load. Anchored through the syntax plugin
363
+ * for the same reason as {@link resolveSyntaxTable}.
342
364
  *
343
365
  * `formatSequence` is a pure helper that takes the traveler and state as
344
366
  * arguments, so which copy answers is immaterial — but resolving it the same
@@ -347,8 +369,8 @@ export function install(options = {}) {
347
369
  * @returns {{ formatSequence: Function }}
348
370
  */
349
371
  function ASTUtil() {
350
- const fromKernel = createRequire(require.resolve('typhonjs-escomplex'));
351
- const mod = fromKernel(
372
+ const fromReader = createRequire(require.resolve(ANCHOR_PACKAGE));
373
+ const mod = fromReader(
352
374
  'typhonjs-escomplex-commons/dist/utils/ast/ASTUtil.js',
353
375
  );
354
376
  return mod?.default ?? mod;