mandrel 2.25.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 +10 -6
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +3 -1
- package/.agents/instructions.md +1 -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 +4 -0
- package/.agents/schemas/baselines/crap.schema.json +8 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +89 -6
- package/.agents/scripts/audit-baselines.js +136 -0
- 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-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +38 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -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-lenses.js +1 -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 +26 -4
- package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
- 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 -11
- 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/crap-engine.js +32 -13
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +13 -0
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- 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/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -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/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/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/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 +209 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
- 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/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
- 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/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
- 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/story-adjacency.js +3 -3
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- 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/wave-runner/live-probe.js +24 -14
- package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
- package/.agents/scripts/lib/workers/crap-worker.js +2 -10
- 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/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +112 -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 +26 -36
- package/.agents/scripts/single-story-close.js +28 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/stories-wave-tick.js +214 -38
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- 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/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/deliver.md +13 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- 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 +77 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
- package/.agents/workflows/helpers/plan-reference.md +15 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +3 -0
- package/README.md +13 -6
- package/docs/CHANGELOG.md +64 -0
- 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
|
@@ -315,17 +315,79 @@ export async function runCloseValidation({
|
|
|
315
315
|
}
|
|
316
316
|
|
|
317
317
|
// ── Phase 2: serial gates in declared order ─────────────────────────
|
|
318
|
+
await runSerialGates(serial, {
|
|
319
|
+
spawnCwd,
|
|
320
|
+
log,
|
|
321
|
+
failed,
|
|
322
|
+
skipped,
|
|
323
|
+
evidenceActive,
|
|
324
|
+
evidenceClock,
|
|
325
|
+
evidenceVerdict,
|
|
326
|
+
recordIfActive,
|
|
327
|
+
dispatchGate,
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── Phase 3: advisory projections ───────────────────────────────────
|
|
331
|
+
// Story #4776 — the projection layer's live call site. Deliberately
|
|
332
|
+
// outside the `ok` computation: a projected breach informs, it never
|
|
333
|
+
// fails a close.
|
|
334
|
+
if (failed.length === 0) {
|
|
335
|
+
await runAdvisoryProjections({
|
|
336
|
+
runProjections,
|
|
337
|
+
cwd: spawnCwd,
|
|
338
|
+
baseBranch,
|
|
339
|
+
storyBranch,
|
|
340
|
+
config,
|
|
341
|
+
log,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { ok: failed.length === 0, failed, skipped };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Phase 2 helper — run the serial gates in declared order, stopping at the
|
|
350
|
+
* first failure. Extracted from `runCloseValidation` (Story #4926); the
|
|
351
|
+
* evidence bookkeeping stays bit-identical to the parallel pass because both
|
|
352
|
+
* call the same injected `evidenceVerdict` / `recordIfActive` closures.
|
|
353
|
+
*
|
|
354
|
+
* Mutates the caller's `failed` / `skipped` accumulators — the same arrays
|
|
355
|
+
* Phase 1 already wrote into, so the returned verdict stays one list.
|
|
356
|
+
*
|
|
357
|
+
* @param {Array<object>} serial
|
|
358
|
+
* @param {object} deps
|
|
359
|
+
* @returns {Promise<void>}
|
|
360
|
+
*/
|
|
361
|
+
async function runSerialGates(
|
|
362
|
+
serial,
|
|
363
|
+
{
|
|
364
|
+
spawnCwd,
|
|
365
|
+
log,
|
|
366
|
+
failed,
|
|
367
|
+
skipped,
|
|
368
|
+
evidenceActive,
|
|
369
|
+
evidenceClock,
|
|
370
|
+
evidenceVerdict,
|
|
371
|
+
recordIfActive,
|
|
372
|
+
dispatchGate,
|
|
373
|
+
},
|
|
374
|
+
) {
|
|
375
|
+
const failGate = (gate, status, message) => {
|
|
376
|
+
failed.push({ gate, status, cwd: spawnCwd });
|
|
377
|
+
log(message);
|
|
378
|
+
if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
|
|
379
|
+
};
|
|
318
380
|
for (const gate of serial) {
|
|
319
381
|
let execution;
|
|
320
382
|
try {
|
|
321
383
|
execution = applyChangedFileScope({ gate, spawnCwd, log });
|
|
322
384
|
} catch (err) {
|
|
323
|
-
|
|
324
|
-
|
|
385
|
+
failGate(
|
|
386
|
+
gate,
|
|
387
|
+
1,
|
|
325
388
|
`[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
|
|
326
389
|
);
|
|
327
|
-
|
|
328
|
-
break;
|
|
390
|
+
return;
|
|
329
391
|
}
|
|
330
392
|
if (execution.skip) {
|
|
331
393
|
skipped.push({ gate, reason: 'no-changed-files' });
|
|
@@ -349,12 +411,12 @@ export async function runCloseValidation({
|
|
|
349
411
|
tolerateNoFilesProcessed: execution.tolerateNoFilesProcessed,
|
|
350
412
|
});
|
|
351
413
|
if (result.status !== 0) {
|
|
352
|
-
|
|
353
|
-
|
|
414
|
+
failGate(
|
|
415
|
+
gate,
|
|
416
|
+
result.status,
|
|
354
417
|
`[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
|
|
355
418
|
);
|
|
356
|
-
|
|
357
|
-
break;
|
|
419
|
+
return;
|
|
358
420
|
}
|
|
359
421
|
log(`[close-validation] ✓ ${gate.name}`);
|
|
360
422
|
recordIfActive(
|
|
@@ -363,23 +425,6 @@ export async function runCloseValidation({
|
|
|
363
425
|
evidenceActive ? evidenceClock() - startedAt : 0,
|
|
364
426
|
);
|
|
365
427
|
}
|
|
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
|
-
|
|
382
|
-
return { ok: failed.length === 0, failed, skipped };
|
|
383
428
|
}
|
|
384
429
|
|
|
385
430
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import escomplex from 'typhonjs-escomplex';
|
|
2
2
|
import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
3
|
+
import { deriveMethodIdentities } from './crap-method-identity.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The two line coordinate systems a CRAP row's `startLine` can be expressed
|
|
@@ -47,6 +48,7 @@ export const COORDINATE_TRANSPILED = 'transpiled';
|
|
|
47
48
|
* @param {((line: number) => number|null)|null} [mapLine]
|
|
48
49
|
* @returns {Array<{
|
|
49
50
|
* method: string,
|
|
51
|
+
* anonymous: boolean,
|
|
50
52
|
* startLine: number,
|
|
51
53
|
* cyclomatic: number,
|
|
52
54
|
* coverage: number|null,
|
|
@@ -78,8 +80,14 @@ function resolveCoordinate(rawStartLine, mapLine) {
|
|
|
78
80
|
|
|
79
81
|
export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
80
82
|
const methods = report?.methods ?? [];
|
|
83
|
+
// Identities are derived over the WHOLE method list, before any row is
|
|
84
|
+
// skipped, and read back by index (Story #4969). Deriving them from the
|
|
85
|
+
// surviving rows instead would let an unscorable method's absence shift its
|
|
86
|
+
// siblings' ordinals — reintroducing, through the back door, exactly the
|
|
87
|
+
// position dependence this replaces.
|
|
88
|
+
const identities = deriveMethodIdentities(methods);
|
|
81
89
|
const rows = [];
|
|
82
|
-
for (const m of methods) {
|
|
90
|
+
for (const [i, m] of methods.entries()) {
|
|
83
91
|
const rawStartLine = m?.lineStart;
|
|
84
92
|
if (typeof rawStartLine !== 'number') continue;
|
|
85
93
|
const { startLine, coordinateSystem } = resolveCoordinate(
|
|
@@ -93,7 +101,7 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
93
101
|
: null;
|
|
94
102
|
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
95
103
|
rows.push({
|
|
96
|
-
|
|
104
|
+
...identities[i],
|
|
97
105
|
startLine,
|
|
98
106
|
cyclomatic,
|
|
99
107
|
coverage,
|
|
@@ -132,10 +140,16 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
132
140
|
* so `requireCoverage: false` keeps meaning "score untested code" whenever a
|
|
133
141
|
* real coverage run stands behind the verdict.
|
|
134
142
|
*
|
|
135
|
-
*
|
|
136
|
-
* method
|
|
137
|
-
*
|
|
138
|
-
*
|
|
143
|
+
* **Unjoinable is not untested (Story #4901).** Both policies above are about
|
|
144
|
+
* a method whose coverage is *absent*; neither is about one whose coverage
|
|
145
|
+
* could not be **joined**. A transpiled `startLine` is the latter — not in
|
|
146
|
+
* the coordinate space the `fnMap` or the baseline is keyed against — so 0%
|
|
147
|
+
* is not a measurement but a number invented for it, and `crapFormula(c, 0)`
|
|
148
|
+
* is the maximal `c² + c`. Excluded under **either** policy, and counted.
|
|
149
|
+
*
|
|
150
|
+
* `resolvedMethods` / `totalMethods` count the *join*, not the fill, which is
|
|
151
|
+
* what makes them a health signal for the updater's fail-closed
|
|
152
|
+
* resolution-rate floor — so the exclusion is applied *after* both counters.
|
|
139
153
|
*
|
|
140
154
|
* @param {Array<object>} rawRows Rows from `methodRowsFromReport`.
|
|
141
155
|
* @param {{requireCoverage?: boolean, coverageAvailable?: boolean}} [opts]
|
|
@@ -158,21 +172,25 @@ export function finalizeMethodRows(
|
|
|
158
172
|
totalMethods += 1;
|
|
159
173
|
const unresolved = mr.crap === null || mr.coverage === null;
|
|
160
174
|
if (!unresolved) resolvedMethods += 1;
|
|
161
|
-
|
|
175
|
+
// Unjoinable is not untested (Story #4901) — see the block comment above.
|
|
176
|
+
if (
|
|
177
|
+
mr.coordinateSystem === COORDINATE_TRANSPILED ||
|
|
178
|
+
(unresolved && (requireCoverage || !coverageAvailable))
|
|
179
|
+
) {
|
|
162
180
|
skippedMethodsNoCoverage += 1;
|
|
163
181
|
continue;
|
|
164
182
|
}
|
|
165
183
|
const coverage = unresolved ? 0 : mr.coverage;
|
|
166
184
|
const crap = unresolved ? crapFormula(mr.cyclomatic, 0) : mr.crap;
|
|
185
|
+
// Everything the scan decided is carried forward; this step overrides only
|
|
186
|
+
// what its own policy resolves. Spreading rather than re-listing each field
|
|
187
|
+
// is why the row's identity marker (Story #4969) and its provenance
|
|
188
|
+
// (Story #4866) survive the step without a line each to remember them —
|
|
189
|
+
// a hand-rebuilt row is how a marker silently stops reaching the baseline.
|
|
167
190
|
rows.push({
|
|
168
|
-
|
|
169
|
-
startLine: mr.startLine,
|
|
170
|
-
cyclomatic: mr.cyclomatic,
|
|
191
|
+
...mr,
|
|
171
192
|
coverage,
|
|
172
193
|
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
194
|
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
177
195
|
});
|
|
178
196
|
}
|
|
@@ -203,6 +221,7 @@ export function finalizeMethodRows(
|
|
|
203
221
|
* original line resolver; see `methodRowsFromReport`.
|
|
204
222
|
* @returns {Array<{
|
|
205
223
|
* method: string,
|
|
224
|
+
* anonymous: boolean,
|
|
206
225
|
* startLine: number,
|
|
207
226
|
* cyclomatic: number,
|
|
208
227
|
* coverage: number|null,
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crap-method-identity.js — order-independent identity for the methods an
|
|
3
|
+
* escomplex report describes (Story #4969).
|
|
4
|
+
*
|
|
5
|
+
* Split out of `crap-engine.js` deliberately. That module is the CRAP scoring
|
|
6
|
+
* kernel — parse, remap a line, join coverage, apply the formula — and naming
|
|
7
|
+
* a method is a different question from scoring one: it reads the shape of the
|
|
8
|
+
* scope tree and nothing about coverage or complexity. Keeping the walker here
|
|
9
|
+
* leaves the kernel's contract ("pure, no I/O, one method in, one score out")
|
|
10
|
+
* legible instead of half-buried under a tree traversal.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The label `typhonjs-escomplex-commons` gives a function it cannot name
|
|
15
|
+
* (Story #4969).
|
|
16
|
+
*
|
|
17
|
+
* The `N` is a per-module counter incremented during the AST walk, so it
|
|
18
|
+
* numbers a function by its *position among its anonymous siblings* rather
|
|
19
|
+
* than by anything about the function itself. Inserting or deleting one
|
|
20
|
+
* anonymous function renumbers every later one in the file — and 34.6% of this
|
|
21
|
+
* repository's CRAP rows were keyed on exactly that. See
|
|
22
|
+
* `deriveMethodIdentities` for what replaces it.
|
|
23
|
+
*/
|
|
24
|
+
const ESCOMPLEX_ANON_LABEL_RE = /^<anon method-\d+>$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True for a `method` value that is a derived anonymous identity rather than a
|
|
28
|
+
* name the source actually carries — either this module's `<anon …>` identity
|
|
29
|
+
* or escomplex's superseded ordinal label.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} method
|
|
32
|
+
* @returns {boolean}
|
|
33
|
+
*/
|
|
34
|
+
export function isAnonymousMethodLabel(method) {
|
|
35
|
+
return typeof method === 'string' && method.startsWith('<anon ');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Coerce a method's escomplex line span into a usable numeric pair. A method
|
|
40
|
+
* missing either bound still has to take a deterministic place in the scope
|
|
41
|
+
* tree, so it collapses to a zero-width span rather than being dropped: an
|
|
42
|
+
* identity that varies with how the parser failed would be worse than one that
|
|
43
|
+
* is merely coarse.
|
|
44
|
+
*
|
|
45
|
+
* @param {{lineStart?: unknown, lineEnd?: unknown}} m
|
|
46
|
+
* @returns {{lineStart: number, lineEnd: number}}
|
|
47
|
+
*/
|
|
48
|
+
function spanOf(m) {
|
|
49
|
+
const lineStart = Number.isFinite(m?.lineStart) ? m.lineStart : 0;
|
|
50
|
+
const lineEnd = Number.isFinite(m?.lineEnd) ? m.lineEnd : lineStart;
|
|
51
|
+
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Give every method in an escomplex report an identity that does not depend on
|
|
56
|
+
* how many anonymous functions happen to precede it (Story #4969).
|
|
57
|
+
*
|
|
58
|
+
* **Named methods are untouched** — their identity is the name escomplex
|
|
59
|
+
* reported, exactly as before, so no named row is re-keyed by this change.
|
|
60
|
+
*
|
|
61
|
+
* **An anonymous method is identified by where it sits, not by when it was
|
|
62
|
+
* counted.** The identity is `<anon {scope path}>`, where the path is the
|
|
63
|
+
* chain of enclosing methods from the module down to the method itself. Each
|
|
64
|
+
* link contributes a name if it has one, otherwise its parameter list plus an
|
|
65
|
+
* ordinal among the siblings that share *that exact parent and that exact
|
|
66
|
+
* parameter list*:
|
|
67
|
+
*
|
|
68
|
+
* ```text
|
|
69
|
+
* <anon (p)#0> module-scope, first (p) arrow
|
|
70
|
+
* <anon buildDefaultCrapScorer/(files,opts)#0> inside a named function
|
|
71
|
+
* <anon buildDefaultCrapScorer/(files,opts)#0/(r)#0> inside that one
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* **Why the parameter list and not the body.** The identity has to survive an
|
|
75
|
+
* edit *to* the method — a method that gains a branch must still be recognised
|
|
76
|
+
* as the same method, or a genuine complexity regression would read as a brand
|
|
77
|
+
* new function and score against the new-method ceiling instead of its own
|
|
78
|
+
* baseline. That rules out anything derived from the body: a source or
|
|
79
|
+
* normalized-AST hash re-keys the row on the very edit the gate exists to
|
|
80
|
+
* catch. A parameter list is stable across the edits CRAP measures, and it
|
|
81
|
+
* partitions same-scope siblings finely enough that the residual ordinal is
|
|
82
|
+
* almost always `#0`.
|
|
83
|
+
*
|
|
84
|
+
* **The residual.** Within one parent *and* one parameter list, siblings are
|
|
85
|
+
* still told apart by source order, so inserting a same-signature sibling
|
|
86
|
+
* *before* an existing one still shifts it. That residual is irreducible:
|
|
87
|
+
* anything that distinguishes two same-scope, same-signature functions has to
|
|
88
|
+
* read their bodies, and reading their bodies re-keys them on every edit. The
|
|
89
|
+
* blast radius drops from "every anonymous function in the file" to "the
|
|
90
|
+
* same-signature siblings of one scope".
|
|
91
|
+
*
|
|
92
|
+
* Emission order is load-bearing and guaranteed: escomplex walks the AST
|
|
93
|
+
* pre-order, so a method's ancestors are always emitted before it. That lets a
|
|
94
|
+
* single forward pass with an open-ancestor stack resolve every parent in
|
|
95
|
+
* O(n), and it makes the innermost enclosing method the most recent one still
|
|
96
|
+
* open.
|
|
97
|
+
*
|
|
98
|
+
* @param {Array<object>} methods `report.methods` from escomplex.
|
|
99
|
+
* @returns {Array<{method: string, anonymous: boolean}>} One entry per input
|
|
100
|
+
* method, index-aligned with `methods` and spreadable straight onto a row.
|
|
101
|
+
* The scope path itself stays internal — only the identity leaves.
|
|
102
|
+
*/
|
|
103
|
+
export function deriveMethodIdentities(methods) {
|
|
104
|
+
const identities = [];
|
|
105
|
+
/** @type {Array<{lineEnd: number, scopePath: string}>} */
|
|
106
|
+
const openAncestors = [];
|
|
107
|
+
/** Sibling counts keyed by `parent scope path` + `parameter list`. */
|
|
108
|
+
const ordinals = new Map();
|
|
109
|
+
|
|
110
|
+
for (const m of methods ?? []) {
|
|
111
|
+
const { lineStart, lineEnd } = spanOf(m);
|
|
112
|
+
// Close every ancestor whose span has ended before this method begins.
|
|
113
|
+
while (
|
|
114
|
+
openAncestors.length > 0 &&
|
|
115
|
+
openAncestors[openAncestors.length - 1].lineEnd < lineStart
|
|
116
|
+
) {
|
|
117
|
+
openAncestors.pop();
|
|
118
|
+
}
|
|
119
|
+
const parentPath =
|
|
120
|
+
openAncestors.length > 0
|
|
121
|
+
? openAncestors[openAncestors.length - 1].scopePath
|
|
122
|
+
: '';
|
|
123
|
+
|
|
124
|
+
const name = typeof m?.name === 'string' ? m.name : '';
|
|
125
|
+
let segment;
|
|
126
|
+
let anonymous;
|
|
127
|
+
if (name === '' || ESCOMPLEX_ANON_LABEL_RE.test(name)) {
|
|
128
|
+
const params = Array.isArray(m?.paramNames) ? m.paramNames : [];
|
|
129
|
+
const signature = `(${params.join(',')})`;
|
|
130
|
+
// NUL separates the two halves because it is the one character a scope
|
|
131
|
+
// path can never contain — a printable delimiter could be produced by a
|
|
132
|
+
// parameter name and silently merge two distinct buckets. Spelled as an
|
|
133
|
+
// escape: a literal NUL byte would make this file binary to git and grep.
|
|
134
|
+
const bucket = `${parentPath}\u0000${signature}`;
|
|
135
|
+
const ordinal = ordinals.get(bucket) ?? 0;
|
|
136
|
+
ordinals.set(bucket, ordinal + 1);
|
|
137
|
+
segment = `${signature}#${ordinal}`;
|
|
138
|
+
anonymous = true;
|
|
139
|
+
} else {
|
|
140
|
+
segment = name;
|
|
141
|
+
anonymous = false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const scopePath = parentPath === '' ? segment : `${parentPath}/${segment}`;
|
|
145
|
+
identities.push({
|
|
146
|
+
method: anonymous ? `<anon ${scopePath}>` : name,
|
|
147
|
+
anonymous,
|
|
148
|
+
});
|
|
149
|
+
openAncestors.push({ lineEnd, scopePath });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return identities;
|
|
153
|
+
}
|
|
@@ -139,6 +139,12 @@ function projectCrapEnvelopeToLegacy(parsed) {
|
|
|
139
139
|
...(row.coordinateSystem === undefined
|
|
140
140
|
? {}
|
|
141
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 }),
|
|
142
148
|
})),
|
|
143
149
|
};
|
|
144
150
|
}
|
|
@@ -215,6 +221,7 @@ export function buildBaselineEnvelope({
|
|
|
215
221
|
file: r.file,
|
|
216
222
|
method: r.method,
|
|
217
223
|
startLine: r.startLine,
|
|
224
|
+
...(r.anonymous === undefined ? {} : { anonymous: r.anonymous }),
|
|
218
225
|
})),
|
|
219
226
|
tsTranspilerVersion,
|
|
220
227
|
};
|
|
@@ -501,6 +508,9 @@ export async function scanAndScore({
|
|
|
501
508
|
rows.push({
|
|
502
509
|
file: item.relPath,
|
|
503
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,
|
|
504
514
|
startLine: mr.startLine,
|
|
505
515
|
cyclomatic: mr.cyclomatic,
|
|
506
516
|
coverage: mr.coverage,
|
|
@@ -844,6 +854,9 @@ export async function scanAndScoreCombined({
|
|
|
844
854
|
crapRows.push({
|
|
845
855
|
file: item.relPath,
|
|
846
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,
|
|
847
860
|
startLine: mr.startLine,
|
|
848
861
|
cyclomatic: mr.cyclomatic,
|
|
849
862
|
coverage: mr.coverage,
|
|
@@ -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
|
+
}
|