mandrel 2.55.0 → 2.57.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 (131) hide show
  1. package/.agents/agents/plan-critic.md +13 -18
  2. package/.agents/agents/story-worker.md +25 -34
  3. package/.agents/docs/agentrc-reference.json +4 -30
  4. package/.agents/docs/configuration.md +11 -28
  5. package/.agents/docs/execution-reference.md +5 -5
  6. package/.agents/docs/quality-gates.md +8 -7
  7. package/.agents/instructions.md +9 -10
  8. package/.agents/rules/ci-remediation.md +39 -21
  9. package/.agents/schemas/agentrc.schema.json +28 -185
  10. package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
  11. package/.agents/scripts/acceptance-eval.js +107 -17
  12. package/.agents/scripts/audit-to-stories.js +222 -75
  13. package/.agents/scripts/ceremony-derive.js +191 -0
  14. package/.agents/scripts/check-context-budget.js +28 -33
  15. package/.agents/scripts/check-cyclomatic.js +4 -3
  16. package/.agents/scripts/deliver-light.js +31 -94
  17. package/.agents/scripts/file-ci-gap.js +306 -0
  18. package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
  19. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  20. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  21. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  22. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  23. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  24. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  25. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  26. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  27. package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
  28. package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
  29. package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
  30. package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
  31. package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
  32. package/.agents/scripts/lib/close-validation/gates.js +52 -1
  33. package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
  34. package/.agents/scripts/lib/config/delivery-routing.js +7 -33
  35. package/.agents/scripts/lib/config/explain.js +0 -19
  36. package/.agents/scripts/lib/config/limits.js +18 -78
  37. package/.agents/scripts/lib/config/quality.js +6 -3
  38. package/.agents/scripts/lib/config/runners.js +3 -2
  39. package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
  40. package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
  41. package/.agents/scripts/lib/config-settings-schema.js +49 -143
  42. package/.agents/scripts/lib/crap-engine.js +35 -4
  43. package/.agents/scripts/lib/crap-utils.js +17 -1
  44. package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
  45. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  46. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  47. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  48. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  49. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  50. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  51. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  52. package/.agents/scripts/lib/label-constants.js +6 -1
  53. package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
  54. package/.agents/scripts/lib/observability/source-classifier.js +2 -0
  55. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
  56. package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
  57. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  58. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  59. package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
  60. package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
  61. package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
  62. package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
  63. package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
  64. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
  65. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
  66. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  67. package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
  68. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
  69. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
  70. package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
  71. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
  72. package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
  73. package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
  74. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
  75. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  76. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
  77. package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
  78. package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
  79. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  80. package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
  81. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
  82. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
  83. package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
  84. package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
  85. package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
  86. package/.agents/scripts/lib/story-body/story-body.js +17 -237
  87. package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
  88. package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
  89. package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
  90. package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
  91. package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
  92. package/.agents/scripts/lib/test-run-credit.js +266 -0
  93. package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
  94. package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
  95. package/.agents/scripts/lib/workers/crap-worker.js +32 -41
  96. package/.agents/scripts/plan-context.js +7 -9
  97. package/.agents/scripts/plan-critics.js +28 -54
  98. package/.agents/scripts/plan-persist.js +25 -68
  99. package/.agents/scripts/pr-watch-with-update.js +3 -2
  100. package/.agents/scripts/quality-preview.js +51 -0
  101. package/.agents/scripts/run-tests.js +12 -0
  102. package/.agents/scripts/stories-wave-tick.js +23 -45
  103. package/.agents/scripts/test-isolate.js +13 -180
  104. package/.agents/scripts/update-coverage-baseline.js +25 -70
  105. package/.agents/scripts/update-crap-baseline.js +19 -123
  106. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  107. package/.agents/workflows/audit-clean-code.md +4 -3
  108. package/.agents/workflows/audit-to-stories.md +63 -27
  109. package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
  110. package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
  111. package/.agents/workflows/helpers/code-review.md +2 -3
  112. package/.agents/workflows/helpers/deliver-digest.md +41 -57
  113. package/.agents/workflows/helpers/deliver-light.md +40 -105
  114. package/.agents/workflows/helpers/deliver-reference.md +1 -1
  115. package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
  116. package/.agents/workflows/helpers/deliver-story.md +9 -13
  117. package/.agents/workflows/helpers/plan-reference.md +132 -196
  118. package/.agents/workflows/mandrel-plan.md +28 -41
  119. package/.agents/workflows/memory-consolidate.md +9 -13
  120. package/docs/CHANGELOG.md +33 -0
  121. package/lib/migrations/index.js +4 -0
  122. package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
  123. package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
  124. package/package.json +1 -1
  125. package/.agents/scripts/lib/framework-version.js +0 -39
  126. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
  127. package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
  128. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
  129. package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
  130. package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
  131. package/.agents/scripts/lib/orchestration/verify-tier-repair.js +0 -107
@@ -0,0 +1,162 @@
1
+ /**
2
+ * lib/audit-to-stories/issue-corpus.js — where the dedup corpus comes from,
3
+ * and how a corpus that could not be fetched is described to the operator.
4
+ *
5
+ * Dedup needs exactly one thing from GitHub: the Issues carrying an `audit::*`
6
+ * label. Until Story #5301 the only source was the provider's list port, which
7
+ * spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud sandbox,
8
+ * where `gh` is absent and direct API access is disabled while the GitHub MCP
9
+ * tools work fine — could not dedup at all: every group classified `create`
10
+ * and a scheduled sweep re-filed what it had already filed.
11
+ *
12
+ * Sourcing lives here rather than in `dedupe-against-github.js` so that module
13
+ * stays what its own header claims — pure routing of findings to verdicts —
14
+ * and so the empty-corpus and failed-fetch cases cannot diverge between call
15
+ * sites. Nothing here reaches the network or the filesystem: a caller that
16
+ * already holds the corpus passes the array in.
17
+ */
18
+
19
+ import { auditLabelsForFindings } from './audit-lenses.js';
20
+ import { buildIssueIndex } from './issue-index.js';
21
+
22
+ /**
23
+ * Resolve the dedup corpus into an index, from whichever source is wired.
24
+ *
25
+ * A corpus the caller already holds (`issues`) wins: the host fetched it by
26
+ * whatever GitHub access path it has, which is what lets dedup run where there
27
+ * is no `gh` CLI. Otherwise the run's `audit::*` Issues are pre-fetched off the
28
+ * list port, once.
29
+ *
30
+ * Two results deliberately do NOT collapse to "no index", because a null index
31
+ * silently returns the run to the per-finding search — which on a
32
+ * provider-less host is no dedup at all, the failure this path exists to kill.
33
+ * An **empty** injected corpus is a legitimate first sweep and yields a real
34
+ * zero-row index. A **failed** pre-fetch hands back its `error` so the caller
35
+ * can say so in its own words: an operator who cannot see that the pre-fetch
36
+ * failed cannot tell a checked plan from an unchecked one.
37
+ *
38
+ * Module-internal: every caller reaches it through `prepareDedupRouting`, so
39
+ * the empty-corpus and failed-fetch cases cannot diverge between call sites.
40
+ *
41
+ * @param {{ listAuditIssues?: Function, groups?: Array<object>,
42
+ * issues?: Array<object> }} params
43
+ * @returns {Promise<{ index: object|null, source: 'injected'|'prefetch'|'none',
44
+ * error?: unknown }>}
45
+ */
46
+ async function resolveIssueCorpus({ listAuditIssues, groups, issues }) {
47
+ if (Array.isArray(issues)) {
48
+ return { index: buildIssueIndex(issues), source: 'injected' };
49
+ }
50
+ const labels =
51
+ typeof listAuditIssues === 'function'
52
+ ? auditLabelsForFindings(
53
+ (groups ?? []).flatMap((group) => group?.findings ?? []),
54
+ )
55
+ : [];
56
+ if (labels.length === 0) return { index: null, source: 'none' };
57
+ try {
58
+ return {
59
+ index: buildIssueIndex(await listAuditIssues(labels)),
60
+ source: 'prefetch',
61
+ };
62
+ } catch (err) {
63
+ return { index: null, source: 'none', error: err };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Attach the index description to a seeded summary, when there is one to make.
69
+ *
70
+ * A run with neither an injected corpus nor a list port has no index to
71
+ * describe, and `{ source: 'none', size: 0 }` says nothing the field's absence
72
+ * does not — while inviting the reading "an index was consulted and it was
73
+ * empty", the exact confusion this whole path exists to remove. Omitting it
74
+ * also leaves the summary a pure per-finding-search run emits byte-identical
75
+ * to what it has always been.
76
+ *
77
+ * A failed pre-fetch is the one `none` that IS described: there the run ended
78
+ * *without* an index it expected to have, and the operator needs to see that.
79
+ *
80
+ * @param {object} summary — the seeded counters.
81
+ * @param {{ source: string, index: object|null, error?: unknown }} resolution
82
+ * @returns {object} the same summary, with `dedupIndex` when applicable.
83
+ */
84
+ function withIndexDescription(summary, { source, index, error }) {
85
+ if (source === 'none' && !error) return summary;
86
+ return { ...summary, dedupIndex: { source, size: index?.size ?? 0 } };
87
+ }
88
+
89
+ /**
90
+ * Assemble everything routing needs from the caller's ports and corpus: the
91
+ * read ports, the resolved index, and the two facts the caller must report —
92
+ * what the corpus was and whether fetching it degraded.
93
+ *
94
+ * The provider port is validated here because this is where "is there a usable
95
+ * dedup source at all" is actually known. It is required only on the
96
+ * un-indexed path: once an index exists every exact lookup is answered from
97
+ * memory and `findIssuesByFingerprint` is never called, so demanding it there
98
+ * would be the one thing standing between a `gh`-less host and a real dedup
99
+ * run.
100
+ *
101
+ * @param {{ groups?: Array<object>, provider?: object,
102
+ * searchCandidates?: Function, listAuditIssues?: Function,
103
+ * issues?: Array<object> }} params
104
+ * The seeded `summary` comes back with it: the corpus is the only thing that
105
+ * knows what the index was, and returning the counters beside it keeps the
106
+ * caller from reconstructing a shape it does not own.
107
+ *
108
+ * @returns {Promise<{ routing: object, summary: object, error?: unknown }>}
109
+ * @throws {Error} when neither a provider read port nor a corpus is supplied.
110
+ */
111
+ export async function prepareDedupRouting({
112
+ groups,
113
+ provider,
114
+ searchCandidates,
115
+ listAuditIssues,
116
+ issues,
117
+ }) {
118
+ const hasProviderPort =
119
+ Boolean(provider) && typeof provider.findIssuesByFingerprint === 'function';
120
+ if (!hasProviderPort && !Array.isArray(issues)) {
121
+ throw new Error(
122
+ 'classifyGroupsAgainstGitHub: provider.findIssuesByFingerprint is required ' +
123
+ 'when no `issues` corpus is supplied',
124
+ );
125
+ }
126
+ const { index, source, error } = await resolveIssueCorpus({
127
+ listAuditIssues,
128
+ groups,
129
+ issues,
130
+ });
131
+ const semanticPort =
132
+ typeof searchCandidates === 'function' ? searchCandidates : undefined;
133
+ return {
134
+ routing: {
135
+ // routeFinding hands the port the sha it computed off the canonical
136
+ // projection, which equals the sha the group already carries.
137
+ searchIssues: hasProviderPort
138
+ ? (sha) => provider.findIssuesByFingerprint(sha)
139
+ : undefined,
140
+ semanticPort,
141
+ // An index carries the semantic-key map, so location-based confirmation
142
+ // costs nothing once one exists. Without this, confirmation would discard
143
+ // the `bySemanticKey` half of the pool the local lookup just built, and a
144
+ // provider-less run would be fingerprint-exact only — strictly weaker
145
+ // than the path it replaces.
146
+ routeOptions: {
147
+ semanticKeyConfirm: Boolean(semanticPort) || Boolean(index),
148
+ },
149
+ index,
150
+ },
151
+ summary: withIndexDescription(
152
+ {
153
+ create: 0,
154
+ skipOpen: 0,
155
+ skipReoccurring: 0,
156
+ dedupDegraded: { count: 0, groups: [] },
157
+ },
158
+ { source, index, error },
159
+ ),
160
+ error,
161
+ };
162
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * lib/audit-to-stories/issues-file.js — the issue corpus dedup checks against,
3
+ * and the one normaliser every source of it goes through.
4
+ *
5
+ * Dedup needs exactly one thing from GitHub: the list of Issues carrying an
6
+ * `audit::*` label. Until Story #5301 the only way to get it was the provider,
7
+ * which spawns `gh`, so a host without a `gh` CLI — a Claude Code cloud
8
+ * sandbox, where `gh` is absent and direct API access is disabled but the
9
+ * GitHub MCP tools work fine — could not dedup at all. Every group classified
10
+ * `create` and a scheduled sweep re-filed findings it had already filed.
11
+ *
12
+ * `--issues-file <path>` breaks that coupling: the host fetches the list by
13
+ * whatever access path it has and hands over a JSON array. Nothing here knows
14
+ * how it was fetched, so MCP, REST, and a cached dump are all equally valid.
15
+ *
16
+ * A file that cannot be read as an issue array is a **hard error**, never a
17
+ * degrade. Falling back would mean "dedup did not run" on precisely the
18
+ * invocation whose whole purpose is that it does — and a create-only plan the
19
+ * operator reads as checked is how the duplicate-filing loop starts.
20
+ */
21
+
22
+ import fs from 'node:fs';
23
+
24
+ /**
25
+ * Flatten one raw issue onto the `{ number, state, title, body }` shape the
26
+ * dedupe module reads, collapsing every closed-ish state spelling (`CLOSED`,
27
+ * `state_reason: not_planned`, …) onto `'closed'`.
28
+ *
29
+ * Shared by both corpus sources — the provider's `searchIssues` hits and the
30
+ * `--issues-file` array — so a host can hand over a raw `list_issues` result
31
+ * verbatim without knowing which spelling this repo's dedup expects.
32
+ *
33
+ * @param {object} hit
34
+ * @returns {{ number: number, state: 'open'|'closed', title: string, body: string }}
35
+ */
36
+ export function normaliseIssueHit(hit) {
37
+ return {
38
+ number: hit.number,
39
+ state: (hit.state ?? hit.state_reason ?? 'open')
40
+ .toString()
41
+ .toLowerCase()
42
+ .includes('closed')
43
+ ? 'closed'
44
+ : 'open',
45
+ title: hit.title ?? '',
46
+ body: hit.body ?? '',
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Read and normalise a host-supplied issue corpus.
52
+ *
53
+ * `state` is read from either `state` or `state_reason`, so a closed-as-
54
+ * not-planned issue is recognised however the host's API spelled it. Only
55
+ * `number` and `body` are load-bearing: the number identifies the match and
56
+ * the body carries the provenance footers dedup confirms identity against.
57
+ *
58
+ * @param {string} filePath — path to a JSON array of issues.
59
+ * @param {{ readFileSyncImpl?: typeof fs.readFileSync }} [seams]
60
+ * @returns {Array<{ number: number, state: string, title: string, body: string }>}
61
+ * @throws {Error} when the file is missing, unreadable, not JSON, or not an array.
62
+ */
63
+ export function loadIssuesFile(
64
+ filePath,
65
+ { readFileSyncImpl = fs.readFileSync } = {},
66
+ ) {
67
+ let raw;
68
+ try {
69
+ raw = readFileSyncImpl(filePath, 'utf8');
70
+ } catch (err) {
71
+ throw new Error(
72
+ `--issues-file: cannot read "${filePath}" (${err.message}). Dedup needs ` +
73
+ 'the issue corpus to check against; running without it would classify ' +
74
+ 'every group "create" and re-file findings already tracked.',
75
+ );
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ } catch (err) {
81
+ throw new Error(
82
+ `--issues-file: "${filePath}" is not valid JSON (${err.message}). ` +
83
+ 'Expected a JSON array of issues, e.g. the result of listing every ' +
84
+ 'issue labelled audit::* with state "all".',
85
+ );
86
+ }
87
+ if (!Array.isArray(parsed)) {
88
+ throw new Error(
89
+ `--issues-file: "${filePath}" holds ${describeShape(parsed)}, not a JSON ` +
90
+ 'array of issues. Pass the issue list itself, not the envelope wrapping it.',
91
+ );
92
+ }
93
+ return parsed.filter(isIssueLike).map(normaliseIssueHit);
94
+ }
95
+
96
+ /**
97
+ * Whether one array entry can possibly identify an issue. An entry without a
98
+ * numeric `number` can never confirm a match — exactly as it could not when it
99
+ * came back from a search — so it is dropped rather than indexed under nothing.
100
+ *
101
+ * @param {unknown} entry
102
+ * @returns {boolean}
103
+ */
104
+ function isIssueLike(entry) {
105
+ return (
106
+ Boolean(entry) &&
107
+ typeof entry === 'object' &&
108
+ typeof entry.number === 'number'
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Name what a non-array payload actually was, so the error points at the fix.
114
+ * @param {unknown} value
115
+ * @returns {string}
116
+ */
117
+ function describeShape(value) {
118
+ if (value === null) return 'null';
119
+ if (Array.isArray(value)) return 'an array';
120
+ return `a JSON ${typeof value}`;
121
+ }
@@ -28,9 +28,9 @@
28
28
  * complexity budget does not absorb a git driver.
29
29
  */
30
30
 
31
+ import { DEFAULT_LEDGER_PATH } from '../findings/audit-ledger.js';
31
32
  import { gh as defaultGh } from '../gh-exec.js';
32
33
  import { gitSync } from '../git-utils.js';
33
- import { DEFAULT_LEDGER_PATH } from './ledger.js';
34
34
  import { openLedgerPullRequest, probeGit } from './ledger-pr.js';
35
35
 
36
36
  /** Fallback base branch when config carries no `project.baseBranch`. */
@@ -0,0 +1,126 @@
1
+ /**
2
+ * lib/audit-to-stories/ledger-record.js — record what a run actually filed.
3
+ *
4
+ * The cross-run ledger can only suppress an already-filed finding if something
5
+ * tells it the finding was filed. Until Story #5305 nothing did: the reconcile
6
+ * ran *during* the scan, before any Issue existed, and its `issueStates` were
7
+ * derived from `matchedIssues` — the Issues dedup FOUND. A group classified
8
+ * `create` has none, so a freshly opened Issue contributed nothing and its
9
+ * entry persisted as `status: "new", issue: null` forever. On a host where
10
+ * GitHub-search dedup works that is invisible; on one where it cannot run,
11
+ * nothing suppresses anything and every sweep re-files everything.
12
+ *
13
+ * The missing input is the `groupKey → issueNumber` map, which exists only
14
+ * after the Issues are opened. `--wire-edges` already takes exactly that map
15
+ * and is already a required pass, which is why the record rides along with it
16
+ * rather than arriving as a second command an operator must remember.
17
+ *
18
+ * Nothing here reaches the network: the caller hands over the map it already
19
+ * holds, and the only I/O is the ledger read/write this module delegates to
20
+ * `ledger.js`. That is what lets the record run BEFORE the provider is loaded,
21
+ * so a host whose provider cannot be constructed still records what it filed.
22
+ */
23
+
24
+ import {
25
+ DEFAULT_LEDGER_PATH,
26
+ readLedger,
27
+ reconcileLedger,
28
+ writeLedger,
29
+ } from '../findings/audit-ledger.js';
30
+ import {
31
+ fingerprintAuditFinding,
32
+ toCanonicalFinding,
33
+ } from './finding-adapter.js';
34
+
35
+ /**
36
+ * Project the opened-issue map onto the `{ fingerprint → issueState }` shape
37
+ * `reconcileLedger` reads, and collect the findings those groups carry.
38
+ *
39
+ * Only groups present in the map contribute. A group the map does not mention
40
+ * was not opened — deduped, ledger-suppressed, or simply skipped — and
41
+ * inventing an Issue state for it is how a finding gets suppressed against an
42
+ * Issue that does not exist.
43
+ *
44
+ * Every recorded state is `open`: the map names Issues this run just created,
45
+ * and a just-created Issue is open. A later close is learned from the live
46
+ * lookup on the next run, where it outranks this record (`decideStatus` reads
47
+ * the closed-Issue branch first).
48
+ *
49
+ * @param {object} params
50
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
51
+ * @param {Record<string, number>} params.issueByGroupKey — group key → issue number.
52
+ * Module-internal: `recordFiledIssues` is the only production entrypoint, and
53
+ * exporting this for tests alone trips the CI-only `dead-exports:production`
54
+ * gate. It is covered through `recordFiledIssues`, which is the seam that
55
+ * actually ships.
56
+ *
57
+ * @returns {{ findings: Array<object>, issueStates: Record<string, { state: string, number: number }>, groupsRecorded: number }}
58
+ */
59
+ function issueStatesFromIssueMap({ groups, issueByGroupKey }) {
60
+ const findings = [];
61
+ const issueStates = {};
62
+ let groupsRecorded = 0;
63
+
64
+ for (const group of groups ?? []) {
65
+ const number = issueByGroupKey?.[group?.groupKey];
66
+ if (typeof number !== 'number') continue;
67
+ groupsRecorded += 1;
68
+ for (const finding of group.findings ?? []) {
69
+ findings.push(finding);
70
+ issueStates[fingerprintAuditFinding(finding).full] = {
71
+ state: 'open',
72
+ number,
73
+ };
74
+ }
75
+ }
76
+
77
+ return { findings, issueStates, groupsRecorded };
78
+ }
79
+
80
+ /**
81
+ * Fold the just-opened Issues onto the committed ledger.
82
+ *
83
+ * Only the mapped groups' findings are passed to `reconcileLedger`, so every
84
+ * other entry survives untouched — `reconcileLedger` copies the prior index
85
+ * forward and rewrites only what this scan hands it.
86
+ *
87
+ * @param {object} params
88
+ * @param {string} [params.ledgerPath]
89
+ * @param {Array<object>} params.groups — the `create`-eligible groups.
90
+ * @param {Record<string, number>} params.issueByGroupKey
91
+ * @param {boolean} [params.write=true] — `false` computes the record without
92
+ * persisting it, which is what `--dry-run` needs.
93
+ * @param {{ readLedgerImpl?: Function, writeLedgerImpl?: Function }} [seams]
94
+ * @returns {{ path: string, written: boolean, groupsRecorded: number, findingsRecorded: number, filed: number }}
95
+ */
96
+ export function recordFiledIssues(
97
+ { ledgerPath, groups, issueByGroupKey, write = true },
98
+ { readLedgerImpl = readLedger, writeLedgerImpl = writeLedger } = {},
99
+ ) {
100
+ const path = ledgerPath ?? DEFAULT_LEDGER_PATH;
101
+ const { findings, issueStates, groupsRecorded } = issueStatesFromIssueMap({
102
+ groups,
103
+ issueByGroupKey,
104
+ });
105
+
106
+ const { ledger: next, classifications } = reconcileLedger({
107
+ ledger: readLedgerImpl(path),
108
+ findings,
109
+ issueStates,
110
+ toCanonical: toCanonicalFinding,
111
+ });
112
+ // Nothing to record means nothing to write. The ledger is committed consumer
113
+ // state and `--ledger-commit` opens a PR only when it changed, so rewriting
114
+ // it with a fresh `generatedAt` and no new memory would manufacture a diff
115
+ // that says nothing.
116
+ const wrote = Boolean(write) && findings.length > 0;
117
+ if (wrote) writeLedgerImpl(path, next);
118
+
119
+ return {
120
+ path,
121
+ written: wrote,
122
+ groupsRecorded,
123
+ findingsRecorded: findings.length,
124
+ filed: classifications.filter((c) => c.status === 'filed').length,
125
+ };
126
+ }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { SEVERITIES } from '../findings/severity.js';
21
+ import { auditLabelFooterForFindings } from './audit-label-taxonomy.js';
21
22
  import { formatEpicGrouping } from './epic-grouping-directive.js';
22
23
  import {
23
24
  renderFingerprintFooter,
@@ -107,6 +108,16 @@ function formatMVPScope(groups) {
107
108
  const footers = [
108
109
  renderFingerprintFooter(findings),
109
110
  renderSemanticKeyFooter(findings),
111
+ // ...and the `audit::*` labels the dedup corpus is listed by.
112
+ //
113
+ // Without them a Story the chained planning path files is absent from
114
+ // the pool an indexed sweep matches against, and with an index in play
115
+ // the exact lookup is answered from that pool without ever reaching
116
+ // the provider — so the fingerprint footer above cannot rescue it. The
117
+ // two footers are therefore a pair: one carries the identity, the
118
+ // other carries the reason the next sweep ever looks at this issue
119
+ // (Story #5307).
120
+ auditLabelFooterForFindings(findings),
110
121
  ]
111
122
  .map((f) => ` ${f}`)
112
123
  .join('\n');
@@ -0,0 +1,110 @@
1
+ /**
2
+ * lib/baselines/coverage-updater-cli.js — the `update-coverage-baseline` CLI's
3
+ * scope-flag reconciliation and its bespoke scorer.
4
+ *
5
+ * Story #5316: both lived inside `update-coverage-baseline.js#main`, which no
6
+ * test imports, so `main` scored CRAP 30 and the inlined scorer another 30 —
7
+ * two of the ten methods Story #5311's honest re-anchor made visible, both at
8
+ * 0% coverage.
9
+ *
10
+ * **Why this is NOT the `refresh-service.js` default scorer.** Story #4293's
11
+ * idiom — drop the bespoke scorer, let `refreshBaseline` resolve the canonical
12
+ * default — was the obvious move here, and `buildDefaultCoverageScorer` is
13
+ * behaviour-equivalent line for line. It was rejected for one reason: the
14
+ * default is silent where this one speaks. A missing or unreadable coverage
15
+ * artifact makes any scorer return `[]`, and `refresh-service.js` has no
16
+ * empty-rows guard, so a full-scope refresh then writes an emptied baseline at
17
+ * exit 0. This scorer's `[Coverage] ❌` line is currently the only signal an
18
+ * operator gets that it happened. Converging would have traded a CRAP row for
19
+ * a quieter failure. (The fail-open itself is real and wants its own Story —
20
+ * it changes `refreshBaseline` semantics for every caller.)
21
+ */
22
+
23
+ import { Logger } from '../Logger.js';
24
+ import { parseDiffScopeFlag } from './diff-scope-cli.js';
25
+
26
+ /**
27
+ * Reconcile the two scope flags.
28
+ *
29
+ * Throws when both are present: they describe incompatible scopes, and
30
+ * silently preferring one would write a baseline the operator did not ask for.
31
+ *
32
+ * @param {string[]} [argv]
33
+ * @returns {{fullScope: boolean, diffScopeRef: string|null}}
34
+ */
35
+ export function resolveCoverageUpdaterScope(argv = []) {
36
+ const diffScopeRef = parseDiffScopeFlag(argv);
37
+ const fullScope = argv.includes('--full-scope');
38
+ if (fullScope && diffScopeRef !== null) {
39
+ throw new Error(
40
+ '[Coverage] --full-scope is incompatible with --diff-scope; pick one',
41
+ );
42
+ }
43
+ return { fullScope, diffScopeRef };
44
+ }
45
+
46
+ /**
47
+ * Build the scorer `refreshBaseline` invokes.
48
+ *
49
+ * Reads `coverage-final.json`, narrows to the c8 include/exclude scope so the
50
+ * baseline records exactly the files coverage is measured over, and — in diff
51
+ * mode — narrows again to the service-resolved in-scope list so untouched rows
52
+ * are preserved rather than re-scored.
53
+ *
54
+ * The three collaborators are named seams (`rules/test-seams.md`) so a test
55
+ * drives the whole scorer with no coverage artifact and no `.c8rc.cjs` on
56
+ * disk.
57
+ *
58
+ * @param {string} cwd
59
+ * @param {{readCoverage: Function, loadScope: Function, buildScope: Function,
60
+ * score: Function, logger?: object}} deps
61
+ * @returns {(files: string[], opts: object) => object[]}
62
+ */
63
+ export function buildCoverageUpdaterScorer(
64
+ cwd,
65
+ { readCoverage, loadScope, buildScope, score, logger = Logger } = {},
66
+ ) {
67
+ return (files, opts) => {
68
+ const effectiveCwd = opts?.cwd ?? cwd;
69
+ let raw;
70
+ try {
71
+ raw = readCoverage(effectiveCwd);
72
+ } catch (err) {
73
+ // The only operator-facing signal that the refresh is about to record
74
+ // nothing — see this module's header.
75
+ logger.error(`[Coverage] ❌ ${err.message}`);
76
+ return [];
77
+ }
78
+
79
+ const c8Config = loadScope(effectiveCwd);
80
+ const scores = score({
81
+ raw,
82
+ cwd: effectiveCwd,
83
+ scope: buildScope({
84
+ include: c8Config.include ?? [],
85
+ exclude: c8Config.exclude ?? [],
86
+ }),
87
+ });
88
+
89
+ // In diff mode, further narrow to the service-resolved in-scope file list.
90
+ const inScope =
91
+ !opts?.fullScope && Array.isArray(files) && files.length > 0
92
+ ? new Set(files)
93
+ : null;
94
+
95
+ const rows = Object.entries(scores)
96
+ .filter(([relPath]) => inScope === null || inScope.has(relPath))
97
+ .map(([relPath, s]) => ({
98
+ path: relPath,
99
+ lines: s?.lines ?? 0,
100
+ branches: s?.branches ?? 0,
101
+ functions: s?.functions ?? 0,
102
+ }));
103
+
104
+ const fileCount = Object.keys(scores).length;
105
+ logger.info(
106
+ `[Coverage] Scored ${fileCount} file(s)${inScope ? ` (${rows.length} in scope)` : ''}.`,
107
+ );
108
+ return rows;
109
+ };
110
+ }
@@ -16,6 +16,7 @@ import {
16
16
  resolveEscomplexVersion,
17
17
  scanAndScore,
18
18
  } from '../crap-utils.js';
19
+ import { CYCLOMATIC_CEILING } from '../cyclomatic-ceiling.js';
19
20
  import { resolveCrapPreviewIncremental } from './crap-preview-incremental.js';
20
21
  import { resolveCrapEnvOverrides } from './env-overrides.js';
21
22
  import {
@@ -66,6 +67,26 @@ function hasCrapRegressions(result) {
66
67
  * }} opts
67
68
  * @returns {Promise<{ exitCode: number, envelope: object }>}
68
69
  */
70
+ /**
71
+ * The scanned methods at or over the fixed cyclomatic ceiling (Story #5313),
72
+ * as advisories: `quality-preview.js` lists them and exits 0 on them, so the
73
+ * reading reaches the author without the preview ever refusing a commit on
74
+ * complexity alone. Pure.
75
+ *
76
+ * @param {Array<{ file: string, method: string, startLine: number, cyclomatic: number }>} rows
77
+ * @returns {Array<{ file: string, method: string, startLine: number, cyclomatic: number }>}
78
+ */
79
+ export function listCyclomaticAdvisories(rows) {
80
+ return (Array.isArray(rows) ? rows : [])
81
+ .filter((r) => Number(r?.cyclomatic) >= CYCLOMATIC_CEILING)
82
+ .map(({ file, method, startLine, cyclomatic }) => ({
83
+ file,
84
+ method,
85
+ startLine,
86
+ cyclomatic,
87
+ }));
88
+ }
89
+
69
90
  export async function computeCrapPreviewScan({
70
91
  crap,
71
92
  cwd,
@@ -120,6 +141,10 @@ export async function computeCrapPreviewScan({
120
141
  newMethodCeiling,
121
142
  scopeInfo: { scope, diffRef },
122
143
  });
144
+ // Story #5313: a method at or over the cyclomatic ceiling is an ADVISORY
145
+ // on the preview — reported, never a verdict. The ratchet in
146
+ // `check-cyclomatic.js` owns enforcement.
147
+ envelope.cyclomaticAdvisories = listCyclomaticAdvisories(scan.rows);
123
148
  // Story #4866 (AC-5): above the drifted-row ratio the basis is self-
124
149
  // evidently unsound and every per-method verdict below it is an artefact of
125
150
  // a mis-keyed join. Say so once, by name, and fail open.