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.
Files changed (189) hide show
  1. package/.agents/agents/acceptance-critic.md +19 -21
  2. package/.agents/agents/auditor.md +16 -19
  3. package/.agents/agents/story-worker.md +41 -54
  4. package/.agents/audit-checklists/architecture.md +5 -1
  5. package/.agents/audit-checklists/baselines.md +21 -0
  6. package/.agents/audit-checklists/quality.md +3 -0
  7. package/.agents/docs/agentrc-reference.json +2 -1
  8. package/.agents/docs/configuration.md +2 -1
  9. package/.agents/docs/quality-gates.md +80 -18
  10. package/.agents/docs/workflows.md +4 -2
  11. package/.agents/instructions.md +1 -1
  12. package/.agents/rules/ci-remediation.md +68 -3
  13. package/.agents/schemas/agentrc.schema.json +6 -1
  14. package/.agents/schemas/audit-rules.json +15 -0
  15. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  16. package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
  17. package/.agents/schemas/baselines/crap.schema.json +13 -1
  18. package/.agents/schemas/crap-report.schema.json +37 -0
  19. package/.agents/schemas/model-attribution.schema.json +4 -0
  20. package/.agents/scripts/acceptance-eval.js +124 -15
  21. package/.agents/scripts/audit-baselines.js +136 -0
  22. package/.agents/scripts/audit-labels-bootstrap.js +16 -95
  23. package/.agents/scripts/audit-to-stories.js +33 -3
  24. package/.agents/scripts/check-arch-cycles.js +12 -93
  25. package/.agents/scripts/check-baseline-drift.js +16 -3
  26. package/.agents/scripts/check-baselines.js +19 -3
  27. package/.agents/scripts/check-context-budget.js +95 -14
  28. package/.agents/scripts/check-cyclomatic.js +214 -0
  29. package/.agents/scripts/check-schema-references.js +392 -0
  30. package/.agents/scripts/check-test-temp-hygiene.js +121 -1
  31. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  32. package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
  33. package/.agents/scripts/deliver-light.js +3 -4
  34. package/.agents/scripts/deliver-recover.js +13 -0
  35. package/.agents/scripts/diagnose-friction.js +85 -19
  36. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  37. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  38. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  39. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  40. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  41. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  42. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  43. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  44. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  45. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  46. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  47. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  48. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  49. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
  50. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  51. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
  52. package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
  53. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
  54. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
  55. package/.agents/scripts/lib/baseline-loader.js +0 -0
  56. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  57. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  58. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  59. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  60. package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
  61. package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
  62. package/.agents/scripts/lib/baselines/reader.js +10 -0
  63. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  64. package/.agents/scripts/lib/baselines/scope.js +39 -90
  65. package/.agents/scripts/lib/baselines/writer.js +16 -7
  66. package/.agents/scripts/lib/changed-files.js +8 -1
  67. package/.agents/scripts/lib/cli-args.js +115 -1
  68. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  69. package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
  70. package/.agents/scripts/lib/crap-engine.js +124 -27
  71. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  72. package/.agents/scripts/lib/crap-utils.js +86 -13
  73. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  74. package/.agents/scripts/lib/env-loader.js +46 -16
  75. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  76. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  77. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  78. package/.agents/scripts/lib/findings/route-finding.js +123 -17
  79. package/.agents/scripts/lib/findings/severity.js +80 -2
  80. package/.agents/scripts/lib/git/cached-fetch.js +0 -0
  81. package/.agents/scripts/lib/git-utils.js +136 -80
  82. package/.agents/scripts/lib/import-graph.js +156 -0
  83. package/.agents/scripts/lib/label-constants.js +17 -0
  84. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  85. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  86. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
  87. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  88. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  89. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  90. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  91. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  92. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
  93. package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
  94. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  95. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  96. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
  97. package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
  98. package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
  99. package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
  100. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  101. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  102. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
  103. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
  104. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  105. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  106. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  107. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  108. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  109. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  110. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  111. package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
  112. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
  113. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
  114. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  115. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  116. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
  117. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  118. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
  119. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  120. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  121. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  122. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  123. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  124. package/.agents/scripts/lib/reserved-test-ids.js +77 -0
  125. package/.agents/scripts/lib/story-adjacency.js +3 -3
  126. package/.agents/scripts/lib/story-plan.js +137 -42
  127. package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
  128. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  129. package/.agents/scripts/lib/test-tiers.js +11 -2
  130. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  131. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  132. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  133. package/.agents/scripts/lib/util/poll-loop.js +18 -2
  134. package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
  135. package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
  136. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
  137. package/.agents/scripts/lib/workers/crap-worker.js +8 -13
  138. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  139. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  140. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  141. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  142. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  143. package/.agents/scripts/lib/worktree-manager.js +14 -0
  144. package/.agents/scripts/plan-run-epilogue.js +17 -5
  145. package/.agents/scripts/pr-watch-with-update.js +568 -213
  146. package/.agents/scripts/providers/github/tickets.js +33 -10
  147. package/.agents/scripts/provision-git-hooks.js +85 -0
  148. package/.agents/scripts/quality-preview.js +137 -28
  149. package/.agents/scripts/resolve-stories.js +4 -1
  150. package/.agents/scripts/run-coverage.js +86 -35
  151. package/.agents/scripts/run-lint.js +20 -0
  152. package/.agents/scripts/run-tests.js +36 -36
  153. package/.agents/scripts/single-story-close.js +34 -2
  154. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  155. package/.agents/scripts/single-story-init.js +7 -0
  156. package/.agents/scripts/stories-wave-tick.js +308 -47
  157. package/.agents/scripts/story-plan.js +65 -9
  158. package/.agents/scripts/update-coverage-baseline.js +34 -4
  159. package/.agents/scripts/update-crap-baseline.js +42 -4
  160. package/.agents/scripts/update-duplication-baseline.js +209 -83
  161. package/.agents/scripts/update-maintainability-baseline.js +31 -4
  162. package/.agents/scripts/validate-docs-freshness.js +1 -0
  163. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  164. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  165. package/.agents/skills/skills.index.json +2 -2
  166. package/.agents/templates/single-story-body.md +16 -8
  167. package/.agents/workflows/audit-architecture.md +69 -13
  168. package/.agents/workflows/audit-baselines.md +289 -0
  169. package/.agents/workflows/audit-navigability.md +5 -4
  170. package/.agents/workflows/audit-quality.md +26 -0
  171. package/.agents/workflows/audit-to-stories.md +30 -4
  172. package/.agents/workflows/deliver.md +92 -97
  173. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  174. package/.agents/workflows/helpers/audit-lens-core.md +25 -4
  175. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  176. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  177. package/.agents/workflows/helpers/deliver-reference.md +95 -4
  178. package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
  179. package/.agents/workflows/helpers/deliver-story.md +104 -119
  180. package/.agents/workflows/helpers/plan-reference.md +49 -5
  181. package/.agents/workflows/memory-consolidate.md +116 -0
  182. package/.agents/workflows/plan.md +62 -75
  183. package/README.md +13 -6
  184. package/docs/CHANGELOG.md +93 -0
  185. package/lib/cli/update.js +14 -11
  186. package/lib/cli/version-check.js +9 -1
  187. package/package.json +9 -4
  188. package/.agents/schemas/friction-event.schema.json +0 -56
  189. 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 { finalizeMethodRows, methodRowsFromReport } from './crap-engine.js';
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`/`tsTranspilerVersion`
101
- * version fields from the running scorer and re-key rows by `file` so
102
- * existing comparators keep working until Story #1912 lands the unified
103
- * gate. Detection probes the first row for the new `path` key — the
104
- * legacy envelope also carries `$schema` but keys rows by `file`.
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: resolveTsTranspilerVersion(),
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({ abs, relPath, requireCoverage }, coverage) {
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, { requireCoverage });
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({ abs, relPath, requireCoverage }, coverage) {
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, { requireCoverage });
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({ abs, relPath, miRel: rawRel, requireCoverage });
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
- let value = (match[2] || '').trim();
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
  }
@@ -333,5 +333,3 @@ export async function graduateAuditResults(opts = {}) {
333
333
  },
334
334
  });
335
335
  }
336
-
337
- export default graduateAuditResults;
@@ -307,5 +307,3 @@ export async function fetchPriorFeedback({
307
307
 
308
308
  return envelope;
309
309
  }
310
-
311
- export default fetchPriorFeedback;
@@ -495,5 +495,3 @@ export async function fileRetroProposals({
495
495
  );
496
496
  return { routedProposals: enriched, summary };
497
497
  }
498
-
499
- export default graduateRetroProposals;