mandrel 2.20.0 → 2.22.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/README.md +1 -1
- package/.agents/agents/story-worker.md +15 -0
- package/.agents/instructions.md +14 -17
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/rules/known-tooling-behavior.md +114 -0
- package/.agents/scripts/check-context-budget.js +134 -2
- package/.agents/scripts/deliver-light.js +72 -8
- package/.agents/scripts/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +113 -7
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
- package/.agents/scripts/lib/label-constants.js +12 -1
- package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
- package/.agents/scripts/lib/observability/signals-writer.js +133 -14
- package/.agents/scripts/lib/observability/source-classifier.js +131 -1
- package/.agents/scripts/lib/orchestration/code-review.js +12 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +119 -52
- package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
- package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
- package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
- package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
- package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
- package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
- package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
- package/.agents/scripts/lib/story-body/story-body.js +248 -174
- package/.agents/scripts/lib/temp-retention.js +23 -8
- package/.agents/scripts/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +6 -8
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-light.md +45 -5
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +56 -21
- package/.agents/workflows/helpers/deliver-story.md +8 -5
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-providers/degraded-gates.js — the review's "a gate did not run"
|
|
3
|
+
* channel (Story #4839).
|
|
4
|
+
*
|
|
5
|
+
* ## The defect this closes
|
|
6
|
+
*
|
|
7
|
+
* Story #4699 correctly decided that a tool which **could not execute** is an
|
|
8
|
+
* operational degradation, not a code finding: it is routed to friction
|
|
9
|
+
* telemetry so severity tiers keep reflecting code findings only. What #4699
|
|
10
|
+
* did not add was any *other* channel, so the review's own verdict became
|
|
11
|
+
* unable to distinguish "lint ran and found nothing" from "lint never ran" —
|
|
12
|
+
* both rendered `✅ No findings` with an all-zero severity tally, and the close
|
|
13
|
+
* pipeline read the second as the first. A gate that reports success when it
|
|
14
|
+
* did not run is worse than no gate, because it is trusted.
|
|
15
|
+
*
|
|
16
|
+
* This module is that missing channel. A degradation travels **beside** the
|
|
17
|
+
* `Finding[]`, never inside it:
|
|
18
|
+
*
|
|
19
|
+
* - it never becomes a `Finding`, so `countBySeverity` is untouched and no
|
|
20
|
+
* execution failure can appear as a critical / high / medium (or even a
|
|
21
|
+
* suggestion) — #4699's intent survives intact;
|
|
22
|
+
* - it is rendered as its own section in the structured comment, and it
|
|
23
|
+
* suppresses the false `✅ No findings` claim;
|
|
24
|
+
* - it is carried on the `runCodeReview` envelope as `degraded` /
|
|
25
|
+
* `degradations[]`, so the close pipeline sees it too.
|
|
26
|
+
*
|
|
27
|
+
* ## Report, not block — and why
|
|
28
|
+
*
|
|
29
|
+
* A degraded gate is reported loudly and does **not** halt the close. The close
|
|
30
|
+
* pipeline already runs the canonical `npm run lint` as a hard
|
|
31
|
+
* close-validation gate *before* the review phase; the review's scoped lint is
|
|
32
|
+
* a second, narrower read of the same surface. Failing a merge because a
|
|
33
|
+
* *secondary* read of an already-gated surface could not start would block
|
|
34
|
+
* delivery on an operational condition the hard gate has already covered. What
|
|
35
|
+
* was actually broken was the silence, so the fix is to make the silence
|
|
36
|
+
* impossible: every surface that reads the review outcome now states the
|
|
37
|
+
* degradation explicitly. Escalating to a block is a one-line change here if
|
|
38
|
+
* the operator posture ever needs it.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {object} GateDegradation
|
|
43
|
+
* @property {string} tool Emitter (e.g. `native-review-lint`).
|
|
44
|
+
* @property {string} gate Gate that degraded (e.g. `scoped-lint`).
|
|
45
|
+
* @property {string} surface Sub-surface that could not run (e.g. `markdownlint`).
|
|
46
|
+
* @property {string} reason Machine-readable reason code.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure: keep only well-formed degradation records. A misbehaving provider must
|
|
51
|
+
* not be able to corrupt the rendered comment or the envelope.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} input
|
|
54
|
+
* @returns {GateDegradation[]}
|
|
55
|
+
*/
|
|
56
|
+
export function normalizeDegradations(input) {
|
|
57
|
+
if (!Array.isArray(input)) return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const d of input) {
|
|
60
|
+
if (!d || typeof d !== 'object') continue;
|
|
61
|
+
const surface = typeof d.surface === 'string' ? d.surface : null;
|
|
62
|
+
const reason = typeof d.reason === 'string' ? d.reason : null;
|
|
63
|
+
if (surface === null || reason === null) continue;
|
|
64
|
+
out.push({
|
|
65
|
+
tool: typeof d.tool === 'string' ? d.tool : 'unknown',
|
|
66
|
+
gate: typeof d.gate === 'string' ? d.gate : 'unknown',
|
|
67
|
+
surface,
|
|
68
|
+
reason,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Feature-detect a provider's degradation channel, mirroring how
|
|
76
|
+
* `getPromptMessages` is feature-detected. Providers predating this contract
|
|
77
|
+
* carry no `getDegradations`, so the empty array keeps their output byte-stable;
|
|
78
|
+
* a throw degrades to empty (observability must never fail a review).
|
|
79
|
+
*
|
|
80
|
+
* MUST be called **after** `runReview` — a provider records its degradations
|
|
81
|
+
* during the run.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ getDegradations?: Function }} reviewProvider
|
|
84
|
+
* @param {{ warn?: Function }} [logger]
|
|
85
|
+
* @returns {Promise<GateDegradation[]>}
|
|
86
|
+
*/
|
|
87
|
+
export async function collectProviderDegradations(reviewProvider, logger) {
|
|
88
|
+
if (typeof reviewProvider?.getDegradations !== 'function') return [];
|
|
89
|
+
try {
|
|
90
|
+
return normalizeDegradations(await reviewProvider.getDegradations());
|
|
91
|
+
} catch (err) {
|
|
92
|
+
logger?.warn?.(
|
|
93
|
+
`[code-review] getDegradations threw; treating as none. ${
|
|
94
|
+
err?.message ?? err
|
|
95
|
+
}`,
|
|
96
|
+
);
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Pure: one-line operator-facing summary of the degraded gates, for progress
|
|
103
|
+
* output and comment tallies. Empty input renders `none` so the field is always
|
|
104
|
+
* present — a missing degradation line must never be read as "no degradation".
|
|
105
|
+
*
|
|
106
|
+
* @param {ReadonlyArray<GateDegradation>} degradations
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function summarizeDegradations(degradations) {
|
|
110
|
+
const rows = normalizeDegradations(degradations);
|
|
111
|
+
if (rows.length === 0) return 'none';
|
|
112
|
+
return rows.map((d) => `${d.gate}/${d.surface} (${d.reason})`).join(', ');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Pure: the `{ degraded, degradations }` pair every outcome envelope carries, so
|
|
117
|
+
* a caller adds the channel by spreading one helper rather than restating the
|
|
118
|
+
* derivation (and cannot ship `degradations` without `degraded`).
|
|
119
|
+
*
|
|
120
|
+
* @param {unknown} degradations
|
|
121
|
+
* @returns {{ degraded: boolean, degradations: GateDegradation[] }}
|
|
122
|
+
*/
|
|
123
|
+
export function degradationEnvelope(degradations) {
|
|
124
|
+
const rows = normalizeDegradations(degradations);
|
|
125
|
+
return { degraded: rows.length > 0, degradations: rows };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Merge the degraded-gate records of every inline chain entry that carries the
|
|
130
|
+
* channel. A provider predating the contract contributes nothing; a throw is
|
|
131
|
+
* logged and skipped, because a chain must never lose a "this gate did not run"
|
|
132
|
+
* signal *or* fail a review over reporting one.
|
|
133
|
+
*
|
|
134
|
+
* @param {ReadonlyArray<{ name: string, provider: { getDegradations?: Function } }>} entries
|
|
135
|
+
* @param {{ warn?: Function }} [logger]
|
|
136
|
+
* @returns {Promise<GateDegradation[]>}
|
|
137
|
+
*/
|
|
138
|
+
export async function mergeChainDegradations(entries, logger) {
|
|
139
|
+
const merged = [];
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (typeof entry.provider?.getDegradations !== 'function') continue;
|
|
142
|
+
try {
|
|
143
|
+
merged.push(
|
|
144
|
+
...normalizeDegradations(await entry.provider.getDegradations()),
|
|
145
|
+
);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
logger?.warn?.(
|
|
148
|
+
`[code-review] Inline provider "${entry.name}" getDegradations threw; skipping. ${
|
|
149
|
+
err?.message ?? err
|
|
150
|
+
}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pure: the header field naming how many gates did not run. Empty when the
|
|
159
|
+
* review was healthy, so a healthy body stays byte-identical to pre-#4839.
|
|
160
|
+
*
|
|
161
|
+
* @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
|
|
162
|
+
* @returns {string[]}
|
|
163
|
+
*/
|
|
164
|
+
export function renderDegradedHeaderLines(degraded) {
|
|
165
|
+
if (degraded.length === 0) return [];
|
|
166
|
+
return [`**Degraded gates**: ${degraded.length} (did not run)`];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pure: the "nothing surfaced" block. This is the exact sentence the defect
|
|
171
|
+
* turned into a lie — with a degraded gate present, an all-zero tally is not a
|
|
172
|
+
* clean verdict and must not read like one.
|
|
173
|
+
*
|
|
174
|
+
* @param {ReadonlyArray<GateDegradation>} degraded Already normalized.
|
|
175
|
+
* @returns {string[]}
|
|
176
|
+
*/
|
|
177
|
+
export function renderNoFindingsBlock(degraded) {
|
|
178
|
+
if (degraded.length === 0) {
|
|
179
|
+
return [
|
|
180
|
+
'### ✅ No findings',
|
|
181
|
+
'',
|
|
182
|
+
'No issues surfaced by the review provider.',
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
`### ⚠️ No findings — ${degraded.length} gate(s) did not run`,
|
|
187
|
+
'',
|
|
188
|
+
'The gates that ran surfaced no issues. This review does **not** vouch ' +
|
|
189
|
+
'for the degraded surface(s) listed above.',
|
|
190
|
+
];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Pure: render the "Degraded Gates" section of the structured comment. Returns
|
|
195
|
+
* an empty array when nothing degraded, so a healthy review's body stays
|
|
196
|
+
* byte-identical to the pre-#4839 output.
|
|
197
|
+
*
|
|
198
|
+
* @param {ReadonlyArray<GateDegradation>} degradations
|
|
199
|
+
* @returns {string[]} markdown lines
|
|
200
|
+
*/
|
|
201
|
+
export function renderDegradedGatesSection(degradations) {
|
|
202
|
+
const rows = normalizeDegradations(degradations);
|
|
203
|
+
if (rows.length === 0) return [];
|
|
204
|
+
const lines = [
|
|
205
|
+
`### ⚠️ Degraded Gates (${rows.length})`,
|
|
206
|
+
'',
|
|
207
|
+
'The following review gate(s) **did not run**. Their surface is',
|
|
208
|
+
'unreviewed — an all-zero finding tally below does not vouch for it.',
|
|
209
|
+
'',
|
|
210
|
+
];
|
|
211
|
+
for (const d of rows) {
|
|
212
|
+
lines.push(
|
|
213
|
+
`- \`${d.gate}\` → \`${d.surface}\` could not execute — ${d.reason} (emitter: \`${d.tool}\`).`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
lines.push('');
|
|
217
|
+
lines.push(
|
|
218
|
+
'Verify with the canonical `npm run lint` before trusting this review.',
|
|
219
|
+
);
|
|
220
|
+
lines.push('');
|
|
221
|
+
return lines;
|
|
222
|
+
}
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
* @typedef {import('./types.js').Severity} Severity
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import {
|
|
19
|
+
normalizeDegradations,
|
|
20
|
+
renderDegradedGatesSection,
|
|
21
|
+
renderDegradedHeaderLines,
|
|
22
|
+
renderNoFindingsBlock,
|
|
23
|
+
} from './degraded-gates.js';
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
26
|
* Canonical severity ordering. The render output always lists the
|
|
20
27
|
* severity-tier counts in this order and emits the per-finding sections
|
|
@@ -115,6 +122,12 @@ export function renderManualPromptsSection(messages) {
|
|
|
115
122
|
* manual-prompt provider output; rendered as a trailing section when
|
|
116
123
|
* non-empty.
|
|
117
124
|
*
|
|
125
|
+
* Story #4839 — an optional `degradations` field names review gates that could
|
|
126
|
+
* not execute. They are **not** findings and never enter `countBySeverity`; they
|
|
127
|
+
* render as their own section and suppress the unqualified "no findings" claim,
|
|
128
|
+
* because a review that could not run a gate has not established that the
|
|
129
|
+
* gate's surface is clean.
|
|
130
|
+
*
|
|
118
131
|
* @param {{
|
|
119
132
|
* ticketId: number,
|
|
120
133
|
* baseRef: string,
|
|
@@ -122,6 +135,7 @@ export function renderManualPromptsSection(messages) {
|
|
|
122
135
|
* findings: ReadonlyArray<Finding>,
|
|
123
136
|
* provider?: string,
|
|
124
137
|
* promptMessages?: ReadonlyArray<string>,
|
|
138
|
+
* degradations?: ReadonlyArray<object>,
|
|
125
139
|
* }} input
|
|
126
140
|
* @returns {string}
|
|
127
141
|
*/
|
|
@@ -131,6 +145,7 @@ export function renderFindings(input) {
|
|
|
131
145
|
const counts = countBySeverity(findings);
|
|
132
146
|
const totalKnown =
|
|
133
147
|
counts.critical + counts.high + counts.medium + counts.suggestion;
|
|
148
|
+
const degraded = normalizeDegradations(input.degradations);
|
|
134
149
|
|
|
135
150
|
const providerLine = provider
|
|
136
151
|
? `**Provider**: \`${provider}\``
|
|
@@ -142,6 +157,7 @@ export function renderFindings(input) {
|
|
|
142
157
|
`**Comparison**: \`${baseRef}\` … \`${headRef}\``,
|
|
143
158
|
providerLine,
|
|
144
159
|
`**Findings**: ${totalKnown}`,
|
|
160
|
+
...renderDegradedHeaderLines(degraded),
|
|
145
161
|
'',
|
|
146
162
|
'### 📦 Severity Tier Counts',
|
|
147
163
|
'',
|
|
@@ -150,12 +166,11 @@ export function renderFindings(input) {
|
|
|
150
166
|
return `- ${meta.emoji} ${meta.label}: ${counts[sev]}`;
|
|
151
167
|
}),
|
|
152
168
|
'',
|
|
169
|
+
...renderDegradedGatesSection(degraded),
|
|
153
170
|
];
|
|
154
171
|
|
|
155
172
|
if (totalKnown === 0) {
|
|
156
|
-
lines.push(
|
|
157
|
-
lines.push('');
|
|
158
|
-
lines.push('No issues surfaced by the review provider.');
|
|
173
|
+
lines.push(...renderNoFindingsBlock(degraded));
|
|
159
174
|
} else {
|
|
160
175
|
for (const sev of SEVERITY_ORDER) {
|
|
161
176
|
const tierFindings = findings.filter((f) => f && f.severity === sev);
|
|
@@ -40,7 +40,6 @@
|
|
|
40
40
|
* @typedef {import('./types.js').ReviewProvider} ReviewProvider
|
|
41
41
|
*/
|
|
42
42
|
|
|
43
|
-
import { spawnSync } from 'node:child_process';
|
|
44
43
|
import path from 'node:path';
|
|
45
44
|
import { POOL_SERIAL_THRESHOLD, runOnPool } from '../../cpu-pool.js';
|
|
46
45
|
import { gitSpawn } from '../../git-utils.js';
|
|
@@ -54,6 +53,20 @@ import {
|
|
|
54
53
|
} from '../../observability/runtime-friction.js';
|
|
55
54
|
import { PROJECT_ROOT } from '../../project-root.js';
|
|
56
55
|
import { transpileIfNeeded } from '../../transpile.js';
|
|
56
|
+
import {
|
|
57
|
+
parseLintOutput,
|
|
58
|
+
partitionFilesForLint,
|
|
59
|
+
runScopedLint,
|
|
60
|
+
} from './scoped-lint.js';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The scoped-lint surface lives in [`scoped-lint.js`](scoped-lint.js), which
|
|
64
|
+
* owns runner resolution, per-surface classification, and the merge. Story
|
|
65
|
+
* #4839 moved it there while fixing the three invocation defects that made this
|
|
66
|
+
* gate fail open on ~78% of deliveries; the module docstring there carries the
|
|
67
|
+
* diagnosis. The three names stay part of this provider's published lint seam.
|
|
68
|
+
*/
|
|
69
|
+
export { parseLintOutput, partitionFilesForLint, runScopedLint };
|
|
57
70
|
|
|
58
71
|
/** Worker entry that scores one file into a full maintainability report. */
|
|
59
72
|
const MAINTAINABILITY_REPORT_WORKER_URL = new URL(
|
|
@@ -74,72 +87,6 @@ export const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
|
|
|
74
87
|
|
|
75
88
|
const JS_MAINTAINABILITY_EXTS = new Set(['.js', '.mjs', '.cjs']);
|
|
76
89
|
|
|
77
|
-
/**
|
|
78
|
-
* Parse stdout/stderr from a lint runner to estimate error vs warning counts.
|
|
79
|
-
*
|
|
80
|
-
* Handles the two runners composing `npm run lint` in this project:
|
|
81
|
-
* - Biome: emits "Found N error(s)." and "Found N warning(s)." lines.
|
|
82
|
-
* - markdownlint: emits one diagnostic per issue, plus a trailing
|
|
83
|
-
* "Summary: N error(s)" line.
|
|
84
|
-
*
|
|
85
|
-
* Severity classification: when the runner exits non-zero but its output
|
|
86
|
-
* matches neither known reporter format, the result is "could not classify" —
|
|
87
|
-
* `executionFailed: true` so callers can degrade the gate to a suggestion +
|
|
88
|
-
* skipped marker rather than mislabelling an environment problem as high risk.
|
|
89
|
-
*
|
|
90
|
-
* Exported for testing.
|
|
91
|
-
*
|
|
92
|
-
* @param {{ status: number, stdout: string, stderr: string }} result
|
|
93
|
-
* @returns {{ errors: number, warnings: number, parsed: boolean, executionFailed: boolean }}
|
|
94
|
-
*/
|
|
95
|
-
export function parseLintOutput(result) {
|
|
96
|
-
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
97
|
-
|
|
98
|
-
let errors = 0;
|
|
99
|
-
let warnings = 0;
|
|
100
|
-
let parsed = false;
|
|
101
|
-
|
|
102
|
-
const errMatches = combined.matchAll(/Found\s+(\d+)\s+error/gi);
|
|
103
|
-
for (const m of errMatches) {
|
|
104
|
-
errors += Number(m[1]);
|
|
105
|
-
parsed = true;
|
|
106
|
-
}
|
|
107
|
-
const warnMatches = combined.matchAll(/Found\s+(\d+)\s+warning/gi);
|
|
108
|
-
for (const m of warnMatches) {
|
|
109
|
-
warnings += Number(m[1]);
|
|
110
|
-
parsed = true;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const mdSummary = combined.match(/Summary:\s+(\d+)\s+error/i);
|
|
114
|
-
if (mdSummary) {
|
|
115
|
-
errors += Number(mdSummary[1]);
|
|
116
|
-
parsed = true;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const executionFailed = !parsed && result.status !== 0;
|
|
120
|
-
|
|
121
|
-
return { errors, warnings, parsed, executionFailed };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Pure: split changed paths into the file lists each lint runner consumes.
|
|
126
|
-
*
|
|
127
|
-
* Exported for testing.
|
|
128
|
-
*
|
|
129
|
-
* @param {string[]} changedFiles
|
|
130
|
-
* @returns {{ code: string[], md: string[] }}
|
|
131
|
-
*/
|
|
132
|
-
export function partitionFilesForLint(changedFiles) {
|
|
133
|
-
const CODE = /\.(js|mjs|cjs|jsx|ts|tsx|json|jsonc)$/i;
|
|
134
|
-
const code = [];
|
|
135
|
-
const md = [];
|
|
136
|
-
for (const f of changedFiles) {
|
|
137
|
-
if (CODE.test(f)) code.push(f);
|
|
138
|
-
else if (/\.md$/i.test(f)) md.push(f);
|
|
139
|
-
}
|
|
140
|
-
return { code, md };
|
|
141
|
-
}
|
|
142
|
-
|
|
143
90
|
/**
|
|
144
91
|
* Read a changed file's content as it exists at `headRef` via
|
|
145
92
|
* `git show <headRef>:<relPath>`, rather than reading the on-disk copy at
|
|
@@ -197,61 +144,6 @@ export function scoreSourceReport(source, relPath) {
|
|
|
197
144
|
return calculateReport(prepared);
|
|
198
145
|
}
|
|
199
146
|
|
|
200
|
-
function spawnLintRunner(bin, args, cwd) {
|
|
201
|
-
const result = spawnSync('npx', ['--no', bin, ...args], {
|
|
202
|
-
cwd,
|
|
203
|
-
encoding: 'utf-8',
|
|
204
|
-
shell: process.platform === 'win32',
|
|
205
|
-
});
|
|
206
|
-
return {
|
|
207
|
-
status: result.status ?? 1,
|
|
208
|
-
stdout: result.stdout ?? '',
|
|
209
|
-
stderr: result.stderr ?? '',
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Run lint scoped to the changed surface only. Returns a normalized summary
|
|
215
|
-
* compatible with `parseLintOutput` plus a `skipped` flag set when there is
|
|
216
|
-
* no JS or markdown file in the changed set (nothing to lint).
|
|
217
|
-
*
|
|
218
|
-
* @param {string[]} changedFiles
|
|
219
|
-
* @param {string} cwd
|
|
220
|
-
* @param {(bin: string, args: string[], cwd: string) => { status: number, stdout: string, stderr: string }} [runnerFn]
|
|
221
|
-
* @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only', executionFailed?: boolean }}
|
|
222
|
-
*/
|
|
223
|
-
export function runScopedLint(changedFiles, cwd, runnerFn = spawnLintRunner) {
|
|
224
|
-
const { code, md } = partitionFilesForLint(changedFiles);
|
|
225
|
-
if (code.length === 0 && md.length === 0) {
|
|
226
|
-
return {
|
|
227
|
-
errors: 0,
|
|
228
|
-
warnings: 0,
|
|
229
|
-
parsed: false,
|
|
230
|
-
skipped: true,
|
|
231
|
-
mode: 'changed-only',
|
|
232
|
-
};
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const runs = [];
|
|
236
|
-
if (code.length > 0) runs.push(runnerFn('biome', ['lint', ...code], cwd));
|
|
237
|
-
if (md.length > 0) {
|
|
238
|
-
runs.push(
|
|
239
|
-
runnerFn('markdownlint', [...md, '--ignore', 'node_modules'], cwd),
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
let status = 0;
|
|
244
|
-
let stdout = '';
|
|
245
|
-
let stderr = '';
|
|
246
|
-
for (const r of runs) {
|
|
247
|
-
if ((r.status ?? 1) > status) status = r.status ?? 1;
|
|
248
|
-
stdout += r.stdout ?? '';
|
|
249
|
-
stderr += r.stderr ?? '';
|
|
250
|
-
}
|
|
251
|
-
const summary = parseLintOutput({ status, stdout, stderr });
|
|
252
|
-
return { ...summary, skipped: false, mode: 'changed-only' };
|
|
253
|
-
}
|
|
254
|
-
|
|
255
147
|
/**
|
|
256
148
|
* Pure: classify a single file's maintainability report into a row + optional
|
|
257
149
|
* Finding-shaped entries. `reportFn` is the thunk that produces the file's
|
|
@@ -523,6 +415,8 @@ async function runLintPhase({
|
|
|
523
415
|
parsed: false,
|
|
524
416
|
skipped: true,
|
|
525
417
|
mode: 'off',
|
|
418
|
+
executionFailed: false,
|
|
419
|
+
degradations: [],
|
|
526
420
|
};
|
|
527
421
|
}
|
|
528
422
|
logger?.info?.(
|
|
@@ -531,6 +425,35 @@ async function runLintPhase({
|
|
|
531
425
|
return runScopedLintFn(changedFiles, PROJECT_ROOT);
|
|
532
426
|
}
|
|
533
427
|
|
|
428
|
+
/**
|
|
429
|
+
* Pure: turn an `executionFailed` lint summary into the degradation records the
|
|
430
|
+
* review outcome carries beside its findings (Story #4839).
|
|
431
|
+
*
|
|
432
|
+
* A summary from `runScopedLint` names each failed surface; an injected or
|
|
433
|
+
* legacy summary that sets only `executionFailed` degrades to one record for
|
|
434
|
+
* the gate as a whole, so the outcome is never silent about a gate that did not
|
|
435
|
+
* run just because the summary predates the per-surface contract.
|
|
436
|
+
*
|
|
437
|
+
* @param {{ executionFailed?: boolean, degradations?: Array<{ surface: string, reason: string }> }} lintSummary
|
|
438
|
+
* @returns {Array<{ tool: string, gate: string, surface: string, reason: string }>}
|
|
439
|
+
*/
|
|
440
|
+
function buildLintDegradations(lintSummary) {
|
|
441
|
+
if (!lintSummary.executionFailed) return [];
|
|
442
|
+
const rows = Array.isArray(lintSummary.degradations)
|
|
443
|
+
? lintSummary.degradations
|
|
444
|
+
: [];
|
|
445
|
+
const surfaces =
|
|
446
|
+
rows.length > 0
|
|
447
|
+
? rows
|
|
448
|
+
: [{ surface: 'scoped-lint', reason: 'unparseable-output' }];
|
|
449
|
+
return surfaces.map((row) => ({
|
|
450
|
+
tool: 'native-review-lint',
|
|
451
|
+
gate: 'scoped-lint',
|
|
452
|
+
surface: row.surface,
|
|
453
|
+
reason: row.reason,
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
|
|
534
457
|
/**
|
|
535
458
|
* Build a `ReviewProvider` instance backed by the native in-process pipeline.
|
|
536
459
|
*
|
|
@@ -560,12 +483,33 @@ export function createNativeProvider(deps = {}) {
|
|
|
560
483
|
scopeLint = 'changed-only',
|
|
561
484
|
} = deps;
|
|
562
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Degradations recorded by the most recent `runReview`. Read through
|
|
488
|
+
* `getDegradations()` after the run, mirroring how `getPromptMessages` is
|
|
489
|
+
* feature-detected by the orchestrator — findings and degradations travel
|
|
490
|
+
* side by side, so an unexecutable tool never has to become a `Finding` to
|
|
491
|
+
* be visible (Story #4699's intent; Story #4839's fix).
|
|
492
|
+
*
|
|
493
|
+
* @type {Array<{ tool: string, gate: string, surface: string, reason: string }>}
|
|
494
|
+
*/
|
|
495
|
+
let recordedDegradations = [];
|
|
496
|
+
|
|
563
497
|
return {
|
|
498
|
+
/**
|
|
499
|
+
* Gate degradations from the last `runReview`. Never a `Finding`, so
|
|
500
|
+
* severity counts stay code-findings-only.
|
|
501
|
+
*
|
|
502
|
+
* @returns {Array<{ tool: string, gate: string, surface: string, reason: string }>}
|
|
503
|
+
*/
|
|
504
|
+
getDegradations() {
|
|
505
|
+
return recordedDegradations;
|
|
506
|
+
},
|
|
564
507
|
/**
|
|
565
508
|
* @param {ReviewInput} input
|
|
566
509
|
* @returns {Promise<Finding[]>}
|
|
567
510
|
*/
|
|
568
511
|
async runReview(input) {
|
|
512
|
+
recordedDegradations = [];
|
|
569
513
|
const { scope, ticketId, baseRef, headRef } = input ?? {};
|
|
570
514
|
if (!baseRef || !headRef) {
|
|
571
515
|
throw new TypeError(
|
|
@@ -623,8 +567,19 @@ export function createNativeProvider(deps = {}) {
|
|
|
623
567
|
// Story #4699 — a tool that could not execute is an operational
|
|
624
568
|
// degradation, not a code finding. Route it to friction telemetry
|
|
625
569
|
// (best-effort) so severity counts reflect code findings only.
|
|
570
|
+
//
|
|
571
|
+
// Story #4839 — telemetry alone left the review's own verdict unable to
|
|
572
|
+
// distinguish "lint ran and found nothing" from "lint never ran", so
|
|
573
|
+
// the same degradation is also recorded on the outcome channel. It is
|
|
574
|
+
// still never a `Finding`: the friction emission below is unchanged and
|
|
575
|
+
// severity counts remain code-findings-only.
|
|
576
|
+
recordedDegradations = buildLintDegradations(lintSummary);
|
|
626
577
|
logger?.warn?.(
|
|
627
|
-
|
|
578
|
+
`[native-review] Lint runner could not execute (${recordedDegradations
|
|
579
|
+
.map((d) => `${d.surface}: ${d.reason}`)
|
|
580
|
+
.join(
|
|
581
|
+
'; ',
|
|
582
|
+
)}) — reported as a degraded gate on the review outcome and recorded as friction telemetry; no finding emitted. Verify with the canonical \`npm run lint\` before merging.`,
|
|
628
583
|
);
|
|
629
584
|
try {
|
|
630
585
|
await emitToolDegradationFn({
|
|
@@ -646,9 +601,10 @@ export function createNativeProvider(deps = {}) {
|
|
|
646
601
|
|
|
647
602
|
// Canonical ordering: critical (maintainability) first, then high
|
|
648
603
|
// (lint errors), then medium (size/volume warnings), then suggestion
|
|
649
|
-
// (lint warnings
|
|
650
|
-
//
|
|
651
|
-
//
|
|
604
|
+
// (lint warnings). An execution failure contributes to none of these
|
|
605
|
+
// tiers — it travels on the degradation channel. The renderer
|
|
606
|
+
// re-bucketizes by severity tier, so this order only matters for
|
|
607
|
+
// stability of fixture outputs.
|
|
652
608
|
return [
|
|
653
609
|
...results.criticalFindings,
|
|
654
610
|
...lintFindings.filter((f) => f.severity === 'high'),
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
import { createCodexProviderForRegistry } from './codex.js';
|
|
38
|
+
import { mergeChainDegradations } from './degraded-gates.js';
|
|
38
39
|
import { createNativeProviderForRegistry } from './native.js';
|
|
39
40
|
import { createSecurityReviewProviderForRegistry } from './security-review.js';
|
|
40
41
|
import { createUltrareviewProviderForRegistry } from './ultrareview.js';
|
|
@@ -288,6 +289,15 @@ export function createChainProvider(chain, opts = {}) {
|
|
|
288
289
|
}
|
|
289
290
|
return merged;
|
|
290
291
|
},
|
|
292
|
+
/**
|
|
293
|
+
* Degraded gates across the inline chain (Story #4839). Called after
|
|
294
|
+
* `runReview`.
|
|
295
|
+
*
|
|
296
|
+
* @returns {Promise<Array<object>>}
|
|
297
|
+
*/
|
|
298
|
+
async getDegradations() {
|
|
299
|
+
return mergeChainDegradations(chain.inline, logger);
|
|
300
|
+
},
|
|
291
301
|
/**
|
|
292
302
|
* @param {ReviewInput} input
|
|
293
303
|
* @returns {Promise<string[]>}
|