mandrel 2.21.0 → 2.23.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 (44) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/docs/configuration.md +1 -0
  4. package/.agents/instructions.md +14 -17
  5. package/.agents/rules/git-conventions.md +1 -1
  6. package/.agents/rules/known-tooling-behavior.md +114 -0
  7. package/.agents/schemas/agentrc.schema.json +6 -0
  8. package/.agents/scripts/check-context-budget.js +134 -2
  9. package/.agents/scripts/diagnose-friction.js +95 -4
  10. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  11. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  12. package/.agents/scripts/lib/config-settings-schema-delivery.js +8 -0
  13. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  14. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  15. package/.agents/scripts/lib/label-constants.js +12 -1
  16. package/.agents/scripts/lib/observability/runtime-friction.js +41 -1
  17. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  18. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  19. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  20. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  21. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  22. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  24. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  25. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  26. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  27. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +69 -7
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  31. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  32. package/.agents/scripts/lib/orchestration/story-follow-ups.js +380 -13
  33. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  34. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  35. package/.agents/scripts/resolve-stories.js +52 -33
  36. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  37. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  38. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  39. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  40. package/.agents/workflows/helpers/deliver-story.md +2 -2
  41. package/.agents/workflows/helpers/plan-reference.md +45 -4
  42. package/.agents/workflows/plan.md +21 -16
  43. package/docs/CHANGELOG.md +34 -0
  44. package/package.json +1 -1
@@ -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
- '[native-review] Lint runner could not execute — recorded as friction telemetry, no finding emitted. Verify with the canonical `npm run lint` before merging.',
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 / executionFailed). The renderer re-bucketizes by
650
- // severity tier, so this order only matters for stability of fixture
651
- // outputs.
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[]>}
@@ -0,0 +1,300 @@
1
+ /**
2
+ * review-providers/scoped-lint.js — the scoped-lint surface of the native
3
+ * review provider (extracted from `native.js` by Story #4839).
4
+ *
5
+ * ## Why this module exists
6
+ *
7
+ * The scoped-lint gate reported `executionFailed` — and therefore emitted zero
8
+ * findings while the review reported clean — on 18 of 23 Beestera/swarm-os
9
+ * Stories carrying friction (78%) and on 5 mandrel Stories. Measured
10
+ * 2026-07-29, the cause was **not** environmental and **not** a parse failure.
11
+ * Three defects in how the runners were invoked and reconciled produced the
12
+ * same symptom:
13
+ *
14
+ * 1. **The markdown runner was never resolvable.** The provider spawned
15
+ * `npx --no markdownlint`, but the binary this project (and the consumer)
16
+ * installs is `markdownlint-cli2` — `markdownlint-cli2` is the package and
17
+ * the bin name; a bare `markdownlint` bin does not exist. `npx --no` with an
18
+ * unresolvable bin exits 1 printing `could not determine executable to run`
19
+ * and nothing else, so the summary parsed nothing and the gate degraded.
20
+ * The parser was already written for **cli2's** `Summary: N error(s)` line,
21
+ * so the invocation and the parser had never agreed. The `--ignore
22
+ * node_modules` flag was likewise `markdownlint-cli` (v1) syntax, which
23
+ * cli2 does not accept. Fix: resolve the runner from what is actually
24
+ * installed and pass each candidate its own argument shape.
25
+ *
26
+ * 2. **One runner's failure poisoned the other's verdict.** The two runs were
27
+ * folded into a *single* `parseLintOutput` call over concatenated output and
28
+ * the maximum exit status. So the unresolvable markdown runner's exit 1
29
+ * became the verdict for biome too: any change set containing at least one
30
+ * `.md` file degraded the whole gate whenever biome itself had nothing to
31
+ * report — i.e. exactly the clean case the gate exists to confirm. Fix:
32
+ * classify each surface independently and merge structurally.
33
+ *
34
+ * 3. **Biome's "nothing in scope" exit was read as a failure.** `biome lint`
35
+ * exits 1 with `No files were processed in the specified paths.` when every
36
+ * supplied path is excluded by `biome.json` (`temp/`, `dist/`,
37
+ * `.worktrees/`, anything in the VCS ignore file). That is an empty scope,
38
+ * not a runner that could not execute. Fix: recognise the sentinel.
39
+ *
40
+ * ## What a degraded surface now produces
41
+ *
42
+ * `runScopedLint` still reports `executionFailed` — the friction-telemetry
43
+ * emission in `native.js` is deliberately unchanged (Story #4699 routed an
44
+ * unexecutable tool to telemetry so severity tiers reflect code findings only,
45
+ * and that intent stands). It additionally reports a `degradations[]` array
46
+ * naming **which** surface could not run and **why**, so the review outcome can
47
+ * say "this gate did not run" instead of silently reading clean.
48
+ */
49
+
50
+ import { spawnSync } from 'node:child_process';
51
+ import { existsSync } from 'node:fs';
52
+ import path from 'node:path';
53
+
54
+ /** Paths these extensions land on the biome (code) runner. */
55
+ const CODE_EXTENSIONS = /\.(js|mjs|cjs|jsx|ts|tsx|json|jsonc)$/i;
56
+
57
+ /** npx's message when the requested bin cannot be resolved. */
58
+ const NPX_UNRESOLVABLE = /could not determine executable to run/i;
59
+
60
+ /** Biome's exit-1 message when every supplied path is config-excluded. */
61
+ const BIOME_EMPTY_SCOPE = /No files were processed in the specified paths/i;
62
+
63
+ /**
64
+ * Markdown runners in preference order, each with the argument shape *it*
65
+ * accepts. `markdownlint-cli2` takes bare paths/globs and rejects `--ignore`;
66
+ * `markdownlint` (cli v1) takes `--ignore`. Explicit changed-file paths are
67
+ * passed either way, so the v1 ignore flag is belt-and-braces only.
68
+ */
69
+ const MARKDOWN_RUNNERS = Object.freeze([
70
+ Object.freeze({ bin: 'markdownlint-cli2', extraArgs: Object.freeze([]) }),
71
+ Object.freeze({
72
+ bin: 'markdownlint',
73
+ extraArgs: Object.freeze(['--ignore', 'node_modules']),
74
+ }),
75
+ ]);
76
+
77
+ /** Reason codes carried on a degradation record. */
78
+ const DEGRADATION_REASONS = Object.freeze({
79
+ RUNNER_NOT_INSTALLED: 'runner-not-installed',
80
+ RUNNER_NOT_RESOLVABLE: 'runner-not-resolvable',
81
+ UNPARSEABLE_OUTPUT: 'unparseable-output',
82
+ });
83
+
84
+ /**
85
+ * Spawn one lint runner through `npx --no` (never install on the fly).
86
+ *
87
+ * @param {string} bin
88
+ * @param {string[]} args
89
+ * @param {string} cwd
90
+ * @returns {{ status: number, stdout: string, stderr: string }}
91
+ */
92
+ function spawnLintRunner(bin, args, cwd) {
93
+ const result = spawnSync('npx', ['--no', bin, ...args], {
94
+ cwd,
95
+ encoding: 'utf-8',
96
+ shell: process.platform === 'win32',
97
+ });
98
+ return {
99
+ status: result.status ?? 1,
100
+ stdout: result.stdout ?? '',
101
+ stderr: result.stderr ?? '',
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Pure-ish: pick the first markdown runner whose bin is actually installed
107
+ * under `<cwd>/node_modules/.bin`. Returns `null` when none is — an honest
108
+ * "this surface has no runner" that the caller reports rather than silently
109
+ * folding into a generic parse failure.
110
+ *
111
+ * The disk probe (rather than "spawn and see") is what makes the failure
112
+ * *nameable*: `npx --no <missing-bin>` yields only a generic npm error, which
113
+ * is precisely how the defect hid for months.
114
+ *
115
+ * Not exported: it is reachable — and asserted — through {@link runScopedLint},
116
+ * whose `existsFn` seam drives every resolution branch.
117
+ *
118
+ * @param {string} cwd
119
+ * @param {(p: string) => boolean} [existsFn] Injected for testing.
120
+ * @returns {{ bin: string, extraArgs: ReadonlyArray<string> }|null}
121
+ */
122
+ function resolveMarkdownRunner(cwd, existsFn = existsSync) {
123
+ for (const candidate of MARKDOWN_RUNNERS) {
124
+ const base = path.join(cwd, 'node_modules', '.bin', candidate.bin);
125
+ if (existsFn(base)) return candidate;
126
+ if (
127
+ process.platform === 'win32' &&
128
+ (existsFn(`${base}.cmd`) || existsFn(`${base}.ps1`))
129
+ ) {
130
+ return candidate;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Pure: split changed paths into the file lists each lint runner consumes.
138
+ *
139
+ * @param {string[]} changedFiles
140
+ * @returns {{ code: string[], md: string[] }}
141
+ */
142
+ export function partitionFilesForLint(changedFiles) {
143
+ const code = [];
144
+ const md = [];
145
+ for (const f of changedFiles) {
146
+ if (CODE_EXTENSIONS.test(f)) code.push(f);
147
+ else if (/\.md$/i.test(f)) md.push(f);
148
+ }
149
+ return { code, md };
150
+ }
151
+
152
+ /**
153
+ * Pure: classify **one** runner's result into a summary.
154
+ *
155
+ * Handles the reporter formats composing `npm run lint` here:
156
+ * - Biome: `Found N error(s).` / `Found N warning(s).`
157
+ * - markdownlint-cli2: a trailing `Summary: N error(s)` line.
158
+ *
159
+ * A non-zero exit whose output matches no known reporter format is "could not
160
+ * classify" → `executionFailed: true`, with `reason` naming what was actually
161
+ * observed. Biome's empty-scope exit is recognised separately as
162
+ * `emptyScope` — nothing to lint is not a broken runner.
163
+ *
164
+ * @param {{ status?: number, stdout?: string, stderr?: string }} result
165
+ * @returns {{ errors: number, warnings: number, parsed: boolean, executionFailed: boolean, emptyScope: boolean, reason: string|null }}
166
+ */
167
+ export function parseLintOutput(result) {
168
+ const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
169
+
170
+ let errors = 0;
171
+ let warnings = 0;
172
+ let parsed = false;
173
+
174
+ for (const m of combined.matchAll(/Found\s+(\d+)\s+error/gi)) {
175
+ errors += Number(m[1]);
176
+ parsed = true;
177
+ }
178
+ for (const m of combined.matchAll(/Found\s+(\d+)\s+warning/gi)) {
179
+ warnings += Number(m[1]);
180
+ parsed = true;
181
+ }
182
+ const mdSummary = combined.match(/Summary:\s+(\d+)\s+error/i);
183
+ if (mdSummary) {
184
+ errors += Number(mdSummary[1]);
185
+ parsed = true;
186
+ }
187
+
188
+ const failedExit = !parsed && (result.status ?? 0) !== 0;
189
+ const emptyScope = failedExit && BIOME_EMPTY_SCOPE.test(combined);
190
+ const executionFailed = failedExit && !emptyScope;
191
+ const reason = executionFailed
192
+ ? NPX_UNRESOLVABLE.test(combined)
193
+ ? DEGRADATION_REASONS.RUNNER_NOT_RESOLVABLE
194
+ : DEGRADATION_REASONS.UNPARSEABLE_OUTPUT
195
+ : null;
196
+
197
+ return { errors, warnings, parsed, executionFailed, emptyScope, reason };
198
+ }
199
+
200
+ /**
201
+ * Pure: merge per-surface summaries into the gate's single summary. Counts add;
202
+ * `executionFailed` is the OR across surfaces; each failed surface contributes
203
+ * one degradation record naming itself. Merging *summaries* rather than raw
204
+ * output is what stops one runner's failure from becoming the other's verdict.
205
+ *
206
+ * @param {Array<{ surface: string, summary: ReturnType<typeof parseLintOutput> }>} surfaces
207
+ */
208
+ function mergeSurfaceSummaries(surfaces) {
209
+ let errors = 0;
210
+ let warnings = 0;
211
+ let parsed = false;
212
+ let executionFailed = false;
213
+ const degradations = [];
214
+
215
+ for (const { surface, summary } of surfaces) {
216
+ errors += summary.errors;
217
+ warnings += summary.warnings;
218
+ if (summary.parsed) parsed = true;
219
+ if (summary.executionFailed) {
220
+ executionFailed = true;
221
+ degradations.push({
222
+ surface,
223
+ reason: summary.reason ?? DEGRADATION_REASONS.UNPARSEABLE_OUTPUT,
224
+ });
225
+ }
226
+ }
227
+
228
+ return {
229
+ errors,
230
+ warnings,
231
+ parsed,
232
+ executionFailed,
233
+ skipped: false,
234
+ mode: 'changed-only',
235
+ degradations,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Run lint scoped to the changed surface only.
241
+ *
242
+ * @param {string[]} changedFiles
243
+ * @param {string} cwd
244
+ * @param {typeof spawnLintRunner} [runnerFn]
245
+ * @param {{ existsFn?: (p: string) => boolean }} [deps] Test seam for runner resolution.
246
+ * @returns {{ errors: number, warnings: number, parsed: boolean, skipped: boolean, mode: 'changed-only'|'off', executionFailed: boolean, degradations: Array<{ surface: string, reason: string }> }}
247
+ */
248
+ export function runScopedLint(
249
+ changedFiles,
250
+ cwd,
251
+ runnerFn = spawnLintRunner,
252
+ deps = {},
253
+ ) {
254
+ const { existsFn = existsSync } = deps;
255
+ const { code, md } = partitionFilesForLint(changedFiles);
256
+ if (code.length === 0 && md.length === 0) {
257
+ return {
258
+ errors: 0,
259
+ warnings: 0,
260
+ parsed: false,
261
+ skipped: true,
262
+ mode: 'changed-only',
263
+ executionFailed: false,
264
+ degradations: [],
265
+ };
266
+ }
267
+
268
+ const surfaces = [];
269
+ if (code.length > 0) {
270
+ surfaces.push({
271
+ surface: 'biome',
272
+ summary: parseLintOutput(runnerFn('biome', ['lint', ...code], cwd)),
273
+ });
274
+ }
275
+ if (md.length > 0) {
276
+ const runner = resolveMarkdownRunner(cwd, existsFn);
277
+ if (runner === null) {
278
+ surfaces.push({
279
+ surface: 'markdownlint',
280
+ summary: {
281
+ errors: 0,
282
+ warnings: 0,
283
+ parsed: false,
284
+ executionFailed: true,
285
+ emptyScope: false,
286
+ reason: DEGRADATION_REASONS.RUNNER_NOT_INSTALLED,
287
+ },
288
+ });
289
+ } else {
290
+ surfaces.push({
291
+ surface: runner.bin,
292
+ summary: parseLintOutput(
293
+ runnerFn(runner.bin, [...md, ...runner.extraArgs], cwd),
294
+ ),
295
+ });
296
+ }
297
+ }
298
+
299
+ return mergeSurfaceSummaries(surfaces);
300
+ }