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.
- package/.agents/agents/plan-critic.md +13 -18
- package/.agents/agents/story-worker.md +25 -34
- package/.agents/docs/agentrc-reference.json +4 -30
- package/.agents/docs/configuration.md +11 -28
- package/.agents/docs/execution-reference.md +5 -5
- package/.agents/docs/quality-gates.md +8 -7
- package/.agents/instructions.md +9 -10
- package/.agents/rules/ci-remediation.md +39 -21
- package/.agents/schemas/agentrc.schema.json +28 -185
- package/.agents/schemas/story-deliver-terminal.schema.json +1 -1
- package/.agents/scripts/acceptance-eval.js +107 -17
- package/.agents/scripts/audit-to-stories.js +222 -75
- package/.agents/scripts/ceremony-derive.js +191 -0
- package/.agents/scripts/check-context-budget.js +28 -33
- package/.agents/scripts/check-cyclomatic.js +4 -3
- package/.agents/scripts/deliver-light.js +31 -94
- package/.agents/scripts/file-ci-gap.js +306 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +15 -2
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
- package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
- package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
- package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
- package/.agents/scripts/lib/baselines/coverage-updater-cli.js +110 -0
- package/.agents/scripts/lib/baselines/crap-preview-scan.js +25 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +223 -0
- package/.agents/scripts/lib/bdd-scenario-budget.js +21 -3
- package/.agents/scripts/lib/bootstrap/quality-bootstrap.js +0 -1
- package/.agents/scripts/lib/close-validation/gates.js +52 -1
- package/.agents/scripts/lib/config/acceptance-eval.js +25 -57
- package/.agents/scripts/lib/config/delivery-routing.js +7 -33
- package/.agents/scripts/lib/config/explain.js +0 -19
- package/.agents/scripts/lib/config/limits.js +18 -78
- package/.agents/scripts/lib/config/quality.js +6 -3
- package/.agents/scripts/lib/config/runners.js +3 -2
- package/.agents/scripts/lib/config-settings-schema-delivery.js +15 -68
- package/.agents/scripts/lib/config-settings-schema-quality.js +0 -14
- package/.agents/scripts/lib/config-settings-schema.js +49 -143
- package/.agents/scripts/lib/crap-engine.js +35 -4
- package/.agents/scripts/lib/crap-utils.js +17 -1
- package/.agents/scripts/lib/cyclomatic-ceiling.js +19 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
- package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
- package/.agents/scripts/lib/findings/route-finding.js +38 -0
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/github/framework-repo.js +148 -2
- package/.agents/scripts/lib/label-constants.js +6 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +2 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +5 -4
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +19 -73
- package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
- package/.agents/scripts/lib/orchestration/complexity-gate.js +46 -212
- package/.agents/scripts/lib/orchestration/file-assumptions.js +32 -17
- package/.agents/scripts/lib/orchestration/light-escalation.js +3 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +66 -233
- package/.agents/scripts/lib/orchestration/plan-context.js +181 -387
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +42 -153
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +14 -70
- package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
- package/.agents/scripts/lib/orchestration/plan-persist/changes-repair.js +300 -0
- package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +131 -168
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +133 -299
- package/.agents/scripts/lib/orchestration/plan-persist/soft-findings.js +55 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -65
- package/.agents/scripts/lib/orchestration/plan-persist/wave-serialisation.js +22 -35
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +30 -139
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +61 -223
- package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pre-gate-steps.js +46 -16
- package/.agents/scripts/lib/orchestration/story-close/context-budget-writeback.js +213 -0
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
- package/.agents/scripts/lib/orchestration/task-body-validator.js +10 -63
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +33 -539
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +21 -414
- package/.agents/scripts/lib/orchestration/ticket-validator.js +54 -118
- package/.agents/scripts/lib/orchestration/verify-credit.js +69 -24
- package/.agents/scripts/lib/story-body/body-format-lints.js +15 -85
- package/.agents/scripts/lib/story-body/story-body.js +17 -237
- package/.agents/scripts/lib/templates/decomposer-prompts.js +84 -121
- package/.agents/scripts/lib/test-isolate/cli-options.js +93 -0
- package/.agents/scripts/lib/test-isolate/progress-log.js +45 -0
- package/.agents/scripts/lib/test-isolate/render-report.js +97 -0
- package/.agents/scripts/lib/test-isolate/run-isolate.js +87 -0
- package/.agents/scripts/lib/test-run-credit.js +266 -0
- package/.agents/scripts/lib/wave-runner/footprint.js +48 -358
- package/.agents/scripts/lib/wave-runner/ready-set.js +6 -5
- package/.agents/scripts/lib/workers/crap-worker.js +32 -41
- package/.agents/scripts/plan-context.js +7 -9
- package/.agents/scripts/plan-critics.js +28 -54
- package/.agents/scripts/plan-persist.js +25 -68
- package/.agents/scripts/pr-watch-with-update.js +3 -2
- package/.agents/scripts/quality-preview.js +51 -0
- package/.agents/scripts/run-tests.js +12 -0
- package/.agents/scripts/stories-wave-tick.js +23 -45
- package/.agents/scripts/test-isolate.js +13 -180
- package/.agents/scripts/update-coverage-baseline.js +25 -70
- package/.agents/scripts/update-crap-baseline.js +19 -123
- package/.agents/skills/core/scope-triage/SKILL.md +3 -3
- package/.agents/workflows/audit-clean-code.md +4 -3
- package/.agents/workflows/audit-to-stories.md +63 -27
- package/.agents/workflows/helpers/acceptance-self-eval.md +41 -41
- package/.agents/workflows/helpers/code-quality-guardrails.md +4 -4
- package/.agents/workflows/helpers/code-review.md +2 -3
- package/.agents/workflows/helpers/deliver-digest.md +41 -57
- package/.agents/workflows/helpers/deliver-light.md +40 -105
- package/.agents/workflows/helpers/deliver-reference.md +1 -1
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -62
- package/.agents/workflows/helpers/deliver-story.md +9 -13
- package/.agents/workflows/helpers/plan-reference.md +132 -196
- package/.agents/workflows/mandrel-plan.md +28 -41
- package/.agents/workflows/memory-consolidate.md +9 -13
- package/docs/CHANGELOG.md +33 -0
- package/lib/migrations/index.js +4 -0
- package/lib/migrations/steps/2.57.0-retire-delivery-limit-knobs.js +45 -0
- package/lib/migrations/steps/2.57.0-retire-planning-limit-knobs.js +59 -0
- package/package.json +1 -1
- package/.agents/scripts/lib/framework-version.js +0 -39
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +0 -223
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +0 -97
- package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +0 -26
- package/.agents/scripts/lib/orchestration/spec-budget.js +0 -89
- package/.agents/scripts/lib/orchestration/spec-spill.js +0 -74
- 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.
|