mandrel 2.24.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +50 -13
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -221,6 +221,11 @@ function readAndShape(kind, absolutePath) {
|
|
|
221
221
|
// dropping it here would make every baseline look unstamped and fail the
|
|
222
222
|
// whole repo closed on a stamp that is actually present on disk.
|
|
223
223
|
scoringSemantics: parsed.scoringSemantics,
|
|
224
|
+
// Story #4866 — same contract for the transpiler stamp. Dropping it here
|
|
225
|
+
// would leave the ts-transpiler compat axis reachable but blind: it would
|
|
226
|
+
// read `undefined` off every loaded envelope and pass vacuously, which is
|
|
227
|
+
// the exact deadness this Story exists to end.
|
|
228
|
+
tsTranspilerVersion: parsed.tsTranspilerVersion,
|
|
224
229
|
};
|
|
225
230
|
}
|
|
226
231
|
|
|
@@ -314,6 +319,11 @@ export function loadFile(absolutePath, opts = {}) {
|
|
|
314
319
|
// dropping it here would make every baseline look unstamped and fail the
|
|
315
320
|
// whole repo closed on a stamp that is actually present on disk.
|
|
316
321
|
scoringSemantics: parsed.scoringSemantics,
|
|
322
|
+
// Story #4866 — same contract for the transpiler stamp. Dropping it here
|
|
323
|
+
// would leave the ts-transpiler compat axis reachable but blind: it would
|
|
324
|
+
// read `undefined` off every loaded envelope and pass vacuously, which is
|
|
325
|
+
// the exact deadness this Story exists to end.
|
|
326
|
+
tsTranspilerVersion: parsed.tsTranspilerVersion,
|
|
317
327
|
};
|
|
318
328
|
}
|
|
319
329
|
|
|
@@ -262,9 +262,10 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
262
262
|
// declaration order; the row sort is done by `sortRows()`.
|
|
263
263
|
//
|
|
264
264
|
// Story #4775: the projection is deliberately explicit, so any per-kind
|
|
265
|
-
// envelope stamp (`scoringSemantics
|
|
266
|
-
//
|
|
267
|
-
//
|
|
265
|
+
// envelope stamp (`scoringSemantics`, and `tsTranspilerVersion` since Story
|
|
266
|
+
// #4866) must be carried through by name or it is silently dropped on the
|
|
267
|
+
// way to disk — the stamp would then be present in memory, validated, and
|
|
268
|
+
// absent from the file it exists to protect.
|
|
268
269
|
const canonical = {
|
|
269
270
|
$schema: envelope.$schema,
|
|
270
271
|
kernelVersion: envelope.kernelVersion,
|
|
@@ -272,6 +273,9 @@ export function writeFile(absPath, envelope, opts = {}) {
|
|
|
272
273
|
...(envelope.scoringSemantics === undefined
|
|
273
274
|
? {}
|
|
274
275
|
: { scoringSemantics: envelope.scoringSemantics }),
|
|
276
|
+
...(envelope.tsTranspilerVersion === undefined
|
|
277
|
+
? {}
|
|
278
|
+
: { tsTranspilerVersion: envelope.tsTranspilerVersion }),
|
|
275
279
|
rollup: envelope.rollup,
|
|
276
280
|
rows: envelope.rows,
|
|
277
281
|
};
|
|
@@ -214,12 +214,17 @@ const ROUTING_SCHEMA = {
|
|
|
214
214
|
// the merge/CI watch poll loop; `autoMerge` selects the merge posture.
|
|
215
215
|
// Retired: `earlyPr` (Epic early-PR warmup) and `requireChecks` (no
|
|
216
216
|
// AutomergePredicate reader on v2).
|
|
217
|
+
// Story #4890 added `attachWindowMs`: how long the watch keeps re-resolving an
|
|
218
|
+
// EMPTY required-check set before it stops waiting for one. This block is
|
|
219
|
+
// `additionalProperties: false`, so the knob is inert unless it lands here AND
|
|
220
|
+
// in the `.agents/schemas/agentrc.schema.json` mirror.
|
|
217
221
|
const CI_WATCH_SCHEMA = {
|
|
218
222
|
type: 'object',
|
|
219
223
|
properties: {
|
|
220
224
|
pollIntervalMs: { type: 'integer', minimum: 1 },
|
|
221
225
|
maxPolls: { type: 'integer', minimum: 1 },
|
|
222
226
|
maxResumes: { type: 'integer', minimum: 0 },
|
|
227
|
+
attachWindowMs: { type: 'integer', minimum: 1 },
|
|
223
228
|
},
|
|
224
229
|
additionalProperties: false,
|
|
225
230
|
};
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import escomplex from 'typhonjs-escomplex';
|
|
2
2
|
import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The two line coordinate systems a CRAP row's `startLine` can be expressed
|
|
6
|
+
* in (Story #4866).
|
|
7
|
+
*
|
|
8
|
+
* `original` — the coordinates of the file a reader can open, and the ones
|
|
9
|
+
* istanbul's `fnMap` is keyed against. A JavaScript source is already in this
|
|
10
|
+
* system; a TS/TSX source reaches it only through a successful sourcemap
|
|
11
|
+
* lookup.
|
|
12
|
+
*
|
|
13
|
+
* `transpiled` — escomplex's own coordinates over the emitted JavaScript,
|
|
14
|
+
* kept only when the sourcemap has no entry originating on the method's
|
|
15
|
+
* generated line. Such a row is NOT an original-source coordinate and must
|
|
16
|
+
* never be presented as one: it cannot be joined to coverage, and it cannot
|
|
17
|
+
* be compared against a baseline row carrying the other provenance.
|
|
18
|
+
*/
|
|
19
|
+
export const COORDINATE_ORIGINAL = 'original';
|
|
20
|
+
export const COORDINATE_TRANSPILED = 'transpiled';
|
|
21
|
+
|
|
4
22
|
/**
|
|
5
23
|
* Derive the raw per-method CRAP rows from an escomplex report.
|
|
6
24
|
*
|
|
@@ -9,15 +27,20 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
|
9
27
|
* method's line is remapped or its coverage joined — the parity the
|
|
10
28
|
* combined-parity suite asserts.
|
|
11
29
|
*
|
|
12
|
-
* **Coordinates (Story #4775).** `mapLine`
|
|
13
|
-
* `lineStart` — which is in *transpiled* coordinates
|
|
14
|
-
* into the *original source* coordinates istanbul's
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
30
|
+
* **Coordinates (Story #4775, corrected by Story #4866).** `mapLine`
|
|
31
|
+
* translates escomplex's `lineStart` — which is in *transpiled* coordinates
|
|
32
|
+
* for a TS/TSX source — into the *original source* coordinates istanbul's
|
|
33
|
+
* `fnMap` uses. A `null` mapper means the two coordinate systems already
|
|
34
|
+
* coincide (plain JavaScript).
|
|
35
|
+
*
|
|
36
|
+
* #4775 let an unresolvable line fall back to the un-remapped value, which
|
|
37
|
+
* made one scan emit rows in two coordinate systems with nothing on the row
|
|
38
|
+
* saying which. Each row now records its own `coordinateSystem`, and an
|
|
39
|
+
* un-remapped row joins no coverage at all: joining a transpiled line against
|
|
40
|
+
* an original-source `fnMap` does not merely miss, it can land inside an
|
|
41
|
+
* unrelated function's range and mis-attribute that function's coverage. An
|
|
42
|
+
* honest `coverage: null` lets the scanner's `requireCoverage` policy decide,
|
|
43
|
+
* exactly as it does for a genuinely uninstrumented method.
|
|
21
44
|
*
|
|
22
45
|
* @param {object|null} report An `escomplex.analyzeModule` report.
|
|
23
46
|
* @param {object|null} coverageForFile Istanbul coverage entry for this file.
|
|
@@ -28,22 +51,55 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
|
28
51
|
* cyclomatic: number,
|
|
29
52
|
* coverage: number|null,
|
|
30
53
|
* crap: number|null,
|
|
54
|
+
* coordinateSystem: 'original'|'transpiled',
|
|
31
55
|
* }>}
|
|
32
56
|
*/
|
|
57
|
+
/**
|
|
58
|
+
* Resolve one method's line into a coordinate and the provenance of that
|
|
59
|
+
* coordinate — the single place the rule lives.
|
|
60
|
+
*
|
|
61
|
+
* No mapper means the source was never transpiled, so its line already IS an
|
|
62
|
+
* original-source coordinate. A mapper that resolves yields one. A mapper
|
|
63
|
+
* that does not leaves the transpiled line, said so.
|
|
64
|
+
*
|
|
65
|
+
* @param {number} rawStartLine escomplex's `lineStart`.
|
|
66
|
+
* @param {((line: number) => number|null)|null} mapLine
|
|
67
|
+
* @returns {{startLine: number, coordinateSystem: 'original'|'transpiled'}}
|
|
68
|
+
*/
|
|
69
|
+
function resolveCoordinate(rawStartLine, mapLine) {
|
|
70
|
+
if (typeof mapLine !== 'function') {
|
|
71
|
+
return { startLine: rawStartLine, coordinateSystem: COORDINATE_ORIGINAL };
|
|
72
|
+
}
|
|
73
|
+
const mapped = mapLine(rawStartLine);
|
|
74
|
+
return typeof mapped === 'number'
|
|
75
|
+
? { startLine: mapped, coordinateSystem: COORDINATE_ORIGINAL }
|
|
76
|
+
: { startLine: rawStartLine, coordinateSystem: COORDINATE_TRANSPILED };
|
|
77
|
+
}
|
|
78
|
+
|
|
33
79
|
export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
34
80
|
const methods = report?.methods ?? [];
|
|
35
81
|
const rows = [];
|
|
36
82
|
for (const m of methods) {
|
|
37
83
|
const rawStartLine = m?.lineStart;
|
|
38
84
|
if (typeof rawStartLine !== 'number') continue;
|
|
39
|
-
const
|
|
40
|
-
|
|
85
|
+
const { startLine, coordinateSystem } = resolveCoordinate(
|
|
86
|
+
rawStartLine,
|
|
87
|
+
mapLine,
|
|
88
|
+
);
|
|
41
89
|
const cyclomatic = m?.cyclomatic ?? 0;
|
|
42
|
-
const coverage =
|
|
43
|
-
|
|
44
|
-
|
|
90
|
+
const coverage =
|
|
91
|
+
coverageForFile && coordinateSystem === COORDINATE_ORIGINAL
|
|
92
|
+
? coverageForMethodInEntry(coverageForFile, startLine)
|
|
93
|
+
: null;
|
|
45
94
|
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
46
|
-
rows.push({
|
|
95
|
+
rows.push({
|
|
96
|
+
method: m.name,
|
|
97
|
+
startLine,
|
|
98
|
+
cyclomatic,
|
|
99
|
+
coverage,
|
|
100
|
+
crap,
|
|
101
|
+
coordinateSystem,
|
|
102
|
+
});
|
|
47
103
|
}
|
|
48
104
|
return rows;
|
|
49
105
|
}
|
|
@@ -63,13 +119,26 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
63
119
|
* dropped, which made it a no-op for baseline population — the caller
|
|
64
120
|
* asked for "score it anyway" and got silence.
|
|
65
121
|
*
|
|
122
|
+
* **Absent coverage is not zero coverage (Story #4871).** The 0%-covered
|
|
123
|
+
* inference above is only defensible when a coverage **artifact** exists and
|
|
124
|
+
* simply reports nothing for this method — "the tests ran and never reached
|
|
125
|
+
* it" is a measurement. When no artifact was produced at all — a freshly
|
|
126
|
+
* initialized story worktree, whose `coverage/` directory does not exist —
|
|
127
|
+
* nothing was measured, and filling the gap with 0% manufactures a maximal
|
|
128
|
+
* CRAP out of an absent observation, failing the first commit on files the
|
|
129
|
+
* change never touched. `coverageAvailable: false` therefore makes such a
|
|
130
|
+
* method **unscorable** under either policy: skipped and counted, never
|
|
131
|
+
* filled. It is deliberately keyed on the artifact, not the per-file entry,
|
|
132
|
+
* so `requireCoverage: false` keeps meaning "score untested code" whenever a
|
|
133
|
+
* real coverage run stands behind the verdict.
|
|
134
|
+
*
|
|
66
135
|
* `resolvedMethods` / `totalMethods` count the *join*, not the fill: a
|
|
67
136
|
* method scored 0% because its coverage was unresolved counts as
|
|
68
137
|
* unresolved. That is what makes them usable as a health signal for the
|
|
69
138
|
* updater's fail-closed resolution-rate floor.
|
|
70
139
|
*
|
|
71
140
|
* @param {Array<object>} rawRows Rows from `methodRowsFromReport`.
|
|
72
|
-
* @param {{requireCoverage?: boolean}} [opts]
|
|
141
|
+
* @param {{requireCoverage?: boolean, coverageAvailable?: boolean}} [opts]
|
|
73
142
|
* @returns {{
|
|
74
143
|
* rows: Array<object>,
|
|
75
144
|
* skippedMethodsNoCoverage: number,
|
|
@@ -77,7 +146,10 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
77
146
|
* totalMethods: number,
|
|
78
147
|
* }}
|
|
79
148
|
*/
|
|
80
|
-
export function finalizeMethodRows(
|
|
149
|
+
export function finalizeMethodRows(
|
|
150
|
+
rawRows,
|
|
151
|
+
{ requireCoverage = true, coverageAvailable = true } = {},
|
|
152
|
+
) {
|
|
81
153
|
const rows = [];
|
|
82
154
|
let skippedMethodsNoCoverage = 0;
|
|
83
155
|
let resolvedMethods = 0;
|
|
@@ -86,7 +158,7 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
86
158
|
totalMethods += 1;
|
|
87
159
|
const unresolved = mr.crap === null || mr.coverage === null;
|
|
88
160
|
if (!unresolved) resolvedMethods += 1;
|
|
89
|
-
if (unresolved && requireCoverage) {
|
|
161
|
+
if (unresolved && (requireCoverage || !coverageAvailable)) {
|
|
90
162
|
skippedMethodsNoCoverage += 1;
|
|
91
163
|
continue;
|
|
92
164
|
}
|
|
@@ -98,6 +170,10 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
98
170
|
cyclomatic: mr.cyclomatic,
|
|
99
171
|
coverage,
|
|
100
172
|
crap,
|
|
173
|
+
// Provenance survives the policy step (Story #4866): a row scored 0%
|
|
174
|
+
// under `requireCoverage: false` may still be carrying a transpiled
|
|
175
|
+
// coordinate, and the compare needs to know that before it keys on it.
|
|
176
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
101
177
|
});
|
|
102
178
|
}
|
|
103
179
|
return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
|
|
@@ -111,7 +187,8 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
111
187
|
* Kernel contract:
|
|
112
188
|
* - Pure (no I/O, no AST parse beyond the one delegated to escomplex via
|
|
113
189
|
* `analyzeModule`).
|
|
114
|
-
* - Methods whose coverage cannot be resolved from `coverageForFile`
|
|
190
|
+
* - Methods whose coverage cannot be resolved from `coverageForFile` —
|
|
191
|
+
* including every method whose line stayed in transpiled coordinates —
|
|
115
192
|
* produce `coverage: null` and `crap: null`. Callers apply their own
|
|
116
193
|
* `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
|
|
117
194
|
* this kernel never decides to skip.
|
|
@@ -130,6 +207,7 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
130
207
|
* cyclomatic: number,
|
|
131
208
|
* coverage: number|null,
|
|
132
209
|
* crap: number|null,
|
|
210
|
+
* coordinateSystem: 'original'|'transpiled',
|
|
133
211
|
* }>}
|
|
134
212
|
*/
|
|
135
213
|
export function calculateCrapForSource(
|
|
@@ -4,7 +4,11 @@ import escomplex from 'typhonjs-escomplex';
|
|
|
4
4
|
import { canonicalise as canonicalisePath } from './baselines/path-canon.js';
|
|
5
5
|
import { findCoverageEntry } from './coverage-utils.js';
|
|
6
6
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
COORDINATE_ORIGINAL,
|
|
9
|
+
finalizeMethodRows,
|
|
10
|
+
methodRowsFromReport,
|
|
11
|
+
} from './crap-engine.js';
|
|
8
12
|
import { Logger } from './Logger.js';
|
|
9
13
|
import { scanDirectory } from './maintainability-utils.js';
|
|
10
14
|
import {
|
|
@@ -97,11 +101,19 @@ function resolveBaselinePath({ cwd = process.cwd(), baselinePath } = {}) {
|
|
|
97
101
|
/**
|
|
98
102
|
* Story #1895: shipped baseline switched to the canonical envelope shape
|
|
99
103
|
* (`$schema`, `kernelVersion`, `generatedAt`, `rollup`, `rows` keyed on
|
|
100
|
-
* `path`). Backfill the legacy `escomplexVersion
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
104
|
+
* `path`). Backfill the legacy `escomplexVersion` field from the running
|
|
105
|
+
* scorer and re-key rows by `file` so existing comparators keep working
|
|
106
|
+
* until Story #1912 lands the unified gate. Detection probes the first row
|
|
107
|
+
* for the new `path` key — the legacy envelope also carries `$schema` but
|
|
108
|
+
* keys rows by `file`.
|
|
109
|
+
*
|
|
110
|
+
* **Compat stamps survive the projection (Story #4866).** `scoringSemantics`
|
|
111
|
+
* and `tsTranspilerVersion` are *baseline* facts, and the projection used to
|
|
112
|
+
* drop the first and overwrite the second with the RUNNING value — which made
|
|
113
|
+
* every compat axis reading them either unstamped or vacuously self-equal.
|
|
114
|
+
* They are carried verbatim now, `null` when the envelope never stamped them,
|
|
115
|
+
* so an axis can tell "written by a different transpiler" apart from "written
|
|
116
|
+
* before the stamp existed" instead of guessing.
|
|
105
117
|
*/
|
|
106
118
|
function projectCrapEnvelopeToLegacy(parsed) {
|
|
107
119
|
if (
|
|
@@ -114,12 +126,19 @@ function projectCrapEnvelopeToLegacy(parsed) {
|
|
|
114
126
|
return {
|
|
115
127
|
kernelVersion: parsed.kernelVersion,
|
|
116
128
|
escomplexVersion: resolveEscomplexVersion(),
|
|
117
|
-
tsTranspilerVersion:
|
|
129
|
+
tsTranspilerVersion:
|
|
130
|
+
typeof parsed.tsTranspilerVersion === 'string'
|
|
131
|
+
? parsed.tsTranspilerVersion
|
|
132
|
+
: null,
|
|
133
|
+
scoringSemantics: parsed.scoringSemantics ?? null,
|
|
118
134
|
rows: parsed.rows.map((row) => ({
|
|
119
135
|
crap: row.crap,
|
|
120
136
|
file: row.path,
|
|
121
137
|
method: row.method,
|
|
122
138
|
startLine: row.startLine,
|
|
139
|
+
...(row.coordinateSystem === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { coordinateSystem: row.coordinateSystem }),
|
|
123
142
|
})),
|
|
124
143
|
};
|
|
125
144
|
}
|
|
@@ -201,6 +220,25 @@ export function buildBaselineEnvelope({
|
|
|
201
220
|
};
|
|
202
221
|
}
|
|
203
222
|
|
|
223
|
+
/**
|
|
224
|
+
* True when a coverage artifact was actually loaded for this scan.
|
|
225
|
+
*
|
|
226
|
+
* Story #4871: "the tests ran and never reached this method" is a measurement
|
|
227
|
+
* and the CRAP formula's 0%-covered arm is the right answer for it. "No
|
|
228
|
+
* coverage run happened at all" — a freshly initialized story worktree with no
|
|
229
|
+
* `coverage/` directory — is an *absent* observation, and filling it with 0%
|
|
230
|
+
* drives every method to `c² + c`, failing the first commit on files the
|
|
231
|
+
* change never touched. Resolved once per scan and carried on each queue item
|
|
232
|
+
* so the pool workers, which only ever receive their own file's coverage
|
|
233
|
+
* entry, can still tell the two apart.
|
|
234
|
+
*
|
|
235
|
+
* @param {object|null|undefined} coverage Parsed `coverage-final.json` map.
|
|
236
|
+
* @returns {boolean}
|
|
237
|
+
*/
|
|
238
|
+
function isCoverageArtifactPresent(coverage) {
|
|
239
|
+
return coverage !== null && coverage !== undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
204
242
|
/**
|
|
205
243
|
* How many files to name when reporting the worst unresolved offenders. Long
|
|
206
244
|
* enough to point at a pattern, short enough to stay a readable CLI message.
|
|
@@ -421,12 +459,13 @@ export async function scanAndScore({
|
|
|
421
459
|
// inside `.worktrees/<workspace>/` (with cwd pointing at the main
|
|
422
460
|
// checkout) cannot leak the worktree prefix into the on-disk baseline's
|
|
423
461
|
// `file` / `path` keys downstream.
|
|
462
|
+
const coverageAvailable = isCoverageArtifactPresent(coverage);
|
|
424
463
|
const queue = [];
|
|
425
464
|
for (const abs of files) {
|
|
426
465
|
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
427
466
|
const relPath = canonicalisePath(rawRel);
|
|
428
467
|
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
429
|
-
queue.push({ abs, relPath, requireCoverage });
|
|
468
|
+
queue.push({ abs, relPath, requireCoverage, coverageAvailable });
|
|
430
469
|
}
|
|
431
470
|
const scannedFiles = queue.length;
|
|
432
471
|
|
|
@@ -466,6 +505,7 @@ export async function scanAndScore({
|
|
|
466
505
|
cyclomatic: mr.cyclomatic,
|
|
467
506
|
coverage: mr.coverage,
|
|
468
507
|
crap: mr.crap,
|
|
508
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
469
509
|
});
|
|
470
510
|
}
|
|
471
511
|
}
|
|
@@ -494,7 +534,10 @@ export async function scanAndScore({
|
|
|
494
534
|
* Uses `analyzeOnce` so the source is parsed a single time and both the
|
|
495
535
|
* CRAP rows and the MI score are derived from the same escomplex report.
|
|
496
536
|
*/
|
|
497
|
-
function scoreFileSerial(
|
|
537
|
+
function scoreFileSerial(
|
|
538
|
+
{ abs, relPath, requireCoverage, coverageAvailable = true },
|
|
539
|
+
coverage,
|
|
540
|
+
) {
|
|
498
541
|
const entry = findCoverageEntry(coverage, relPath);
|
|
499
542
|
if (requireCoverage && entry === null) {
|
|
500
543
|
return {
|
|
@@ -522,7 +565,10 @@ function scoreFileSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
522
565
|
prepared.mapLine,
|
|
523
566
|
);
|
|
524
567
|
if (parseError) return dropped;
|
|
525
|
-
const finalized = finalizeMethodRows(crapRows, {
|
|
568
|
+
const finalized = finalizeMethodRows(crapRows, {
|
|
569
|
+
requireCoverage,
|
|
570
|
+
coverageAvailable,
|
|
571
|
+
});
|
|
526
572
|
return {
|
|
527
573
|
skippedFileNoCoverage: false,
|
|
528
574
|
hasCoverageEntry: entry !== null,
|
|
@@ -568,7 +614,10 @@ async function scoreFilesViaPool(queue, coverage) {
|
|
|
568
614
|
* file), `[]` when coverage-skipped, otherwise the scored method rows.
|
|
569
615
|
* - `skippedFileNoCoverage` / `skippedMethodsNoCoverage` — CRAP counters.
|
|
570
616
|
*/
|
|
571
|
-
function scoreFileCombinedSerial(
|
|
617
|
+
function scoreFileCombinedSerial(
|
|
618
|
+
{ abs, relPath, requireCoverage, coverageAvailable = true },
|
|
619
|
+
coverage,
|
|
620
|
+
) {
|
|
572
621
|
const entry = findCoverageEntry(coverage, relPath);
|
|
573
622
|
const prepared = prepareSourceForScoring(abs);
|
|
574
623
|
if (prepared.error) {
|
|
@@ -613,7 +662,10 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
613
662
|
};
|
|
614
663
|
}
|
|
615
664
|
const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
|
|
616
|
-
finalizeMethodRows(rawCrapRows, {
|
|
665
|
+
finalizeMethodRows(rawCrapRows, {
|
|
666
|
+
requireCoverage,
|
|
667
|
+
coverageAvailable,
|
|
668
|
+
});
|
|
617
669
|
return {
|
|
618
670
|
relPath,
|
|
619
671
|
miScore,
|
|
@@ -730,12 +782,19 @@ export async function scanAndScoreCombined({
|
|
|
730
782
|
// Build the work queue. Each item carries both the canonicalised relPath
|
|
731
783
|
// (CRAP's key + scope filter, matching scanAndScore) and the raw relPath
|
|
732
784
|
// (MI's key, matching calculateAll's `path.relative(cwd, p)` shape).
|
|
785
|
+
const coverageAvailable = isCoverageArtifactPresent(coverage);
|
|
733
786
|
const queue = [];
|
|
734
787
|
for (const abs of files) {
|
|
735
788
|
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
736
789
|
const relPath = canonicalisePath(rawRel);
|
|
737
790
|
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
738
|
-
queue.push({
|
|
791
|
+
queue.push({
|
|
792
|
+
abs,
|
|
793
|
+
relPath,
|
|
794
|
+
miRel: rawRel,
|
|
795
|
+
requireCoverage,
|
|
796
|
+
coverageAvailable,
|
|
797
|
+
});
|
|
739
798
|
}
|
|
740
799
|
const scannedFiles = queue.length;
|
|
741
800
|
|
|
@@ -789,6 +848,7 @@ export async function scanAndScoreCombined({
|
|
|
789
848
|
cyclomatic: mr.cyclomatic,
|
|
790
849
|
coverage: mr.coverage,
|
|
791
850
|
crap: mr.crap,
|
|
851
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
792
852
|
});
|
|
793
853
|
}
|
|
794
854
|
}
|
|
@@ -2,6 +2,51 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Logger } from './Logger.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Parse the right-hand side of a `KEY=` line into its value.
|
|
7
|
+
*
|
|
8
|
+
* Two rules, and the quoting decides which one applies:
|
|
9
|
+
*
|
|
10
|
+
* - **Quoted** (`"…"` / `'…'`): the contents between the opening quote and
|
|
11
|
+
* its matching closing quote are kept **verbatim** — a `#` inside quotes is
|
|
12
|
+
* part of the value, not a comment. Anything after the closing quote is
|
|
13
|
+
* trailing commentary and is discarded.
|
|
14
|
+
* - **Unquoted**: the value ends at the first **unescaped** `#`. A `\#`
|
|
15
|
+
* escape emits a literal `#` and keeps the scan going, so a value that
|
|
16
|
+
* genuinely contains a hash can still be written without quotes.
|
|
17
|
+
*
|
|
18
|
+
* An opening quote with no closing partner is not a quoted value — it falls
|
|
19
|
+
* through to the unquoted rule so a malformed line still yields something
|
|
20
|
+
* rather than swallowing the rest of the file's intent.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} raw — the text after the `=`, unparsed.
|
|
23
|
+
* @returns {string} The value with any inline comment removed.
|
|
24
|
+
*/
|
|
25
|
+
function parseEnvValue(raw) {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (trimmed.length === 0) return '';
|
|
28
|
+
|
|
29
|
+
const quote = trimmed.charAt(0);
|
|
30
|
+
if (quote === '"' || quote === "'") {
|
|
31
|
+
const closing = trimmed.indexOf(quote, 1);
|
|
32
|
+
// Verbatim contents: no comment stripping, no unescaping.
|
|
33
|
+
if (closing !== -1) return trimmed.slice(1, closing);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let value = '';
|
|
37
|
+
for (let i = 0; i < trimmed.length; i += 1) {
|
|
38
|
+
const char = trimmed.charAt(i);
|
|
39
|
+
if (char === '\\' && trimmed.charAt(i + 1) === '#') {
|
|
40
|
+
value += '#';
|
|
41
|
+
i += 1;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === '#') break;
|
|
45
|
+
value += char;
|
|
46
|
+
}
|
|
47
|
+
return value.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
5
50
|
/**
|
|
6
51
|
* Auto-load .env from the project root if it exists
|
|
7
52
|
*/
|
|
@@ -31,22 +76,7 @@ export function loadEnv(projectRoot) {
|
|
|
31
76
|
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
|
|
32
77
|
if (match) {
|
|
33
78
|
const key = match[1];
|
|
34
|
-
|
|
35
|
-
// Remove quotes if present
|
|
36
|
-
if (
|
|
37
|
-
value.length > 0 &&
|
|
38
|
-
value.charAt(0) === '"' &&
|
|
39
|
-
value.charAt(value.length - 1) === '"'
|
|
40
|
-
) {
|
|
41
|
-
value = value.substring(1, value.length - 1);
|
|
42
|
-
} else if (
|
|
43
|
-
value.length > 0 &&
|
|
44
|
-
value.charAt(0) === "'" &&
|
|
45
|
-
value.charAt(value.length - 1) === "'"
|
|
46
|
-
) {
|
|
47
|
-
value = value.substring(1, value.length - 1);
|
|
48
|
-
}
|
|
49
|
-
process.env[key] = value;
|
|
79
|
+
process.env[key] = parseEnvValue(match[2] || '');
|
|
50
80
|
}
|
|
51
81
|
});
|
|
52
82
|
}
|