mandrel 2.16.0 → 2.18.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/docs/agentrc-reference.json +10 -0
- package/.agents/docs/configuration.md +9 -0
- package/.agents/docs/quality-gates.md +137 -0
- package/.agents/schemas/agentrc.schema.json +48 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +4 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +52 -12
- package/.agents/scripts/audit-to-stories.js +92 -25
- package/.agents/scripts/boot-sweep.js +67 -8
- package/.agents/scripts/check-baseline-drift.js +138 -0
- package/.agents/scripts/coverage-capture.js +74 -25
- package/.agents/scripts/deliver-recover.js +45 -18
- package/.agents/scripts/drain-pending-cleanup.js +67 -23
- package/.agents/scripts/generate-lens-checklists.js +81 -30
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
- package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
- package/.agents/scripts/lib/baselines/envelope.js +7 -0
- package/.agents/scripts/lib/baselines/kernel.js +31 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
- package/.agents/scripts/lib/baselines/reader.js +12 -1
- package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
- package/.agents/scripts/lib/baselines/writer.js +10 -0
- package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
- package/.agents/scripts/lib/cli-utils.js +48 -13
- package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
- package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
- package/.agents/scripts/lib/close-validation/runner.js +68 -0
- package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
- package/.agents/scripts/lib/config/quality.js +40 -0
- package/.agents/scripts/lib/config/temp-paths.js +27 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
- package/.agents/scripts/lib/coverage-utils.js +92 -9
- package/.agents/scripts/lib/crap-engine.js +113 -23
- package/.agents/scripts/lib/crap-utils.js +159 -93
- package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
- package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
- package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
- package/.agents/scripts/lib/observability/terse-result.js +7 -3
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
- package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
- package/.agents/scripts/lib/single-story-sweep.js +11 -0
- package/.agents/scripts/lib/stdio-flush.js +71 -0
- package/.agents/scripts/lib/temp-retention.js +559 -0
- package/.agents/scripts/lib/transpile.js +133 -6
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
- package/.agents/scripts/lib/workers/crap-worker.js +49 -76
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
- package/.agents/scripts/nav-registry-diff.js +30 -8
- package/.agents/scripts/plan-run-epilogue.js +27 -11
- package/.agents/scripts/resolve-doc-tiers.js +18 -8
- package/.agents/scripts/single-story-close.js +9 -92
- package/.agents/scripts/single-story-init.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +6 -1
- package/.agents/scripts/update-crap-baseline.js +13 -0
- package/README.md +14 -6
- package/docs/CHANGELOG.md +36 -0
- package/lib/cli/version-helpers.js +7 -0
- package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
- package/package.json +5 -1
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from './commands.js';
|
|
20
20
|
import { DEFAULT_GATES, partitionGates } from './gates.js';
|
|
21
21
|
import { defaultGateRunner } from './process.js';
|
|
22
|
+
import { runProjectionAdvisories as defaultRunProjections } from './projections/advisories.js';
|
|
22
23
|
import { defaultGetHeadSha } from './projections/head-sha.js';
|
|
23
24
|
|
|
24
25
|
/** @typedef {import('./gates.js').Gate} Gate */
|
|
@@ -90,10 +91,23 @@ function applyChangedFileScope({ gate, spawnCwd, log }) {
|
|
|
90
91
|
* story-close uses it to drive `phaseTimer.mark(...)` for per-gate
|
|
91
92
|
* wall-clock telemetry. Errors thrown from the hook propagate.
|
|
92
93
|
*
|
|
94
|
+
* Projection advisories (Story #4776): when `baseBranch` and `storyBranch`
|
|
95
|
+
* are both supplied and every gate passed, the maintainability and CRAP
|
|
96
|
+
* pre-merge projections run through `projections/advisories.js` and log
|
|
97
|
+
* their advisories to the same `log` sink the gates use. They are advisory
|
|
98
|
+
* by construction — the returned `ok` is decided entirely by the gates, so
|
|
99
|
+
* a projected breach never fails a close. They are skipped after a gate
|
|
100
|
+
* failure, where the operator needs the failing gate's evidence, not a
|
|
101
|
+
* baseline-refresh nudge.
|
|
102
|
+
*
|
|
93
103
|
* @param {{
|
|
94
104
|
* cwd: string,
|
|
95
105
|
* worktreePath?: string,
|
|
96
106
|
* gates?: Gate[],
|
|
107
|
+
* baseBranch?: string|null,
|
|
108
|
+
* storyBranch?: string|null,
|
|
109
|
+
* config?: object|null,
|
|
110
|
+
* runProjections?: typeof defaultRunProjections,
|
|
97
111
|
* runner?: (cmd: string, args: string[], opts: { cwd: string, signal?: AbortSignal, gateName?: string, log?: (m: string) => void }) => Promise<{ status: number }> | { status: number },
|
|
98
112
|
* log?: (m: string) => void,
|
|
99
113
|
* onGateStart?: (gate: Gate) => void,
|
|
@@ -114,6 +128,10 @@ export async function runCloseValidation({
|
|
|
114
128
|
runner = defaultGateRunner,
|
|
115
129
|
log = () => {},
|
|
116
130
|
onGateStart,
|
|
131
|
+
baseBranch = null,
|
|
132
|
+
storyBranch = null,
|
|
133
|
+
config = null,
|
|
134
|
+
runProjections = defaultRunProjections,
|
|
117
135
|
storyId = null,
|
|
118
136
|
standalone = false,
|
|
119
137
|
useEvidence = true,
|
|
@@ -346,5 +364,55 @@ export async function runCloseValidation({
|
|
|
346
364
|
);
|
|
347
365
|
}
|
|
348
366
|
|
|
367
|
+
// ── Phase 3: advisory projections ───────────────────────────────────
|
|
368
|
+
// Story #4776 — the projection layer's live call site. Deliberately
|
|
369
|
+
// outside the `ok` computation: a projected breach informs, it never
|
|
370
|
+
// fails a close.
|
|
371
|
+
if (failed.length === 0) {
|
|
372
|
+
await runAdvisoryProjections({
|
|
373
|
+
runProjections,
|
|
374
|
+
cwd: spawnCwd,
|
|
375
|
+
baseBranch,
|
|
376
|
+
storyBranch,
|
|
377
|
+
config,
|
|
378
|
+
log,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
349
382
|
return { ok: failed.length === 0, failed, skipped };
|
|
350
383
|
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Phase 3 helper — run the advisory projections, absorbing every failure.
|
|
387
|
+
*
|
|
388
|
+
* No-ops without a branch pair to diff (resume / legacy callers), and can
|
|
389
|
+
* never influence the close verdict: the caller has already decided `ok`
|
|
390
|
+
* before this runs, and a throw here is logged, not propagated.
|
|
391
|
+
*
|
|
392
|
+
* @param {{
|
|
393
|
+
* runProjections: typeof defaultRunProjections,
|
|
394
|
+
* cwd: string,
|
|
395
|
+
* baseBranch: string|null,
|
|
396
|
+
* storyBranch: string|null,
|
|
397
|
+
* config: object|null,
|
|
398
|
+
* log: (m: string) => void,
|
|
399
|
+
* }} opts
|
|
400
|
+
* @returns {Promise<void>}
|
|
401
|
+
*/
|
|
402
|
+
async function runAdvisoryProjections({
|
|
403
|
+
runProjections,
|
|
404
|
+
cwd,
|
|
405
|
+
baseBranch,
|
|
406
|
+
storyBranch,
|
|
407
|
+
config,
|
|
408
|
+
log,
|
|
409
|
+
}) {
|
|
410
|
+
if (!(baseBranch && storyBranch)) return;
|
|
411
|
+
try {
|
|
412
|
+
await runProjections({ cwd, baseBranch, storyBranch, config, log });
|
|
413
|
+
} catch (err) {
|
|
414
|
+
log(
|
|
415
|
+
`[close-validation] ⚠ projection advisories skipped: ${err?.message ?? err}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -13,6 +13,13 @@ export const CRAP_GATE = {
|
|
|
13
13
|
targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
|
|
14
14
|
newMethodCeiling: { type: 'integer', minimum: 1 },
|
|
15
15
|
requireCoverage: { type: 'boolean' },
|
|
16
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN: the
|
|
17
|
+
// fraction of methods that must resolve a coverage entry, counted only
|
|
18
|
+
// over files that HAVE one, before `update-crap-baseline.js` will
|
|
19
|
+
// persist. A broken join is silent by construction (unresolved methods
|
|
20
|
+
// are simply absent), so the updater refuses rather than writing a thin
|
|
21
|
+
// baseline and logging it as success. Default 0.75.
|
|
22
|
+
minMethodResolutionRate: { type: 'number', minimum: 0, maximum: 1 },
|
|
16
23
|
friction: {
|
|
17
24
|
type: 'object',
|
|
18
25
|
properties: { markerKey: { type: 'string', minLength: 1 } },
|
|
@@ -88,6 +88,15 @@ export const CRAP_GATE_DEFAULTS = Object.freeze({
|
|
|
88
88
|
// semantics.
|
|
89
89
|
refreshTimeoutMs: 60_000,
|
|
90
90
|
ignoreGlobs: Object.freeze([]),
|
|
91
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN. The
|
|
92
|
+
// fraction of methods that must resolve a coverage entry, counted only over
|
|
93
|
+
// files that HAVE one, before `update-crap-baseline.js` will persist. A
|
|
94
|
+
// broken join is silent by construction (unresolved methods are simply
|
|
95
|
+
// absent from the baseline), so the updater refuses rather than writing a
|
|
96
|
+
// thin baseline and logging it as success. 0.75 sits far above a healthy
|
|
97
|
+
// run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
|
|
98
|
+
// signature of a coordinate-system mismatch.
|
|
99
|
+
minMethodResolutionRate: 0.75,
|
|
91
100
|
});
|
|
92
101
|
|
|
93
102
|
/** Framework defaults for the coverage gate. */
|
|
@@ -121,6 +130,15 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
|
|
|
121
130
|
// spawned by the baseline-attribution refresh path. Defaults to 60 s.
|
|
122
131
|
refreshTimeoutMs: 60_000,
|
|
123
132
|
ignoreGlobs: Object.freeze([]),
|
|
133
|
+
// Story #4775 — fail-closed floor on the per-method coverage JOIN. The
|
|
134
|
+
// fraction of methods that must resolve a coverage entry, counted only over
|
|
135
|
+
// files that HAVE one, before `update-crap-baseline.js` will persist. A
|
|
136
|
+
// broken join is silent by construction (unresolved methods are simply
|
|
137
|
+
// absent from the baseline), so the updater refuses rather than writing a
|
|
138
|
+
// thin baseline and logging it as success. 0.75 sits far above a healthy
|
|
139
|
+
// run (a repo with fresh coverage resolves ~98%) and far below the 4–6%
|
|
140
|
+
// signature of a coordinate-system mismatch.
|
|
141
|
+
minMethodResolutionRate: 0.75,
|
|
124
142
|
});
|
|
125
143
|
|
|
126
144
|
/**
|
|
@@ -144,6 +162,7 @@ const CRAP_GATE_KEYS = new Set([
|
|
|
144
162
|
'refreshTag',
|
|
145
163
|
'refreshTimeoutMs',
|
|
146
164
|
'ignoreGlobs',
|
|
165
|
+
'minMethodResolutionRate',
|
|
147
166
|
]);
|
|
148
167
|
|
|
149
168
|
const COVERAGE_GATE_KEYS = new Set([
|
|
@@ -205,6 +224,22 @@ function warnUnknownKeys(userBlock, knownKeys, blockLabel) {
|
|
|
205
224
|
* @param {{ coveragePath: string }} coverageGate resolved coverage gate
|
|
206
225
|
* @returns {object} flattened legacy-bag view that existing callers read
|
|
207
226
|
*/
|
|
227
|
+
/**
|
|
228
|
+
* Clamp a user-supplied method-resolution floor into `[0, 1]`. A
|
|
229
|
+
* non-numeric, non-finite, or out-of-range value falls back to the framework
|
|
230
|
+
* default rather than silently disabling the guard (a floor of `NaN` would
|
|
231
|
+
* compare false against every rate and never fire).
|
|
232
|
+
*
|
|
233
|
+
* @param {unknown} value
|
|
234
|
+
* @param {number} fallback
|
|
235
|
+
* @returns {number}
|
|
236
|
+
*/
|
|
237
|
+
function resolveResolutionRate(value, fallback) {
|
|
238
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
|
239
|
+
if (value < 0 || value > 1) return fallback;
|
|
240
|
+
return value;
|
|
241
|
+
}
|
|
242
|
+
|
|
208
243
|
export function resolveMaintainabilityCrap(
|
|
209
244
|
userCrap,
|
|
210
245
|
gateScoping,
|
|
@@ -227,6 +262,7 @@ export function resolveMaintainabilityCrap(
|
|
|
227
262
|
DEFAULT_CRAP_TOLERANCE.value,
|
|
228
263
|
),
|
|
229
264
|
requireCoverage: defaults.requireCoverage,
|
|
265
|
+
minMethodResolutionRate: defaults.minMethodResolutionRate,
|
|
230
266
|
friction: { ...defaults.friction },
|
|
231
267
|
refreshTag: defaults.refreshTag,
|
|
232
268
|
refreshTimeoutMs: defaults.refreshTimeoutMs,
|
|
@@ -248,6 +284,10 @@ export function resolveMaintainabilityCrap(
|
|
|
248
284
|
toleranceScalar(defaults.tolerance, DEFAULT_CRAP_TOLERANCE.value),
|
|
249
285
|
),
|
|
250
286
|
requireCoverage: userCrap.requireCoverage ?? defaults.requireCoverage,
|
|
287
|
+
minMethodResolutionRate: resolveResolutionRate(
|
|
288
|
+
userCrap.minMethodResolutionRate,
|
|
289
|
+
defaults.minMethodResolutionRate,
|
|
290
|
+
),
|
|
251
291
|
friction: { ...defaults.friction, ...(userCrap.friction ?? {}) },
|
|
252
292
|
refreshTag: userCrap.refreshTag ?? defaults.refreshTag,
|
|
253
293
|
refreshTimeoutMs: resolvePositiveIntegerMs(
|
|
@@ -274,6 +274,33 @@ export function tempRootFrom(config) {
|
|
|
274
274
|
: 'temp';
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Directory segment (under `tempRoot`) holding every orchestration run log —
|
|
279
|
+
* the close gate transcripts (`close-gates-<sid>.log`) and the terse-result
|
|
280
|
+
* detail dumps (`story-init-result-<sid>.log`, `sync-result-<branch>.log`, …).
|
|
281
|
+
*
|
|
282
|
+
* Story #4794: the four writers that land here each hand-rolled the temp path
|
|
283
|
+
* from a literal `temp` segment joined onto their own cwd, which ignores
|
|
284
|
+
* `project.paths.tempRoot` entirely. On a consumer that relocates its temp
|
|
285
|
+
* root, the writers wrote to `<cwd>/temp/` while every reader — including the
|
|
286
|
+
* retention purge — resolved the configured root, so the artifacts were
|
|
287
|
+
* invisible to the tooling meant to manage them. Routing all four through this
|
|
288
|
+
* helper also picks up main-checkout anchoring for free, so a close running
|
|
289
|
+
* from a Story worktree lands its logs in the same tree the host reads.
|
|
290
|
+
*/
|
|
291
|
+
export const ORCHESTRATION_DIRNAME = 'orchestration';
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* `<tempRoot>/orchestration/` — resolved against the configured temp root and
|
|
295
|
+
* anchored to the main checkout, like every other helper in this module.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} [config]
|
|
298
|
+
* @returns {string}
|
|
299
|
+
*/
|
|
300
|
+
export function orchestrationLogDir(config) {
|
|
301
|
+
return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
|
|
302
|
+
}
|
|
303
|
+
|
|
277
304
|
const runId = (id) => {
|
|
278
305
|
if (!Number.isInteger(id) || id <= 0) {
|
|
279
306
|
throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
|
|
@@ -352,12 +352,81 @@ const AUDIT_TO_STORIES_SCHEMA = {
|
|
|
352
352
|
additionalProperties: false,
|
|
353
353
|
};
|
|
354
354
|
|
|
355
|
+
/**
|
|
356
|
+
* `delivery.tempRetention` — auto-purge of spent temp artifacts (Story #4794).
|
|
357
|
+
*
|
|
358
|
+
* `enabled` defaults to `true`: reclaiming a landed Story's gate transcripts
|
|
359
|
+
* and evidence is the behaviour, and the knob exists to turn it off. `classes`
|
|
360
|
+
* lets an operator keep one family while purging the rest; `staleDays` is the
|
|
361
|
+
* age floor for the families no Story id can be recovered from (audit reports,
|
|
362
|
+
* abandoned `plan-<slug>/` dirs).
|
|
363
|
+
*/
|
|
364
|
+
const TEMP_RETENTION_SCHEMA = {
|
|
365
|
+
type: 'object',
|
|
366
|
+
description:
|
|
367
|
+
'Story #4794. Auto-purge of spent temp artifacts once their Story lands. ' +
|
|
368
|
+
'Classification is an allowlist: only the declared classes below are ever ' +
|
|
369
|
+
'deleted, so operator scratch files under tempRoot are reported with their ' +
|
|
370
|
+
'size and left alone. signals.ndjson is never purged by any path.',
|
|
371
|
+
properties: {
|
|
372
|
+
enabled: {
|
|
373
|
+
type: 'boolean',
|
|
374
|
+
description:
|
|
375
|
+
"Master switch. Default true — reclaiming a landed Story's gate " +
|
|
376
|
+
'transcripts and validation evidence is the behaviour, and this knob ' +
|
|
377
|
+
'turns it off. When false every purge path is a reported no-op.',
|
|
378
|
+
},
|
|
379
|
+
staleDays: {
|
|
380
|
+
type: 'integer',
|
|
381
|
+
minimum: 1,
|
|
382
|
+
description:
|
|
383
|
+
'Age floor (days, default 7) for the families no Story id can be ' +
|
|
384
|
+
'recovered from — roster-level audit reports and abandoned ' +
|
|
385
|
+
'plan-<slug>/ dirs. Story-keyed artifacts do not wait for it: they are ' +
|
|
386
|
+
'purged as soon as their merge is confirmed.',
|
|
387
|
+
},
|
|
388
|
+
classes: {
|
|
389
|
+
type: 'object',
|
|
390
|
+
description:
|
|
391
|
+
'Per-class opt-out. Each defaults to true; set one false to keep that ' +
|
|
392
|
+
'family while the rest are purged.',
|
|
393
|
+
properties: {
|
|
394
|
+
orchestrationLogs: {
|
|
395
|
+
type: 'boolean',
|
|
396
|
+
description:
|
|
397
|
+
'<tempRoot>/orchestration/*.log — close gate transcripts and ' +
|
|
398
|
+
'terse-result detail dumps.',
|
|
399
|
+
},
|
|
400
|
+
validationEvidence: {
|
|
401
|
+
type: 'boolean',
|
|
402
|
+
description:
|
|
403
|
+
'Per-Story validation-evidence.json, lifecycle.ndjson, and ' +
|
|
404
|
+
'manifest.md under the standalone and per-run story trees.',
|
|
405
|
+
},
|
|
406
|
+
auditResults: {
|
|
407
|
+
type: 'boolean',
|
|
408
|
+
description: '<tempRoot>/audits/ — audit lens reports.',
|
|
409
|
+
},
|
|
410
|
+
planDirs: {
|
|
411
|
+
type: 'boolean',
|
|
412
|
+
description:
|
|
413
|
+
'<tempRoot>/plan-<slug>/ — abandoned plan authoring dirs. ' +
|
|
414
|
+
'Age-floored only; the current run is always excluded.',
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
additionalProperties: false,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
additionalProperties: false,
|
|
421
|
+
};
|
|
422
|
+
|
|
355
423
|
export const DELIVERY_SCHEMA = {
|
|
356
424
|
type: 'object',
|
|
357
425
|
properties: {
|
|
358
426
|
execution: EXECUTION_SCHEMA,
|
|
359
427
|
lease: LEASE_SCHEMA,
|
|
360
428
|
docsFreshness: DOCS_FRESHNESS_SCHEMA,
|
|
429
|
+
tempRetention: TEMP_RETENTION_SCHEMA,
|
|
361
430
|
deliverRunner: DELIVER_RUNNER_SCHEMA,
|
|
362
431
|
worktreeIsolation: WORKTREE_ISOLATION_SCHEMA,
|
|
363
432
|
signals: SIGNALS_SCHEMA,
|
|
@@ -121,6 +121,8 @@ export function hasCoverageFor(map, relPath) {
|
|
|
121
121
|
* raw `fnMap` entry — so callers may key by the escomplex `lineStart`
|
|
122
122
|
* (which can match either, depending on producer).
|
|
123
123
|
* - `fnLocByStartLine`: same keying, value is `{fnStart, fnEnd}` derived once.
|
|
124
|
+
* - `fnRanges`: every function's `{fnStart, fnEnd, declLine}` triple, used by
|
|
125
|
+
* the containment / nearest-decl fallbacks when exact-line keying misses.
|
|
124
126
|
* - `statementsByLine`: `Map<line, {total, covered}>` so range scans don't
|
|
125
127
|
* re-walk the full statement map.
|
|
126
128
|
*
|
|
@@ -129,9 +131,10 @@ export function hasCoverageFor(map, relPath) {
|
|
|
129
131
|
export function buildEntryIndex(entry) {
|
|
130
132
|
const fnByStartLine = new Map();
|
|
131
133
|
const fnLocByStartLine = new Map();
|
|
134
|
+
const fnRanges = [];
|
|
132
135
|
const statementsByLine = new Map();
|
|
133
136
|
if (!entry || typeof entry !== 'object') {
|
|
134
|
-
return { fnByStartLine, fnLocByStartLine, statementsByLine };
|
|
137
|
+
return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
|
|
135
138
|
}
|
|
136
139
|
const fnMap = entry.fnMap ?? {};
|
|
137
140
|
const statementMap = entry.statementMap ?? {};
|
|
@@ -152,6 +155,13 @@ export function buildEntryIndex(entry) {
|
|
|
152
155
|
fnByStartLine.set(locLine, f);
|
|
153
156
|
fnLocByStartLine.set(locLine, loc);
|
|
154
157
|
}
|
|
158
|
+
if (fnStart !== null && fnEnd !== null) {
|
|
159
|
+
fnRanges.push({
|
|
160
|
+
fnStart,
|
|
161
|
+
fnEnd,
|
|
162
|
+
declLine: typeof declLine === 'number' ? declLine : fnStart,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
155
165
|
}
|
|
156
166
|
|
|
157
167
|
for (const stmtId of Object.keys(statementMap)) {
|
|
@@ -167,7 +177,76 @@ export function buildEntryIndex(entry) {
|
|
|
167
177
|
if ((statementHits[stmtId] ?? 0) > 0) bucket.covered += 1;
|
|
168
178
|
}
|
|
169
179
|
|
|
170
|
-
return { fnByStartLine, fnLocByStartLine, statementsByLine };
|
|
180
|
+
return { fnByStartLine, fnLocByStartLine, fnRanges, statementsByLine };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* How far from a `fnMap` declaration line a method start may sit and still
|
|
185
|
+
* be considered the same function.
|
|
186
|
+
*
|
|
187
|
+
* Even after remapping to original-source coordinates (Story #4775), a
|
|
188
|
+
* method's start and istanbul's `decl.start.line` do not always agree on the
|
|
189
|
+
* token: escomplex anchors on the function node, istanbul on the declaration
|
|
190
|
+
* it instruments, and a decorator, a leading `export`, or a multi-line
|
|
191
|
+
* parameter list puts them one line apart. One line of slack absorbs that
|
|
192
|
+
* without letting an unrelated neighbouring function be claimed.
|
|
193
|
+
*/
|
|
194
|
+
const DECL_MATCH_WINDOW = 1;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve the `{fnStart, fnEnd}` range of the function a method start line
|
|
198
|
+
* belongs to, in the coordinate system of the coverage entry.
|
|
199
|
+
*
|
|
200
|
+
* Three strategies, most precise first:
|
|
201
|
+
*
|
|
202
|
+
* 1. **Exact** — the line keys a `fnMap` `decl.start.line` or
|
|
203
|
+
* `loc.start.line`. This is the pre-#4775 behaviour and still wins, so
|
|
204
|
+
* every already-resolving row keeps its exact prior value.
|
|
205
|
+
* 2. **Containment** — the innermost function whose `loc` range contains
|
|
206
|
+
* the line. Smallest span wins, so a nested callback is preferred over
|
|
207
|
+
* the enclosing function that also contains the line.
|
|
208
|
+
* 3. **Nearest declaration** — the closest `decl` line within
|
|
209
|
+
* `DECL_MATCH_WINDOW`, which absorbs the ±1 token disagreement between
|
|
210
|
+
* escomplex's method start and istanbul's declaration line.
|
|
211
|
+
*
|
|
212
|
+
* Returns `null` when none of the three finds a function — the caller
|
|
213
|
+
* surfaces that as "no data" rather than "tested zero times."
|
|
214
|
+
*
|
|
215
|
+
* @param {{fnByStartLine: Map, fnLocByStartLine: Map, fnRanges: Array}} idx
|
|
216
|
+
* @param {number} startLine
|
|
217
|
+
* @returns {{fnStart: number, fnEnd: number}|null}
|
|
218
|
+
*/
|
|
219
|
+
function resolveFnRangeForLine(idx, startLine) {
|
|
220
|
+
if (typeof startLine !== 'number') return null;
|
|
221
|
+
if (idx.fnByStartLine.has(startLine)) {
|
|
222
|
+
const loc = idx.fnLocByStartLine.get(startLine);
|
|
223
|
+
if (loc && loc.fnStart !== null && loc.fnEnd !== null) return loc;
|
|
224
|
+
}
|
|
225
|
+
const ranges = idx.fnRanges ?? [];
|
|
226
|
+
let innermost = null;
|
|
227
|
+
let innermostSpan = Number.POSITIVE_INFINITY;
|
|
228
|
+
for (const range of ranges) {
|
|
229
|
+
if (startLine < range.fnStart || startLine > range.fnEnd) continue;
|
|
230
|
+
const span = range.fnEnd - range.fnStart;
|
|
231
|
+
if (span < innermostSpan) {
|
|
232
|
+
innermostSpan = span;
|
|
233
|
+
innermost = range;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (innermost) return { fnStart: innermost.fnStart, fnEnd: innermost.fnEnd };
|
|
237
|
+
|
|
238
|
+
let nearest = null;
|
|
239
|
+
let nearestDist = Number.POSITIVE_INFINITY;
|
|
240
|
+
for (const range of ranges) {
|
|
241
|
+
const dist = Math.abs(range.declLine - startLine);
|
|
242
|
+
if (dist > DECL_MATCH_WINDOW) continue;
|
|
243
|
+
if (dist < nearestDist) {
|
|
244
|
+
nearestDist = dist;
|
|
245
|
+
nearest = range;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (nearest) return { fnStart: nearest.fnStart, fnEnd: nearest.fnEnd };
|
|
249
|
+
return null;
|
|
171
250
|
}
|
|
172
251
|
|
|
173
252
|
function getEntryIndex(entry) {
|
|
@@ -193,22 +272,26 @@ function getEntryIndex(entry) {
|
|
|
193
272
|
* returns 0. A missing / malformed entry or no matching function returns
|
|
194
273
|
* `null` so the caller can distinguish "no data" from "tested zero times."
|
|
195
274
|
*
|
|
275
|
+
* `startLine` MUST be in the coverage entry's own (original-source)
|
|
276
|
+
* coordinate system. Callers scoring transpiled TypeScript remap escomplex's
|
|
277
|
+
* transpiled `lineStart` first — see `transpileIfNeeded`'s `withLineMap`
|
|
278
|
+
* option (Story #4775). Matching is exact-then-containment-then-nearest-decl;
|
|
279
|
+
* see `resolveFnRangeForLine`.
|
|
280
|
+
*
|
|
196
281
|
* The first call on a given entry builds and caches a per-entry index via a
|
|
197
282
|
* non-enumerable Symbol property; consecutive method lookups in the same
|
|
198
283
|
* file pay the build cost exactly once.
|
|
199
284
|
*
|
|
200
285
|
* @param {object|null} entry One inner value from a `coverage-final.json` map.
|
|
201
|
-
* @param {number} startLine The
|
|
286
|
+
* @param {number} startLine The method's start line, in entry coordinates.
|
|
202
287
|
* @returns {number|null}
|
|
203
288
|
*/
|
|
204
289
|
export function coverageForMethodInEntry(entry, startLine) {
|
|
205
290
|
if (!entry || typeof entry !== 'object') return null;
|
|
206
291
|
const idx = getEntryIndex(entry);
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const { fnStart, fnEnd } = loc;
|
|
211
|
-
if (fnStart === null || fnEnd === null) return null;
|
|
292
|
+
const range = resolveFnRangeForLine(idx, startLine);
|
|
293
|
+
if (!range) return null;
|
|
294
|
+
const { fnStart, fnEnd } = range;
|
|
212
295
|
|
|
213
296
|
let total = 0;
|
|
214
297
|
let covered = 0;
|
|
@@ -228,7 +311,7 @@ export function coverageForMethodInEntry(entry, startLine) {
|
|
|
228
311
|
*
|
|
229
312
|
* @param {object|null} map Parsed `coverage-final.json`.
|
|
230
313
|
* @param {string} relPath Repo-relative path of the source file.
|
|
231
|
-
* @param {number} startLine The
|
|
314
|
+
* @param {number} startLine The method's start line, in entry coordinates.
|
|
232
315
|
* @returns {number|null} Coverage in [0, 1], or null when the file or method
|
|
233
316
|
* is absent.
|
|
234
317
|
*/
|
|
@@ -1,6 +1,108 @@
|
|
|
1
1
|
import escomplex from 'typhonjs-escomplex';
|
|
2
2
|
import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Derive the raw per-method CRAP rows from an escomplex report.
|
|
6
|
+
*
|
|
7
|
+
* Single-sourced between `calculateCrapForSource` (CRAP-only path) and
|
|
8
|
+
* `analyzeOnce` (combined MI + CRAP path) so the two cannot drift on how a
|
|
9
|
+
* method's line is remapped or its coverage joined — the parity the
|
|
10
|
+
* combined-parity suite asserts.
|
|
11
|
+
*
|
|
12
|
+
* **Coordinates (Story #4775).** `mapLine` translates escomplex's
|
|
13
|
+
* `lineStart` — which is in *transpiled* coordinates for a TS/TSX source —
|
|
14
|
+
* into the *original source* coordinates istanbul's `fnMap` uses. Without it
|
|
15
|
+
* the join compares two different coordinate systems and either misses or,
|
|
16
|
+
* worse, collides with an unrelated function. A `null` mapper means the two
|
|
17
|
+
* coordinate systems already coincide (plain JavaScript), and a line the map
|
|
18
|
+
* cannot resolve falls back to the un-remapped value rather than dropping the
|
|
19
|
+
* method outright. The remapped line is also what the row reports, so a
|
|
20
|
+
* persisted row points at a line the reader can actually open.
|
|
21
|
+
*
|
|
22
|
+
* @param {object|null} report An `escomplex.analyzeModule` report.
|
|
23
|
+
* @param {object|null} coverageForFile Istanbul coverage entry for this file.
|
|
24
|
+
* @param {((line: number) => number|null)|null} [mapLine]
|
|
25
|
+
* @returns {Array<{
|
|
26
|
+
* method: string,
|
|
27
|
+
* startLine: number,
|
|
28
|
+
* cyclomatic: number,
|
|
29
|
+
* coverage: number|null,
|
|
30
|
+
* crap: number|null,
|
|
31
|
+
* }>}
|
|
32
|
+
*/
|
|
33
|
+
export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
34
|
+
const methods = report?.methods ?? [];
|
|
35
|
+
const rows = [];
|
|
36
|
+
for (const m of methods) {
|
|
37
|
+
const rawStartLine = m?.lineStart;
|
|
38
|
+
if (typeof rawStartLine !== 'number') continue;
|
|
39
|
+
const mapped = typeof mapLine === 'function' ? mapLine(rawStartLine) : null;
|
|
40
|
+
const startLine = typeof mapped === 'number' ? mapped : rawStartLine;
|
|
41
|
+
const cyclomatic = m?.cyclomatic ?? 0;
|
|
42
|
+
const coverage = coverageForFile
|
|
43
|
+
? coverageForMethodInEntry(coverageForFile, startLine)
|
|
44
|
+
: null;
|
|
45
|
+
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
46
|
+
rows.push({ method: m.name, startLine, cyclomatic, coverage, crap });
|
|
47
|
+
}
|
|
48
|
+
return rows;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply the scanner's `requireCoverage` policy to raw method rows and report
|
|
53
|
+
* how much of the coverage join actually landed.
|
|
54
|
+
*
|
|
55
|
+
* Two policies, one honest each way (Story #4775, fix part 3):
|
|
56
|
+
*
|
|
57
|
+
* - `requireCoverage: true` — an unresolved method is skipped and counted,
|
|
58
|
+
* exactly as before. The baseline stays a record of measured code.
|
|
59
|
+
* - `requireCoverage: false` — an unresolved method scores as **0%
|
|
60
|
+
* covered** (`crap = c² + c`, the formula's own treatment of untested
|
|
61
|
+
* code) and lands in the baseline. Previously the flag only stopped
|
|
62
|
+
* whole *files* being skipped while each individual method was still
|
|
63
|
+
* dropped, which made it a no-op for baseline population — the caller
|
|
64
|
+
* asked for "score it anyway" and got silence.
|
|
65
|
+
*
|
|
66
|
+
* `resolvedMethods` / `totalMethods` count the *join*, not the fill: a
|
|
67
|
+
* method scored 0% because its coverage was unresolved counts as
|
|
68
|
+
* unresolved. That is what makes them usable as a health signal for the
|
|
69
|
+
* updater's fail-closed resolution-rate floor.
|
|
70
|
+
*
|
|
71
|
+
* @param {Array<object>} rawRows Rows from `methodRowsFromReport`.
|
|
72
|
+
* @param {{requireCoverage?: boolean}} [opts]
|
|
73
|
+
* @returns {{
|
|
74
|
+
* rows: Array<object>,
|
|
75
|
+
* skippedMethodsNoCoverage: number,
|
|
76
|
+
* resolvedMethods: number,
|
|
77
|
+
* totalMethods: number,
|
|
78
|
+
* }}
|
|
79
|
+
*/
|
|
80
|
+
export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
81
|
+
const rows = [];
|
|
82
|
+
let skippedMethodsNoCoverage = 0;
|
|
83
|
+
let resolvedMethods = 0;
|
|
84
|
+
let totalMethods = 0;
|
|
85
|
+
for (const mr of rawRows ?? []) {
|
|
86
|
+
totalMethods += 1;
|
|
87
|
+
const unresolved = mr.crap === null || mr.coverage === null;
|
|
88
|
+
if (!unresolved) resolvedMethods += 1;
|
|
89
|
+
if (unresolved && requireCoverage) {
|
|
90
|
+
skippedMethodsNoCoverage += 1;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const coverage = unresolved ? 0 : mr.coverage;
|
|
94
|
+
const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
|
|
95
|
+
rows.push({
|
|
96
|
+
method: mr.method,
|
|
97
|
+
startLine: mr.startLine,
|
|
98
|
+
cyclomatic: mr.cyclomatic,
|
|
99
|
+
coverage,
|
|
100
|
+
crap,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
|
|
104
|
+
}
|
|
105
|
+
|
|
4
106
|
/**
|
|
5
107
|
* Score each method in a JavaScript source for Change Risk Anti-Patterns
|
|
6
108
|
* (CRAP): `c² · (1 − cov)³ + c`, where `c` is cyclomatic complexity and `cov`
|
|
@@ -11,15 +113,17 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
|
11
113
|
* `analyzeModule`).
|
|
12
114
|
* - Methods whose coverage cannot be resolved from `coverageForFile`
|
|
13
115
|
* produce `coverage: null` and `crap: null`. Callers apply their own
|
|
14
|
-
* `requireCoverage` policy at the scanner level;
|
|
15
|
-
* decides to skip.
|
|
116
|
+
* `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
|
|
117
|
+
* this kernel never decides to skip.
|
|
16
118
|
* - A parse error returns an empty array — the file is unscorable, not
|
|
17
119
|
* zero-complexity.
|
|
18
120
|
*
|
|
19
|
-
* @param {string} source JavaScript source text.
|
|
121
|
+
* @param {string} source JavaScript source text (possibly transpiled).
|
|
20
122
|
* @param {object|null} coverageForFile The inner value from a
|
|
21
123
|
* `coverage-final.json` map keyed by this file's path, or null when no
|
|
22
124
|
* coverage data is available for this file.
|
|
125
|
+
* @param {((line: number) => number|null)|null} [mapLine] Transpiled →
|
|
126
|
+
* original line resolver; see `methodRowsFromReport`.
|
|
23
127
|
* @returns {Array<{
|
|
24
128
|
* method: string,
|
|
25
129
|
* startLine: number,
|
|
@@ -28,32 +132,18 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
|
28
132
|
* crap: number|null,
|
|
29
133
|
* }>}
|
|
30
134
|
*/
|
|
31
|
-
export function calculateCrapForSource(
|
|
135
|
+
export function calculateCrapForSource(
|
|
136
|
+
source,
|
|
137
|
+
coverageForFile,
|
|
138
|
+
mapLine = null,
|
|
139
|
+
) {
|
|
32
140
|
let report;
|
|
33
141
|
try {
|
|
34
142
|
report = escomplex.analyzeModule(source);
|
|
35
143
|
} catch {
|
|
36
144
|
return [];
|
|
37
145
|
}
|
|
38
|
-
|
|
39
|
-
const rows = [];
|
|
40
|
-
for (const m of methods) {
|
|
41
|
-
const startLine = m?.lineStart;
|
|
42
|
-
if (typeof startLine !== 'number') continue;
|
|
43
|
-
const cyclomatic = m?.cyclomatic ?? 0;
|
|
44
|
-
const coverage = coverageForFile
|
|
45
|
-
? coverageForMethodInEntry(coverageForFile, startLine)
|
|
46
|
-
: null;
|
|
47
|
-
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
48
|
-
rows.push({
|
|
49
|
-
method: m.name,
|
|
50
|
-
startLine,
|
|
51
|
-
cyclomatic,
|
|
52
|
-
coverage,
|
|
53
|
-
crap,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
return rows;
|
|
146
|
+
return methodRowsFromReport(report, coverageForFile, mapLine);
|
|
57
147
|
}
|
|
58
148
|
|
|
59
149
|
/**
|