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.
- package/.agents/README.md +6 -3
- package/.agents/docs/SDLC.md +6 -7
- package/.agents/docs/quality-gates.md +1 -1
- package/.agents/instructions.md +2 -3
- package/.agents/runtime-deps.json +7 -2
- package/.agents/schemas/crap-baseline.schema.json +1 -1
- package/.agents/schemas/crap-report.schema.json +1 -1
- package/.agents/scripts/install-matrix-assert.js +48 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +51 -33
- package/.agents/scripts/lib/baselines/kinds/_crap-read.js +0 -8
- package/.agents/scripts/lib/baselines/kinds/crap.js +35 -18
- package/.agents/scripts/lib/crap-engine.js +2 -2
- package/.agents/scripts/lib/crap-utils.js +21 -5
- package/.agents/scripts/lib/escomplex-ast-compat.js +39 -17
- package/.agents/scripts/lib/escomplex-kernel.js +298 -0
- package/.agents/scripts/lib/maintainability-engine.js +3 -3
- package/.agents/scripts/lib/orchestration/plan-context.js +31 -25
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +28 -24
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +8 -9
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +1 -1
- package/.agents/scripts/lib/orchestration/plan-persist/wave-collision-gate.js +107 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +25 -209
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +8 -5
- package/.agents/scripts/lib/runtime-deps/dep-resolution.js +155 -0
- package/.agents/scripts/lib/runtime-deps/ensure-installed.js +44 -9
- package/.agents/scripts/lib/runtime-deps/parser-major.js +110 -0
- package/.agents/scripts/lib/runtime-deps/preflight.js +6 -25
- package/.agents/scripts/lib/runtime-deps/scan-imports.js +46 -1
- package/.agents/scripts/lib/skills/walk-skill-files.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +21 -18
- package/.agents/scripts/plan-persist.js +0 -11
- package/.agents/skills/skills.index.json +1 -11
- package/.agents/workflows/audit-to-stories.md +14 -11
- package/.agents/workflows/helpers/plan-reference.md +18 -7
- package/.agents/workflows/mandrel-plan.md +14 -13
- package/README.md +3 -3
- package/docs/CHANGELOG.md +8 -0
- package/lib/cli/registry.js +45 -25
- package/package.json +7 -2
- package/.agents/scripts/lib/orchestration/split-policy-validator.js +0 -188
- package/.agents/scripts/lib/templates/spec-author-prompts.js +0 -76
- 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
|
|
296
|
-
`ajv-formats`, `js-yaml`, `minimatch`, `picomatch`,
|
|
297
|
-
`typhonjs-escomplex
|
|
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).
|
package/.agents/docs/SDLC.md
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
package/.agents/instructions.md
CHANGED
|
@@ -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
|
|
198
|
-
|
|
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-
|
|
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
|
|
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": "
|
|
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-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
188
|
+
const scope = formatFindingsList(findings);
|
|
169
189
|
const files = formatKeyFiles(groups);
|
|
170
190
|
const assumptions = formatKeyAssumptions(sourceReports);
|
|
171
|
-
const
|
|
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
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
* `
|
|
878
|
-
*
|
|
879
|
-
*
|
|
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 =
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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* `
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
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
|
|
98
|
-
const mod =
|
|
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
|
|
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
|
|
351
|
-
const mod =
|
|
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;
|