mandrel 2.24.0 → 2.26.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 +19 -21
- 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/baselines.md +21 -0
- 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/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -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/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -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/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- 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/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- 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/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- 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-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -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 +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -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,25 @@ 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 }),
|
|
142
|
+
// Story #4969, same reason as the stamps above: `anonymous` is a
|
|
143
|
+
// BASELINE fact. Dropping it here left every re-keyed row looking like
|
|
144
|
+
// an unmarked anonymous one, which is precisely the shape the
|
|
145
|
+
// `anon-identity-unstamped` axis fails closed — the projection would
|
|
146
|
+
// have manufactured the very defect the axis exists to catch.
|
|
147
|
+
...(row.anonymous === undefined ? {} : { anonymous: row.anonymous }),
|
|
123
148
|
})),
|
|
124
149
|
};
|
|
125
150
|
}
|
|
@@ -196,11 +221,31 @@ export function buildBaselineEnvelope({
|
|
|
196
221
|
file: r.file,
|
|
197
222
|
method: r.method,
|
|
198
223
|
startLine: r.startLine,
|
|
224
|
+
...(r.anonymous === undefined ? {} : { anonymous: r.anonymous }),
|
|
199
225
|
})),
|
|
200
226
|
tsTranspilerVersion,
|
|
201
227
|
};
|
|
202
228
|
}
|
|
203
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when a coverage artifact was actually loaded for this scan.
|
|
232
|
+
*
|
|
233
|
+
* Story #4871: "the tests ran and never reached this method" is a measurement
|
|
234
|
+
* and the CRAP formula's 0%-covered arm is the right answer for it. "No
|
|
235
|
+
* coverage run happened at all" — a freshly initialized story worktree with no
|
|
236
|
+
* `coverage/` directory — is an *absent* observation, and filling it with 0%
|
|
237
|
+
* drives every method to `c² + c`, failing the first commit on files the
|
|
238
|
+
* change never touched. Resolved once per scan and carried on each queue item
|
|
239
|
+
* so the pool workers, which only ever receive their own file's coverage
|
|
240
|
+
* entry, can still tell the two apart.
|
|
241
|
+
*
|
|
242
|
+
* @param {object|null|undefined} coverage Parsed `coverage-final.json` map.
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function isCoverageArtifactPresent(coverage) {
|
|
246
|
+
return coverage !== null && coverage !== undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
204
249
|
/**
|
|
205
250
|
* How many files to name when reporting the worst unresolved offenders. Long
|
|
206
251
|
* enough to point at a pattern, short enough to stay a readable CLI message.
|
|
@@ -421,12 +466,13 @@ export async function scanAndScore({
|
|
|
421
466
|
// inside `.worktrees/<workspace>/` (with cwd pointing at the main
|
|
422
467
|
// checkout) cannot leak the worktree prefix into the on-disk baseline's
|
|
423
468
|
// `file` / `path` keys downstream.
|
|
469
|
+
const coverageAvailable = isCoverageArtifactPresent(coverage);
|
|
424
470
|
const queue = [];
|
|
425
471
|
for (const abs of files) {
|
|
426
472
|
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
427
473
|
const relPath = canonicalisePath(rawRel);
|
|
428
474
|
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
429
|
-
queue.push({ abs, relPath, requireCoverage });
|
|
475
|
+
queue.push({ abs, relPath, requireCoverage, coverageAvailable });
|
|
430
476
|
}
|
|
431
477
|
const scannedFiles = queue.length;
|
|
432
478
|
|
|
@@ -462,10 +508,14 @@ export async function scanAndScore({
|
|
|
462
508
|
rows.push({
|
|
463
509
|
file: item.relPath,
|
|
464
510
|
method: mr.method,
|
|
511
|
+
// Story #4969: `method` may be a derived anonymous identity; the flag
|
|
512
|
+
// is what lets the persisted row say so.
|
|
513
|
+
anonymous: mr.anonymous === true,
|
|
465
514
|
startLine: mr.startLine,
|
|
466
515
|
cyclomatic: mr.cyclomatic,
|
|
467
516
|
coverage: mr.coverage,
|
|
468
517
|
crap: mr.crap,
|
|
518
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
469
519
|
});
|
|
470
520
|
}
|
|
471
521
|
}
|
|
@@ -494,7 +544,10 @@ export async function scanAndScore({
|
|
|
494
544
|
* Uses `analyzeOnce` so the source is parsed a single time and both the
|
|
495
545
|
* CRAP rows and the MI score are derived from the same escomplex report.
|
|
496
546
|
*/
|
|
497
|
-
function scoreFileSerial(
|
|
547
|
+
function scoreFileSerial(
|
|
548
|
+
{ abs, relPath, requireCoverage, coverageAvailable = true },
|
|
549
|
+
coverage,
|
|
550
|
+
) {
|
|
498
551
|
const entry = findCoverageEntry(coverage, relPath);
|
|
499
552
|
if (requireCoverage && entry === null) {
|
|
500
553
|
return {
|
|
@@ -522,7 +575,10 @@ function scoreFileSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
522
575
|
prepared.mapLine,
|
|
523
576
|
);
|
|
524
577
|
if (parseError) return dropped;
|
|
525
|
-
const finalized = finalizeMethodRows(crapRows, {
|
|
578
|
+
const finalized = finalizeMethodRows(crapRows, {
|
|
579
|
+
requireCoverage,
|
|
580
|
+
coverageAvailable,
|
|
581
|
+
});
|
|
526
582
|
return {
|
|
527
583
|
skippedFileNoCoverage: false,
|
|
528
584
|
hasCoverageEntry: entry !== null,
|
|
@@ -568,7 +624,10 @@ async function scoreFilesViaPool(queue, coverage) {
|
|
|
568
624
|
* file), `[]` when coverage-skipped, otherwise the scored method rows.
|
|
569
625
|
* - `skippedFileNoCoverage` / `skippedMethodsNoCoverage` — CRAP counters.
|
|
570
626
|
*/
|
|
571
|
-
function scoreFileCombinedSerial(
|
|
627
|
+
function scoreFileCombinedSerial(
|
|
628
|
+
{ abs, relPath, requireCoverage, coverageAvailable = true },
|
|
629
|
+
coverage,
|
|
630
|
+
) {
|
|
572
631
|
const entry = findCoverageEntry(coverage, relPath);
|
|
573
632
|
const prepared = prepareSourceForScoring(abs);
|
|
574
633
|
if (prepared.error) {
|
|
@@ -613,7 +672,10 @@ function scoreFileCombinedSerial({ abs, relPath, requireCoverage }, coverage) {
|
|
|
613
672
|
};
|
|
614
673
|
}
|
|
615
674
|
const { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods } =
|
|
616
|
-
finalizeMethodRows(rawCrapRows, {
|
|
675
|
+
finalizeMethodRows(rawCrapRows, {
|
|
676
|
+
requireCoverage,
|
|
677
|
+
coverageAvailable,
|
|
678
|
+
});
|
|
617
679
|
return {
|
|
618
680
|
relPath,
|
|
619
681
|
miScore,
|
|
@@ -730,12 +792,19 @@ export async function scanAndScoreCombined({
|
|
|
730
792
|
// Build the work queue. Each item carries both the canonicalised relPath
|
|
731
793
|
// (CRAP's key + scope filter, matching scanAndScore) and the raw relPath
|
|
732
794
|
// (MI's key, matching calculateAll's `path.relative(cwd, p)` shape).
|
|
795
|
+
const coverageAvailable = isCoverageArtifactPresent(coverage);
|
|
733
796
|
const queue = [];
|
|
734
797
|
for (const abs of files) {
|
|
735
798
|
const rawRel = path.relative(cwd, abs).replace(/\\/g, '/');
|
|
736
799
|
const relPath = canonicalisePath(rawRel);
|
|
737
800
|
if (scopeSet && !scopeSet.has(relPath)) continue;
|
|
738
|
-
queue.push({
|
|
801
|
+
queue.push({
|
|
802
|
+
abs,
|
|
803
|
+
relPath,
|
|
804
|
+
miRel: rawRel,
|
|
805
|
+
requireCoverage,
|
|
806
|
+
coverageAvailable,
|
|
807
|
+
});
|
|
739
808
|
}
|
|
740
809
|
const scannedFiles = queue.length;
|
|
741
810
|
|
|
@@ -785,10 +854,14 @@ export async function scanAndScoreCombined({
|
|
|
785
854
|
crapRows.push({
|
|
786
855
|
file: item.relPath,
|
|
787
856
|
method: mr.method,
|
|
857
|
+
// Story #4969: `method` may be a derived anonymous identity; the flag
|
|
858
|
+
// is what lets the persisted row say so.
|
|
859
|
+
anonymous: mr.anonymous === true,
|
|
788
860
|
startLine: mr.startLine,
|
|
789
861
|
cyclomatic: mr.cyclomatic,
|
|
790
862
|
coverage: mr.coverage,
|
|
791
863
|
crap: mr.crap,
|
|
864
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
792
865
|
});
|
|
793
866
|
}
|
|
794
867
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cyclomatic-ceiling.js — the enforcing core behind
|
|
3
|
+
* `delivery.quality.codingGuardrails.cyclomaticMustFix` (Story #4923).
|
|
4
|
+
*
|
|
5
|
+
* The two `codingGuardrails` cyclomatic knobs shipped schema-validated,
|
|
6
|
+
* bootstrap-defaulted and resolver-resolved, and were then read by nothing:
|
|
7
|
+
* `cyclomaticMustFix` had no consumer at all, and `cyclomaticFlag` was
|
|
8
|
+
* shadowed by a hardcoded `8` in a `quality-preview` display column. A ceiling
|
|
9
|
+
* nothing enforces is worse than no ceiling, because the workflow docs promise
|
|
10
|
+
* the merge will be refused.
|
|
11
|
+
*
|
|
12
|
+
* This module is that enforcement, shaped as a **ratchet** rather than a
|
|
13
|
+
* cliff. The repository already carries dozens of functions above the
|
|
14
|
+
* must-fix ceiling; failing every one of them at once would have made the
|
|
15
|
+
* gate un-landable and it would have been disabled the same day. So the
|
|
16
|
+
* committed `baselines/cyclomatic.json` records the existing breaches per
|
|
17
|
+
* file, and the gate fails only when a change **adds** an over-ceiling
|
|
18
|
+
* function to a file that had none, adds one **beyond** that file's recorded
|
|
19
|
+
* count, or pushes a file's worst function **higher** than recorded. Burning
|
|
20
|
+
* the recorded breaches down is a separate, always-permitted motion — a
|
|
21
|
+
* shrinking baseline is the success signal.
|
|
22
|
+
*
|
|
23
|
+
* Scope: the module walks the `maintainability` gate's `targetDirs` /
|
|
24
|
+
* `ignoreGlobs`. Both instruments read the same coverage-free escomplex
|
|
25
|
+
* surface, so re-declaring the scope under `codingGuardrails` would have
|
|
26
|
+
* added two config keys whose only correct value is "whatever maintainability
|
|
27
|
+
* says".
|
|
28
|
+
*
|
|
29
|
+
* @module lib/cyclomatic-ceiling
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { calculateReportForFile } from './maintainability-engine.js';
|
|
34
|
+
import { isIgnoredByGlobs, scanDirectory } from './maintainability-utils.js';
|
|
35
|
+
|
|
36
|
+
/** Default location of the committed breach baseline. */
|
|
37
|
+
export const DEFAULT_CYCLOMATIC_BASELINE = 'baselines/cyclomatic.json';
|
|
38
|
+
|
|
39
|
+
/** Baseline `$schema` marker, matching the sibling ratchet baselines. */
|
|
40
|
+
const CYCLOMATIC_BASELINE_SCHEMA =
|
|
41
|
+
'https://mandrel.dev/baselines/cyclomatic.schema.json';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the enforcement policy from a resolved `delivery.quality` block.
|
|
45
|
+
*
|
|
46
|
+
* `mustFix` and `flag` come straight from `resolveCodingGuardrails`, so a
|
|
47
|
+
* consumer that tunes either knob tunes this gate — which is the whole point
|
|
48
|
+
* of the Story. `targetDirs` / `ignoreGlobs` are borrowed from the
|
|
49
|
+
* maintainability gate (see the module note).
|
|
50
|
+
*
|
|
51
|
+
* @param {object | null | undefined} quality resolved `delivery.quality`
|
|
52
|
+
* @returns {{ mustFix: number, flag: number, targetDirs: string[], ignoreGlobs: string[] }}
|
|
53
|
+
*/
|
|
54
|
+
export function resolveCyclomaticPolicy(quality) {
|
|
55
|
+
const guardrails = quality?.codingGuardrails ?? {};
|
|
56
|
+
const mi = quality?.maintainability ?? {};
|
|
57
|
+
return {
|
|
58
|
+
mustFix: Number(guardrails.cyclomaticMustFix ?? 12),
|
|
59
|
+
flag: Number(guardrails.cyclomaticFlag ?? 8),
|
|
60
|
+
targetDirs: Array.isArray(mi.targetDirs) ? mi.targetDirs : [],
|
|
61
|
+
ignoreGlobs: Array.isArray(mi.ignoreGlobs) ? mi.ignoreGlobs : [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reduce one file's escomplex method list to a breach row, or `null` when the
|
|
67
|
+
* file carries no function above `ceiling`.
|
|
68
|
+
*
|
|
69
|
+
* Pure. Module-private: tests reach it through `scanCyclomatic`'s `scoreFile`
|
|
70
|
+
* seam, which keeps the row math exercised without adding an export whose only
|
|
71
|
+
* importer is a test (the `--production` dead-export pass discounts those).
|
|
72
|
+
*
|
|
73
|
+
* @param {string} file repo-relative POSIX path
|
|
74
|
+
* @param {Array<{ cyclomatic?: number }>} methods
|
|
75
|
+
* @param {number} ceiling
|
|
76
|
+
* @returns {{ file: string, methodsAboveCeiling: number, maxCyclomatic: number } | null}
|
|
77
|
+
*/
|
|
78
|
+
function breachRowFor(file, methods, ceiling) {
|
|
79
|
+
let count = 0;
|
|
80
|
+
let max = 0;
|
|
81
|
+
for (const method of methods ?? []) {
|
|
82
|
+
const c = Number(method?.cyclomatic ?? 0);
|
|
83
|
+
if (!Number.isFinite(c)) continue;
|
|
84
|
+
if (c > ceiling) count += 1;
|
|
85
|
+
if (c > max) max = c;
|
|
86
|
+
}
|
|
87
|
+
return count === 0
|
|
88
|
+
? null
|
|
89
|
+
: { file, methodsAboveCeiling: count, maxCyclomatic: max };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Walk the configured scope and score every file, returning the breach rows
|
|
94
|
+
* sorted by path.
|
|
95
|
+
*
|
|
96
|
+
* `scoreFile` is a seam so tests can drive the reduction without the kernel;
|
|
97
|
+
* production callers omit it.
|
|
98
|
+
*
|
|
99
|
+
* @param {{
|
|
100
|
+
* targetDirs: string[],
|
|
101
|
+
* ignoreGlobs?: string[],
|
|
102
|
+
* ceiling: number,
|
|
103
|
+
* cwd?: string,
|
|
104
|
+
* scoreFile?: (absPath: string) => { methods?: Array<{ cyclomatic?: number }>, parseError?: boolean },
|
|
105
|
+
* }} args
|
|
106
|
+
* @returns {{ rows: Array<object>, scannedFiles: number, parseErrors: number }}
|
|
107
|
+
*/
|
|
108
|
+
export function scanCyclomatic({
|
|
109
|
+
targetDirs,
|
|
110
|
+
ignoreGlobs = [],
|
|
111
|
+
ceiling,
|
|
112
|
+
cwd = process.cwd(),
|
|
113
|
+
scoreFile = calculateReportForFile,
|
|
114
|
+
}) {
|
|
115
|
+
const files = [];
|
|
116
|
+
for (const dir of targetDirs ?? []) {
|
|
117
|
+
const abs = path.isAbsolute(dir) ? dir : path.resolve(cwd, dir);
|
|
118
|
+
scanDirectory(abs, files, { cwd, ignoreGlobs });
|
|
119
|
+
}
|
|
120
|
+
files.sort();
|
|
121
|
+
const rows = [];
|
|
122
|
+
let parseErrors = 0;
|
|
123
|
+
for (const abs of files) {
|
|
124
|
+
if (isIgnoredByGlobs(abs, ignoreGlobs, cwd)) continue;
|
|
125
|
+
const report = scoreFile(abs);
|
|
126
|
+
if (!report || report.parseError) {
|
|
127
|
+
parseErrors += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const rel = path.relative(cwd, abs).split(path.sep).join('/');
|
|
131
|
+
const row = breachRowFor(rel, report.methods, ceiling);
|
|
132
|
+
if (row) rows.push(row);
|
|
133
|
+
}
|
|
134
|
+
rows.sort((a, b) => a.file.localeCompare(b.file));
|
|
135
|
+
return { rows, scannedFiles: files.length, parseErrors };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Diff current breach rows against the committed baseline.
|
|
140
|
+
*
|
|
141
|
+
* Four buckets, only the first two of which fail the gate:
|
|
142
|
+
*
|
|
143
|
+
* - `added` — a file whose over-ceiling function count rose (including
|
|
144
|
+
* 0 → 1, i.e. a brand-new breach in new or changed code).
|
|
145
|
+
* - `worsened` — a file whose worst function got worse than recorded.
|
|
146
|
+
* - `removed` — a file that no longer breaches at all.
|
|
147
|
+
* - `improved` — a file that breaches less than recorded.
|
|
148
|
+
*
|
|
149
|
+
* Pure; identity is the repo-relative file path.
|
|
150
|
+
*
|
|
151
|
+
* @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} baselineRows
|
|
152
|
+
* @param {Array<{file: string, methodsAboveCeiling: number, maxCyclomatic: number}>} currentRows
|
|
153
|
+
* @returns {{ added: Array<object>, worsened: Array<object>, removed: Array<object>, improved: Array<object> }}
|
|
154
|
+
*/
|
|
155
|
+
export function diffCyclomaticRows(baselineRows, currentRows) {
|
|
156
|
+
const base = new Map(
|
|
157
|
+
(baselineRows ?? [])
|
|
158
|
+
.filter((r) => typeof r?.file === 'string')
|
|
159
|
+
.map((r) => [r.file, r]),
|
|
160
|
+
);
|
|
161
|
+
const added = [];
|
|
162
|
+
const worsened = [];
|
|
163
|
+
const improved = [];
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
for (const row of currentRows ?? []) {
|
|
166
|
+
if (typeof row?.file !== 'string') continue;
|
|
167
|
+
seen.add(row.file);
|
|
168
|
+
const prior = base.get(row.file);
|
|
169
|
+
const priorCount = Number(prior?.methodsAboveCeiling ?? 0);
|
|
170
|
+
const priorMax = Number(prior?.maxCyclomatic ?? 0);
|
|
171
|
+
if (row.methodsAboveCeiling > priorCount) {
|
|
172
|
+
added.push({ ...row, baselineCount: priorCount });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (row.maxCyclomatic > priorMax) {
|
|
176
|
+
worsened.push({ ...row, baselineMax: priorMax });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (row.methodsAboveCeiling < priorCount || row.maxCyclomatic < priorMax) {
|
|
180
|
+
improved.push({
|
|
181
|
+
...row,
|
|
182
|
+
baselineCount: priorCount,
|
|
183
|
+
baselineMax: priorMax,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const removed = (baselineRows ?? []).filter(
|
|
188
|
+
(r) => typeof r?.file === 'string' && !seen.has(r.file),
|
|
189
|
+
);
|
|
190
|
+
const byFile = (a, b) => a.file.localeCompare(b.file);
|
|
191
|
+
return {
|
|
192
|
+
added: added.sort(byFile),
|
|
193
|
+
worsened: worsened.sort(byFile),
|
|
194
|
+
removed: removed.sort(byFile),
|
|
195
|
+
improved: improved.sort(byFile),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Assemble the committed baseline envelope. The rollup is deliberately
|
|
201
|
+
* derived, never hand-written: a zero-row baseline here reports zero breaches
|
|
202
|
+
* because the repository has none, not because nobody produced it.
|
|
203
|
+
*
|
|
204
|
+
* @param {{ rows: Array<object>, ceiling: number, generatedAt?: string }} args
|
|
205
|
+
* @returns {object}
|
|
206
|
+
*/
|
|
207
|
+
export function buildCyclomaticEnvelope({ rows, ceiling, generatedAt }) {
|
|
208
|
+
const safeRows = rows ?? [];
|
|
209
|
+
let methods = 0;
|
|
210
|
+
let max = 0;
|
|
211
|
+
for (const row of safeRows) {
|
|
212
|
+
methods += Number(row.methodsAboveCeiling ?? 0);
|
|
213
|
+
if (Number(row.maxCyclomatic ?? 0) > max) max = Number(row.maxCyclomatic);
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
$schema: CYCLOMATIC_BASELINE_SCHEMA,
|
|
217
|
+
generatedAt: generatedAt ?? new Date().toISOString(),
|
|
218
|
+
ceiling,
|
|
219
|
+
rollup: {
|
|
220
|
+
'*': {
|
|
221
|
+
filesAboveCeiling: safeRows.length,
|
|
222
|
+
methodsAboveCeiling: methods,
|
|
223
|
+
maxCyclomatic: max,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
rows: safeRows,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Render the human-readable diff. Emits a summary line even on a clean run so
|
|
232
|
+
* operators see the "no drift" signal rather than silence.
|
|
233
|
+
*
|
|
234
|
+
* @param {{ added: Array, worsened: Array, removed: Array, improved: Array }} diff
|
|
235
|
+
* @param {number} ceiling
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
export function renderCyclomaticDiff(diff, ceiling) {
|
|
239
|
+
const lines = [];
|
|
240
|
+
for (const r of diff.added) {
|
|
241
|
+
lines.push(
|
|
242
|
+
`+ ${r.file}: ${r.methodsAboveCeiling} function(s) over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic}`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
for (const r of diff.worsened) {
|
|
246
|
+
lines.push(
|
|
247
|
+
`! ${r.file}: worst function c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
for (const r of diff.improved) {
|
|
251
|
+
lines.push(
|
|
252
|
+
`~ ${r.file}: ${r.methodsAboveCeiling} over c=${ceiling} (recorded ${r.baselineCount}), worst c=${r.maxCyclomatic} (recorded ${r.baselineMax})`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
for (const r of diff.removed) {
|
|
256
|
+
lines.push(`- ${r.file}: no longer over c=${ceiling}`);
|
|
257
|
+
}
|
|
258
|
+
const failing = diff.added.length + diff.worsened.length;
|
|
259
|
+
lines.push(
|
|
260
|
+
`[cyclomatic] ceiling=${ceiling} added=${diff.added.length} worsened=${diff.worsened.length} improved=${diff.improved.length} removed=${diff.removed.length} ${
|
|
261
|
+
failing > 0 ? '(gate fail)' : '(ok)'
|
|
262
|
+
}`,
|
|
263
|
+
);
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|
|
@@ -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
|
}
|