mandrel 2.24.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -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(
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -214,12 +214,17 @@ const ROUTING_SCHEMA = {
|
|
|
214
214
|
// the merge/CI watch poll loop; `autoMerge` selects the merge posture.
|
|
215
215
|
// Retired: `earlyPr` (Epic early-PR warmup) and `requireChecks` (no
|
|
216
216
|
// AutomergePredicate reader on v2).
|
|
217
|
+
// Story #4890 added `attachWindowMs`: how long the watch keeps re-resolving an
|
|
218
|
+
// EMPTY required-check set before it stops waiting for one. This block is
|
|
219
|
+
// `additionalProperties: false`, so the knob is inert unless it lands here AND
|
|
220
|
+
// in the `.agents/schemas/agentrc.schema.json` mirror.
|
|
217
221
|
const CI_WATCH_SCHEMA = {
|
|
218
222
|
type: 'object',
|
|
219
223
|
properties: {
|
|
220
224
|
pollIntervalMs: { type: 'integer', minimum: 1 },
|
|
221
225
|
maxPolls: { type: 'integer', minimum: 1 },
|
|
222
226
|
maxResumes: { type: 'integer', minimum: 0 },
|
|
227
|
+
attachWindowMs: { type: 'integer', minimum: 1 },
|
|
223
228
|
},
|
|
224
229
|
additionalProperties: false,
|
|
225
230
|
};
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import escomplex from 'typhonjs-escomplex';
|
|
2
2
|
import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
3
|
+
import { deriveMethodIdentities } from './crap-method-identity.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The two line coordinate systems a CRAP row's `startLine` can be expressed
|
|
7
|
+
* in (Story #4866).
|
|
8
|
+
*
|
|
9
|
+
* `original` — the coordinates of the file a reader can open, and the ones
|
|
10
|
+
* istanbul's `fnMap` is keyed against. A JavaScript source is already in this
|
|
11
|
+
* system; a TS/TSX source reaches it only through a successful sourcemap
|
|
12
|
+
* lookup.
|
|
13
|
+
*
|
|
14
|
+
* `transpiled` — escomplex's own coordinates over the emitted JavaScript,
|
|
15
|
+
* kept only when the sourcemap has no entry originating on the method's
|
|
16
|
+
* generated line. Such a row is NOT an original-source coordinate and must
|
|
17
|
+
* never be presented as one: it cannot be joined to coverage, and it cannot
|
|
18
|
+
* be compared against a baseline row carrying the other provenance.
|
|
19
|
+
*/
|
|
20
|
+
export const COORDINATE_ORIGINAL = 'original';
|
|
21
|
+
export const COORDINATE_TRANSPILED = 'transpiled';
|
|
3
22
|
|
|
4
23
|
/**
|
|
5
24
|
* Derive the raw per-method CRAP rows from an escomplex report.
|
|
@@ -9,41 +28,86 @@ import { coverageForMethodInEntry } from './coverage-utils.js';
|
|
|
9
28
|
* method's line is remapped or its coverage joined — the parity the
|
|
10
29
|
* combined-parity suite asserts.
|
|
11
30
|
*
|
|
12
|
-
* **Coordinates (Story #4775).** `mapLine`
|
|
13
|
-
* `lineStart` — which is in *transpiled* coordinates
|
|
14
|
-
* into the *original source* coordinates istanbul's
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
31
|
+
* **Coordinates (Story #4775, corrected by Story #4866).** `mapLine`
|
|
32
|
+
* translates escomplex's `lineStart` — which is in *transpiled* coordinates
|
|
33
|
+
* for a TS/TSX source — into the *original source* coordinates istanbul's
|
|
34
|
+
* `fnMap` uses. A `null` mapper means the two coordinate systems already
|
|
35
|
+
* coincide (plain JavaScript).
|
|
36
|
+
*
|
|
37
|
+
* #4775 let an unresolvable line fall back to the un-remapped value, which
|
|
38
|
+
* made one scan emit rows in two coordinate systems with nothing on the row
|
|
39
|
+
* saying which. Each row now records its own `coordinateSystem`, and an
|
|
40
|
+
* un-remapped row joins no coverage at all: joining a transpiled line against
|
|
41
|
+
* an original-source `fnMap` does not merely miss, it can land inside an
|
|
42
|
+
* unrelated function's range and mis-attribute that function's coverage. An
|
|
43
|
+
* honest `coverage: null` lets the scanner's `requireCoverage` policy decide,
|
|
44
|
+
* exactly as it does for a genuinely uninstrumented method.
|
|
21
45
|
*
|
|
22
46
|
* @param {object|null} report An `escomplex.analyzeModule` report.
|
|
23
47
|
* @param {object|null} coverageForFile Istanbul coverage entry for this file.
|
|
24
48
|
* @param {((line: number) => number|null)|null} [mapLine]
|
|
25
49
|
* @returns {Array<{
|
|
26
50
|
* method: string,
|
|
51
|
+
* anonymous: boolean,
|
|
27
52
|
* startLine: number,
|
|
28
53
|
* cyclomatic: number,
|
|
29
54
|
* coverage: number|null,
|
|
30
55
|
* crap: number|null,
|
|
56
|
+
* coordinateSystem: 'original'|'transpiled',
|
|
31
57
|
* }>}
|
|
32
58
|
*/
|
|
59
|
+
/**
|
|
60
|
+
* Resolve one method's line into a coordinate and the provenance of that
|
|
61
|
+
* coordinate — the single place the rule lives.
|
|
62
|
+
*
|
|
63
|
+
* No mapper means the source was never transpiled, so its line already IS an
|
|
64
|
+
* original-source coordinate. A mapper that resolves yields one. A mapper
|
|
65
|
+
* that does not leaves the transpiled line, said so.
|
|
66
|
+
*
|
|
67
|
+
* @param {number} rawStartLine escomplex's `lineStart`.
|
|
68
|
+
* @param {((line: number) => number|null)|null} mapLine
|
|
69
|
+
* @returns {{startLine: number, coordinateSystem: 'original'|'transpiled'}}
|
|
70
|
+
*/
|
|
71
|
+
function resolveCoordinate(rawStartLine, mapLine) {
|
|
72
|
+
if (typeof mapLine !== 'function') {
|
|
73
|
+
return { startLine: rawStartLine, coordinateSystem: COORDINATE_ORIGINAL };
|
|
74
|
+
}
|
|
75
|
+
const mapped = mapLine(rawStartLine);
|
|
76
|
+
return typeof mapped === 'number'
|
|
77
|
+
? { startLine: mapped, coordinateSystem: COORDINATE_ORIGINAL }
|
|
78
|
+
: { startLine: rawStartLine, coordinateSystem: COORDINATE_TRANSPILED };
|
|
79
|
+
}
|
|
80
|
+
|
|
33
81
|
export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
34
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);
|
|
35
89
|
const rows = [];
|
|
36
|
-
for (const m of methods) {
|
|
90
|
+
for (const [i, m] of methods.entries()) {
|
|
37
91
|
const rawStartLine = m?.lineStart;
|
|
38
92
|
if (typeof rawStartLine !== 'number') continue;
|
|
39
|
-
const
|
|
40
|
-
|
|
93
|
+
const { startLine, coordinateSystem } = resolveCoordinate(
|
|
94
|
+
rawStartLine,
|
|
95
|
+
mapLine,
|
|
96
|
+
);
|
|
41
97
|
const cyclomatic = m?.cyclomatic ?? 0;
|
|
42
|
-
const coverage =
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
const coverage =
|
|
99
|
+
coverageForFile && coordinateSystem === COORDINATE_ORIGINAL
|
|
100
|
+
? coverageForMethodInEntry(coverageForFile, startLine)
|
|
101
|
+
: null;
|
|
45
102
|
const crap = coverage === null ? null : crapFormula(cyclomatic, coverage);
|
|
46
|
-
rows.push({
|
|
103
|
+
rows.push({
|
|
104
|
+
...identities[i],
|
|
105
|
+
startLine,
|
|
106
|
+
cyclomatic,
|
|
107
|
+
coverage,
|
|
108
|
+
crap,
|
|
109
|
+
coordinateSystem,
|
|
110
|
+
});
|
|
47
111
|
}
|
|
48
112
|
return rows;
|
|
49
113
|
}
|
|
@@ -63,13 +127,32 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
63
127
|
* dropped, which made it a no-op for baseline population — the caller
|
|
64
128
|
* asked for "score it anyway" and got silence.
|
|
65
129
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
130
|
+
* **Absent coverage is not zero coverage (Story #4871).** The 0%-covered
|
|
131
|
+
* inference above is only defensible when a coverage **artifact** exists and
|
|
132
|
+
* simply reports nothing for this method — "the tests ran and never reached
|
|
133
|
+
* it" is a measurement. When no artifact was produced at all — a freshly
|
|
134
|
+
* initialized story worktree, whose `coverage/` directory does not exist —
|
|
135
|
+
* nothing was measured, and filling the gap with 0% manufactures a maximal
|
|
136
|
+
* CRAP out of an absent observation, failing the first commit on files the
|
|
137
|
+
* change never touched. `coverageAvailable: false` therefore makes such a
|
|
138
|
+
* method **unscorable** under either policy: skipped and counted, never
|
|
139
|
+
* filled. It is deliberately keyed on the artifact, not the per-file entry,
|
|
140
|
+
* so `requireCoverage: false` keeps meaning "score untested code" whenever a
|
|
141
|
+
* real coverage run stands behind the verdict.
|
|
142
|
+
*
|
|
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.
|
|
70
153
|
*
|
|
71
154
|
* @param {Array<object>} rawRows Rows from `methodRowsFromReport`.
|
|
72
|
-
* @param {{requireCoverage?: boolean}} [opts]
|
|
155
|
+
* @param {{requireCoverage?: boolean, coverageAvailable?: boolean}} [opts]
|
|
73
156
|
* @returns {{
|
|
74
157
|
* rows: Array<object>,
|
|
75
158
|
* skippedMethodsNoCoverage: number,
|
|
@@ -77,7 +160,10 @@ export function methodRowsFromReport(report, coverageForFile, mapLine = null) {
|
|
|
77
160
|
* totalMethods: number,
|
|
78
161
|
* }}
|
|
79
162
|
*/
|
|
80
|
-
export function finalizeMethodRows(
|
|
163
|
+
export function finalizeMethodRows(
|
|
164
|
+
rawRows,
|
|
165
|
+
{ requireCoverage = true, coverageAvailable = true } = {},
|
|
166
|
+
) {
|
|
81
167
|
const rows = [];
|
|
82
168
|
let skippedMethodsNoCoverage = 0;
|
|
83
169
|
let resolvedMethods = 0;
|
|
@@ -86,18 +172,26 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
86
172
|
totalMethods += 1;
|
|
87
173
|
const unresolved = mr.crap === null || mr.coverage === null;
|
|
88
174
|
if (!unresolved) resolvedMethods += 1;
|
|
89
|
-
|
|
175
|
+
// Unjoinable is not untested (Story #4901) — see the block comment above.
|
|
176
|
+
if (
|
|
177
|
+
mr.coordinateSystem === COORDINATE_TRANSPILED ||
|
|
178
|
+
(unresolved && (requireCoverage || !coverageAvailable))
|
|
179
|
+
) {
|
|
90
180
|
skippedMethodsNoCoverage += 1;
|
|
91
181
|
continue;
|
|
92
182
|
}
|
|
93
183
|
const coverage = unresolved ? 0 : mr.coverage;
|
|
94
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.
|
|
95
190
|
rows.push({
|
|
96
|
-
|
|
97
|
-
startLine: mr.startLine,
|
|
98
|
-
cyclomatic: mr.cyclomatic,
|
|
191
|
+
...mr,
|
|
99
192
|
coverage,
|
|
100
193
|
crap,
|
|
194
|
+
coordinateSystem: mr.coordinateSystem ?? COORDINATE_ORIGINAL,
|
|
101
195
|
});
|
|
102
196
|
}
|
|
103
197
|
return { rows, skippedMethodsNoCoverage, resolvedMethods, totalMethods };
|
|
@@ -111,7 +205,8 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
111
205
|
* Kernel contract:
|
|
112
206
|
* - Pure (no I/O, no AST parse beyond the one delegated to escomplex via
|
|
113
207
|
* `analyzeModule`).
|
|
114
|
-
* - Methods whose coverage cannot be resolved from `coverageForFile`
|
|
208
|
+
* - Methods whose coverage cannot be resolved from `coverageForFile` —
|
|
209
|
+
* including every method whose line stayed in transpiled coordinates —
|
|
115
210
|
* produce `coverage: null` and `crap: null`. Callers apply their own
|
|
116
211
|
* `requireCoverage` policy at the scanner level (`finalizeMethodRows`);
|
|
117
212
|
* this kernel never decides to skip.
|
|
@@ -126,10 +221,12 @@ export function finalizeMethodRows(rawRows, { requireCoverage = true } = {}) {
|
|
|
126
221
|
* original line resolver; see `methodRowsFromReport`.
|
|
127
222
|
* @returns {Array<{
|
|
128
223
|
* method: string,
|
|
224
|
+
* anonymous: boolean,
|
|
129
225
|
* startLine: number,
|
|
130
226
|
* cyclomatic: number,
|
|
131
227
|
* coverage: number|null,
|
|
132
228
|
* crap: number|null,
|
|
229
|
+
* coordinateSystem: 'original'|'transpiled',
|
|
133
230
|
* }>}
|
|
134
231
|
*/
|
|
135
232
|
export function calculateCrapForSource(
|
|
@@ -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
|
+
}
|