mandrel 2.21.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 +5 -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/lib/audit-suite/selector.js +275 -162
- package/.agents/scripts/lib/config/temp-paths.js +51 -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 +51 -46
- 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/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/story-close/phases/code-review.js +5 -1
- 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/resolve-stories.js +52 -33
- package/.agents/scripts/single-story-confirm-merge.js +5 -7
- package/.agents/workflows/helpers/deliver-digest.md +8 -6
- package/.agents/workflows/helpers/deliver-reference.md +15 -12
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
- package/.agents/workflows/helpers/deliver-story.md +2 -2
- package/.agents/workflows/helpers/plan-reference.md +5 -4
- package/docs/CHANGELOG.md +20 -0
- 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
|
-
|
|
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[]>}
|
|
@@ -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
|
+
}
|