mandrel 2.25.0 → 2.27.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 (132) hide show
  1. package/.agents/agents/acceptance-critic.md +10 -6
  2. package/.agents/audit-checklists/baselines.md +21 -0
  3. package/.agents/docs/quality-gates.md +80 -18
  4. package/.agents/docs/workflows.md +3 -1
  5. package/.agents/instructions.md +1 -1
  6. package/.agents/schemas/audit-rules.json +15 -0
  7. package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
  8. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  9. package/.agents/schemas/baselines/crap.schema.json +8 -0
  10. package/.agents/schemas/model-attribution.schema.json +4 -0
  11. package/.agents/scripts/acceptance-eval.js +89 -6
  12. package/.agents/scripts/audit-baselines.js +136 -0
  13. package/.agents/scripts/check-arch-cycles.js +12 -93
  14. package/.agents/scripts/check-baseline-drift.js +16 -3
  15. package/.agents/scripts/check-baselines.js +19 -3
  16. package/.agents/scripts/check-cyclomatic.js +214 -0
  17. package/.agents/scripts/check-schema-references.js +392 -0
  18. package/.agents/scripts/check-test-temp-hygiene.js +38 -1
  19. package/.agents/scripts/check-workflow-timeouts.js +291 -0
  20. package/.agents/scripts/diagnose-friction.js +85 -19
  21. package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
  22. package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
  23. package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
  24. package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
  25. package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
  26. package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
  27. package/.agents/scripts/lib/audit-baselines/read.js +87 -0
  28. package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
  29. package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
  30. package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
  31. package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
  32. package/.agents/scripts/lib/audit-suite/index.js +0 -5
  33. package/.agents/scripts/lib/audit-suite/selector.js +9 -62
  34. package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
  35. package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
  36. package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
  37. package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
  38. package/.agents/scripts/lib/baselines/git-base.js +26 -4
  39. package/.agents/scripts/lib/baselines/kinds/crap.js +112 -15
  40. package/.agents/scripts/lib/baselines/reader.js +52 -38
  41. package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
  42. package/.agents/scripts/lib/baselines/scope.js +39 -90
  43. package/.agents/scripts/lib/baselines/writer.js +16 -11
  44. package/.agents/scripts/lib/changed-files.js +8 -1
  45. package/.agents/scripts/lib/cli-args.js +115 -1
  46. package/.agents/scripts/lib/close-validation/runner.js +70 -25
  47. package/.agents/scripts/lib/crap-engine.js +32 -13
  48. package/.agents/scripts/lib/crap-method-identity.js +153 -0
  49. package/.agents/scripts/lib/crap-utils.js +13 -0
  50. package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
  51. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
  52. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
  53. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
  54. package/.agents/scripts/lib/git-utils.js +136 -80
  55. package/.agents/scripts/lib/import-graph.js +156 -0
  56. package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
  57. package/.agents/scripts/lib/observability/source-classifier.js +175 -2
  58. package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
  59. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
  60. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
  61. package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
  62. package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
  63. package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
  64. package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
  65. package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
  66. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
  67. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +209 -109
  68. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +48 -12
  69. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
  70. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
  71. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
  72. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
  73. package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
  74. package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
  75. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +29 -3
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
  78. package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
  79. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +245 -140
  80. package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
  81. package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
  82. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
  84. package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
  85. package/.agents/scripts/lib/story-adjacency.js +3 -3
  86. package/.agents/scripts/lib/test-runner-contract.js +134 -0
  87. package/.agents/scripts/lib/test-tiers.js +11 -2
  88. package/.agents/scripts/lib/util/concurrent-map.js +17 -0
  89. package/.agents/scripts/lib/util/parse-id-list.js +103 -0
  90. package/.agents/scripts/lib/wave-runner/live-probe.js +24 -14
  91. package/.agents/scripts/lib/wave-runner/ready-set.js +189 -42
  92. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +4 -10
  93. package/.agents/scripts/lib/workers/crap-worker.js +2 -10
  94. package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
  95. package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
  96. package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
  97. package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
  98. package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
  99. package/.agents/scripts/lib/worktree-manager.js +14 -0
  100. package/.agents/scripts/plan-run-epilogue.js +17 -5
  101. package/.agents/scripts/providers/github/tickets.js +33 -10
  102. package/.agents/scripts/provision-git-hooks.js +85 -0
  103. package/.agents/scripts/quality-preview.js +112 -28
  104. package/.agents/scripts/resolve-stories.js +4 -1
  105. package/.agents/scripts/run-coverage.js +86 -35
  106. package/.agents/scripts/run-lint.js +20 -0
  107. package/.agents/scripts/run-tests.js +26 -36
  108. package/.agents/scripts/single-story-close.js +28 -2
  109. package/.agents/scripts/single-story-confirm-merge.js +22 -6
  110. package/.agents/scripts/stories-wave-tick.js +214 -38
  111. package/.agents/scripts/update-coverage-baseline.js +34 -4
  112. package/.agents/scripts/update-duplication-baseline.js +209 -83
  113. package/.agents/scripts/validate-docs-freshness.js +1 -0
  114. package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
  115. package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
  116. package/.agents/skills/skills.index.json +2 -2
  117. package/.agents/workflows/audit-baselines.md +289 -0
  118. package/.agents/workflows/audit-navigability.md +5 -4
  119. package/.agents/workflows/deliver.md +13 -4
  120. package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
  121. package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
  122. package/.agents/workflows/helpers/deliver-digest.md +41 -21
  123. package/.agents/workflows/helpers/deliver-reference.md +77 -1
  124. package/.agents/workflows/helpers/deliver-story-reference.md +47 -6
  125. package/.agents/workflows/helpers/plan-reference.md +15 -5
  126. package/.agents/workflows/memory-consolidate.md +116 -0
  127. package/.agents/workflows/plan.md +3 -0
  128. package/README.md +13 -6
  129. package/docs/CHANGELOG.md +71 -0
  130. package/package.json +9 -4
  131. package/.agents/schemas/friction-event.schema.json +0 -56
  132. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
@@ -62,13 +62,71 @@ function parsePositiveInt(value) {
62
62
  return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
63
63
  }
64
64
 
65
+ /** The only two merge-watch postures `--merge-watch-mode` accepts. */
66
+ const MERGE_WATCH_MODES = ['sync', 'async'];
67
+
68
+ /**
69
+ * Parse `--merge-watch-mode` (Story #4949), the per-invocation override of
70
+ * `delivery.mergeWatch.mode`. Absence is preserved as `undefined` so the
71
+ * caller can distinguish "not supplied" (fall back to config) from an explicit
72
+ * posture — the same contract {@link parsePositiveInt} gives
73
+ * `--max-wait-seconds`.
74
+ *
75
+ * Unlike that sibling, an unrecognized value **throws** rather than degrading
76
+ * to absent. A `--max-wait-seconds` typo falls back to a sane bound; a
77
+ * `--merge-watch-mode` typo would fall back to `sync` and silently return a
78
+ * multi-Story run to a serialized foreground wait per close, with the wall
79
+ * clock as the only evidence. Parsing runs before the first close phase, so
80
+ * failing here costs no mutation.
81
+ *
82
+ * @param {unknown} value
83
+ * @returns {'sync'|'async'|undefined}
84
+ */
85
+ export function parseMergeWatchMode(value) {
86
+ if (value == null) return undefined;
87
+ const mode = String(value).trim().toLowerCase();
88
+ if (MERGE_WATCH_MODES.includes(mode)) return mode;
89
+ throw new Error(
90
+ `--merge-watch-mode must be one of ${MERGE_WATCH_MODES.join('|')} (got "${value}")`,
91
+ );
92
+ }
93
+
94
+ /**
95
+ * {@link parseMergeWatchMode} degraded to the "absent" value instead of
96
+ * throwing — how the tolerant parse below treats a flag that failed
97
+ * validation. Reporting `undefined` is safe there and only there, because a
98
+ * tolerant parse never drives a pipeline: its caller surfaces the rejection
99
+ * as the run's failure and runs no phase at all.
100
+ *
101
+ * @param {unknown} value
102
+ * @returns {'sync'|'async'|undefined}
103
+ */
104
+ function tolerantMergeWatchMode(value) {
105
+ try {
106
+ return parseMergeWatchMode(value);
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
65
112
  /**
66
113
  * Standardized CLI argument parser for sprint scripts.
67
114
  * Supports options like --epic, --story, --dry-run, --skip-dashboard.
115
+ *
116
+ * Throws when a *validating* parser rejects a flag value (currently only
117
+ * `--merge-watch-mode`). Callers that must not throw — an error handler
118
+ * needing `storyId` to report an envelope — use {@link parseSprintArgsTolerant}
119
+ * rather than calling this a second time inside their own catch.
120
+ *
68
121
  * @param {string[]} args Array of arguments (defaults to process.argv)
122
+ * @param {{ tolerant?: boolean }} [options] `tolerant` degrades a rejected
123
+ * flag to its absent value instead of throwing. For reporting only.
69
124
  * @returns {object} Parsed and typed argument values
70
125
  */
71
- export function parseSprintArgs(args = process.argv) {
126
+ export function parseSprintArgs(
127
+ args = process.argv,
128
+ { tolerant = false } = {},
129
+ ) {
72
130
  const { values, positionals } = parseArgs({
73
131
  args: args.slice(2),
74
132
  options: {
@@ -85,6 +143,10 @@ export function parseSprintArgs(args = process.argv) {
85
143
  // Story #4543 — per-run override of `delivery.mergeWatch.maxWaitSeconds`
86
144
  // (the merge wait's per-invocation bound). Absent means "use the config".
87
145
  'max-wait-seconds': { type: 'string' },
146
+ // Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
147
+ // Absent means "use the config"; see `parseMergeWatchMode` for why an
148
+ // unrecognized value fails closed instead of degrading to absent.
149
+ 'merge-watch-mode': { type: 'string' },
88
150
  executor: { type: 'string' },
89
151
  cwd: { type: 'string' },
90
152
  'recut-of': { type: 'string' },
@@ -115,6 +177,12 @@ export function parseSprintArgs(args = process.argv) {
115
177
  // headless caller with no host tool-invocation ceiling, so it lands in
116
178
  // one block instead of returning `pending` at the default 300s.
117
179
  maxWaitSeconds: parsePositiveInt(values['max-wait-seconds']),
180
+ // Story #4949 — per-invocation override of `delivery.mergeWatch.mode`.
181
+ // `undefined` when the flag is absent, which is what lets the merge wait
182
+ // fall back to the config; anything unrecognized throws here.
183
+ mergeWatchMode: tolerant
184
+ ? tolerantMergeWatchMode(values['merge-watch-mode'])
185
+ : parseMergeWatchMode(values['merge-watch-mode']),
118
186
  executor: values.executor ?? null,
119
187
  // Resolve worktree cwd from flag or env. Empty string/whitespace → null.
120
188
  cwd:
@@ -137,6 +205,52 @@ export function parseSprintArgs(args = process.argv) {
137
205
  return parsed;
138
206
  }
139
207
 
208
+ /**
209
+ * Last-resort tolerant parse: the fields, or an empty bag if even the
210
+ * tolerant pass cannot produce one.
211
+ *
212
+ * @param {string[]} args
213
+ * @returns {object}
214
+ */
215
+ function parseSprintArgsOrEmpty(args) {
216
+ try {
217
+ return parseSprintArgs(args, { tolerant: true });
218
+ } catch {
219
+ return {};
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Parse argv **without ever throwing**, returning the fields alongside the
225
+ * rejection rather than in place of it.
226
+ *
227
+ * `parseSprintArgs` gained its first *validating* parser in
228
+ * {@link parseMergeWatchMode} (Story #4949), which made a latent shape in the
229
+ * CLI entries fatal: their catch blocks called
230
+ * `failedTerminalFor(err, parseSprintArgs())` — re-invoking the very parser
231
+ * that had just thrown. The second throw escaped the catch, so an
232
+ * unparseable argv produced a bare stack trace with **no terminal envelope
233
+ * and no friction signal**, on the two surfaces whose whole contract is that
234
+ * they always emit one. An error handler must not depend on an operation
235
+ * already known to fail.
236
+ *
237
+ * So the entries parse **once**, up front, through this wrapper: `args`
238
+ * carries the `storyId` and skip flags the envelope is built from, and
239
+ * `error` is the failure to report. The tolerant re-parse degrades **only**
240
+ * the flag that failed validation; every other field parses normally. Use
241
+ * the result to *report*, never to run a pipeline.
242
+ *
243
+ * @param {string[]} [args] Array of arguments (defaults to `process.argv`)
244
+ * @returns {{ args: object, error: Error|null }}
245
+ */
246
+ export function parseSprintArgsTolerant(args = process.argv) {
247
+ try {
248
+ return { args: parseSprintArgs(args), error: null };
249
+ } catch (error) {
250
+ return { args: parseSprintArgsOrEmpty(args), error };
251
+ }
252
+ }
253
+
140
254
  const SUPPORTED_FLAG_TYPES = new Set([
141
255
  'boolean',
142
256
  'ticket',
@@ -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
- failed.push({ gate, status: 1, cwd: spawnCwd });
324
- log(
385
+ failGate(
386
+ gate,
387
+ 1,
325
388
  `[close-validation] ✖ ${gate.name} failed to resolve changed-file scope: ${err?.message ?? err}`,
326
389
  );
327
- if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
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
- failed.push({ gate, status: result.status, cwd: spawnCwd });
353
- log(
414
+ failGate(
415
+ gate,
416
+ result.status,
354
417
  `[close-validation] ✖ ${gate.name} failed (exit ${result.status}) in ${spawnCwd}`,
355
418
  );
356
- if (gate.hint) log(`[close-validation] hint: ${gate.hint}`);
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
- method: m.name,
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
- * `resolvedMethods` / `totalMethods` count the *join*, not the fill: a
136
- * method scored 0% because its coverage was unresolved counts as
137
- * unresolved. That is what makes them usable as a health signal for the
138
- * updater's fail-closed resolution-rate floor.
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
- if (unresolved && (requireCoverage || !coverageAvailable)) {
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
- method: mr.method,
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,