@pyreon/cli 0.16.0 → 0.18.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/README.md +71 -32
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1509 -173
- package/lib/types/index.d.ts +183 -32
- package/package.json +3 -2
- package/src/doctor/gates/audit-tests.ts +70 -0
- package/src/doctor/gates/audit-types.ts +146 -0
- package/src/doctor/gates/bundle-budgets.ts +187 -0
- package/src/doctor/gates/distribution.ts +206 -0
- package/src/doctor/gates/doc-claims.ts +240 -0
- package/src/doctor/gates/index.ts +46 -0
- package/src/doctor/gates/islands-audit.ts +66 -0
- package/src/doctor/gates/lint.ts +129 -0
- package/src/doctor/gates/pyreon-patterns.ts +70 -0
- package/src/doctor/gates/react-patterns.ts +113 -0
- package/src/doctor/gates/ssg-audit.ts +57 -0
- package/src/doctor/orchestrator.ts +176 -0
- package/src/doctor/render/ansi.ts +80 -0
- package/src/doctor/render/gha.ts +47 -0
- package/src/doctor/render/index.ts +8 -0
- package/src/doctor/render/json.ts +16 -0
- package/src/doctor/render/text.ts +206 -0
- package/src/doctor/report.ts +61 -0
- package/src/doctor/score.ts +134 -0
- package/src/doctor/types.ts +196 -0
- package/src/doctor/utils/walk.ts +58 -0
- package/src/doctor.ts +82 -311
- package/src/index.ts +81 -20
- package/src/tests/doctor.test.ts +105 -457
- package/src/tests/gate-adapters.test.ts +193 -0
- package/src/tests/gates.test.ts +674 -0
- package/src/tests/orchestrator.test.ts +72 -0
- package/src/tests/render.test.ts +213 -0
- package/src/tests/report.test.ts +99 -0
- package/src/tests/score.test.ts +158 -0
package/lib/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
|
-
import {
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
import { auditIslands, auditSsg, auditTestEnvironment, detectPyreonPatterns, detectReactPatterns, generateContext as generateContext$1, hasPyreonPatterns, hasReactPatterns, migrateReactCode } from "@pyreon/compiler";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { allRules, lint } from "@pyreon/lint";
|
|
5
9
|
|
|
6
10
|
//#region src/context.ts
|
|
7
11
|
/**
|
|
@@ -33,70 +37,585 @@ function ensureGitignore(cwd) {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
//#endregion
|
|
36
|
-
//#region src/doctor.ts
|
|
40
|
+
//#region src/doctor/gates/audit-types.ts
|
|
37
41
|
/**
|
|
38
|
-
*
|
|
42
|
+
* audit-types gate — programmatic API.
|
|
39
43
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
+
* Catches typed-but-unimplemented public-interface fields. Walks every
|
|
45
|
+
* exported interface in each high-risk package and counts non-type
|
|
46
|
+
* references; fields with zero references are flagged HIGH. Catches the
|
|
47
|
+
* 0.14.0-class bug (`mode: "ssg"` typed but never read by runtime).
|
|
44
48
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
+
* **Implementation note (subprocess adapter).** This gate invokes the
|
|
50
|
+
* standalone `scripts/audit-types.ts` script via `--json --all` and
|
|
51
|
+
* parses the output. The script is 476 lines of mature AST-walking
|
|
52
|
+
* logic with its own test suite; rather than surgically extract it
|
|
53
|
+
* mid-shape, the adapter shape keeps PR 1 tractable and lets PR 2's
|
|
54
|
+
* aggregation layer consume the same `Finding[]` shape as the other
|
|
55
|
+
* gates. Adapter cost is ~50ms subprocess overhead — noise within the
|
|
56
|
+
* gate's 1-5s scan runtime. Full extraction is a deferred follow-up
|
|
57
|
+
* (the doctor aggregator doesn't care HOW the gate runs).
|
|
58
|
+
*/
|
|
59
|
+
const mapSeverity = (s) => {
|
|
60
|
+
switch (s) {
|
|
61
|
+
case "HIGH": return "error";
|
|
62
|
+
case "MEDIUM": return "warning";
|
|
63
|
+
case "LOW": return "info";
|
|
64
|
+
case "OK": return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Pure parse-and-map function — public so tests can exercise the JSON
|
|
69
|
+
* → `Finding[]` translation without spawning a subprocess. Returns the
|
|
70
|
+
* findings plus the count of packages scanned. Exported as `_internal`
|
|
71
|
+
* (unstable API surface — may move when PR 2 lands the aggregator).
|
|
72
|
+
*/
|
|
73
|
+
const _parseAuditTypesOutput = (raw, cwd) => {
|
|
74
|
+
const results = JSON.parse(raw);
|
|
75
|
+
const findings = [];
|
|
76
|
+
for (const r of results) for (const f of r.findings) {
|
|
77
|
+
const severity = mapSeverity(f.severity);
|
|
78
|
+
if (severity === null) continue;
|
|
79
|
+
findings.push({
|
|
80
|
+
category: "architecture",
|
|
81
|
+
severity,
|
|
82
|
+
code: `audit-types/typed-but-unimplemented-${f.severity.toLowerCase()}`,
|
|
83
|
+
gate: "audit-types",
|
|
84
|
+
message: `${f.package}: \`${f.interface}.${f.field}\` is typed in the public API but has ${f.refCount} non-type reference(s) in the package — likely typed-but-unimplemented.`,
|
|
85
|
+
location: {
|
|
86
|
+
path: join(cwd, f.declaredIn),
|
|
87
|
+
relPath: f.declaredIn,
|
|
88
|
+
line: f.declaredLine
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
findings,
|
|
94
|
+
scanned: results.length
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
const runAuditTypesGate = async (opts) => {
|
|
98
|
+
const start = Date.now();
|
|
99
|
+
const findings = [];
|
|
100
|
+
const args = [
|
|
101
|
+
"run",
|
|
102
|
+
join(opts.cwd, "scripts/audit-types.ts"),
|
|
103
|
+
"--json"
|
|
104
|
+
];
|
|
105
|
+
if (opts.packages && opts.packages.length > 0) args.push(...opts.packages);
|
|
106
|
+
else args.push("--all");
|
|
107
|
+
let scannedPackages = 0;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = _parseAuditTypesOutput(execFileSync(opts.bun ?? "bun", args, {
|
|
110
|
+
cwd: opts.cwd,
|
|
111
|
+
encoding: "utf8",
|
|
112
|
+
stdio: [
|
|
113
|
+
"pipe",
|
|
114
|
+
"pipe",
|
|
115
|
+
"pipe"
|
|
116
|
+
],
|
|
117
|
+
maxBuffer: 16 * 1024 * 1024
|
|
118
|
+
}), opts.cwd);
|
|
119
|
+
findings.push(...parsed.findings);
|
|
120
|
+
scannedPackages = parsed.scanned;
|
|
121
|
+
} catch (err) {
|
|
122
|
+
findings.push({
|
|
123
|
+
category: "architecture",
|
|
124
|
+
severity: "error",
|
|
125
|
+
code: "audit-types/gate-failed",
|
|
126
|
+
gate: "audit-types",
|
|
127
|
+
message: `audit-types gate failed to run: ${err.message}`
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
gate: "audit-types",
|
|
132
|
+
category: "architecture",
|
|
133
|
+
findings,
|
|
134
|
+
meta: {
|
|
135
|
+
scanned: scannedPackages,
|
|
136
|
+
elapsedMs: Date.now() - start
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
//#region src/doctor/gates/bundle-budgets.ts
|
|
143
|
+
/**
|
|
144
|
+
* bundle-budgets gate — programmatic API.
|
|
145
|
+
*
|
|
146
|
+
* Locks the gzipped main-entry size of every published `@pyreon/*`
|
|
147
|
+
* package against `scripts/bundle-budgets.json` (current + 25% headroom).
|
|
148
|
+
* Three classes of finding land here:
|
|
49
149
|
*
|
|
50
|
-
*
|
|
150
|
+
* 1. **violations** — package bundles past its budget (real regression).
|
|
151
|
+
* Severity: `error`. Code: `bundle-budgets/over-budget`.
|
|
152
|
+
* 2. **missing** — package has no entry in `bundle-budgets.json` yet
|
|
153
|
+
* (new published package — author needs to commit a budget).
|
|
154
|
+
* Severity: `warning`. Code: `bundle-budgets/missing-budget`.
|
|
155
|
+
* 3. **failures** — the bundler couldn't measure a package (unresolved
|
|
156
|
+
* transitive dep, build artifact issue). Severity: `error`. Code:
|
|
157
|
+
* `bundle-budgets/bundle-failed`. Surfaced as a finding rather than
|
|
158
|
+
* silently dropped — same lesson as PR #434.
|
|
159
|
+
*
|
|
160
|
+
* **Implementation note (subprocess adapter).** This gate invokes the
|
|
161
|
+
* standalone `scripts/check-bundle-budgets.ts` script via `--json` and
|
|
162
|
+
* parses the output. The script is 466 lines of bundler orchestration
|
|
163
|
+
* + AST-walking dep collection logic; extracting it surgically into a
|
|
164
|
+
* pure function carries too much risk for PR 1. The adapter shape lets
|
|
165
|
+
* the doctor aggregator consume the same `Finding[]` shape as the other
|
|
166
|
+
* gates; full extraction is a deferred follow-up (`pyreon doctor`
|
|
167
|
+
* doesn't care HOW the gate runs — only that it returns `GateResult`).
|
|
168
|
+
*
|
|
169
|
+
* The full-bundle measurement is the slowest gate (~15-30s against
|
|
170
|
+
* 50+ published packages). Doctor's default fast mode opts this gate
|
|
171
|
+
* OUT; `--full` enables it.
|
|
51
172
|
*/
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
173
|
+
const formatKB = (bytes) => `${(bytes / 1024).toFixed(2)} KB`;
|
|
174
|
+
/**
|
|
175
|
+
* Pure parse-and-map function — public so tests can exercise the JSON
|
|
176
|
+
* → `Finding[]` translation without spawning a subprocess. Returns the
|
|
177
|
+
* findings plus the count of packages scanned (measured + failures).
|
|
178
|
+
* Exported as `_internal` (unstable API surface — may move when PR 2
|
|
179
|
+
* lands the aggregator).
|
|
180
|
+
*/
|
|
181
|
+
const _parseBundleBudgetsOutput = (raw, cwd) => {
|
|
182
|
+
const result = JSON.parse(raw);
|
|
183
|
+
const findings = [];
|
|
184
|
+
const budgetsRelPath = "scripts/bundle-budgets.json";
|
|
185
|
+
const budgetsPath = join(cwd, budgetsRelPath);
|
|
186
|
+
for (const v of result.violations) findings.push({
|
|
187
|
+
category: "performance",
|
|
188
|
+
severity: "error",
|
|
189
|
+
code: "bundle-budgets/over-budget",
|
|
190
|
+
gate: "bundle-budgets",
|
|
191
|
+
message: `${v.name}: ${formatKB(v.current)} > budget ${formatKB(v.budget)} (over by ${formatKB(v.overBy)}, +${v.overByPct.toFixed(1)}%). If growth is intentional, bump the value in scripts/bundle-budgets.json — the bump itself is the PR signal.`,
|
|
192
|
+
location: {
|
|
193
|
+
path: budgetsPath,
|
|
194
|
+
relPath: budgetsRelPath
|
|
195
|
+
},
|
|
196
|
+
fix: `Run \`bun run check-bundle-budgets --update\` to regenerate budgets after intentional growth.`
|
|
197
|
+
});
|
|
198
|
+
for (const m of result.missing) findings.push({
|
|
199
|
+
category: "performance",
|
|
200
|
+
severity: "warning",
|
|
201
|
+
code: "bundle-budgets/missing-budget",
|
|
202
|
+
gate: "bundle-budgets",
|
|
203
|
+
message: `${m.name}: ${formatKB(m.current)} (no budget entry). New published package?`,
|
|
204
|
+
location: {
|
|
205
|
+
path: budgetsPath,
|
|
206
|
+
relPath: budgetsRelPath
|
|
207
|
+
},
|
|
208
|
+
fix: `Run \`bun run check-bundle-budgets --update\` and review the diff.`
|
|
209
|
+
});
|
|
210
|
+
for (const f of result.failures) findings.push({
|
|
211
|
+
category: "performance",
|
|
212
|
+
severity: "error",
|
|
213
|
+
code: "bundle-budgets/bundle-failed",
|
|
214
|
+
gate: "bundle-budgets",
|
|
215
|
+
message: `${f.name}: bundle failed — ${f.error.split("\n")[0]}. Likely an unresolved third-party dep that the auto-external scan missed.`
|
|
216
|
+
});
|
|
217
|
+
return {
|
|
218
|
+
findings,
|
|
219
|
+
scanned: result.measured.length + result.failures.length
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
const runBundleBudgetsGate = async (opts) => {
|
|
223
|
+
const start = Date.now();
|
|
224
|
+
const findings = [];
|
|
225
|
+
const scriptPath = join(opts.cwd, "scripts/check-bundle-budgets.ts");
|
|
226
|
+
let scannedPackages = 0;
|
|
227
|
+
try {
|
|
228
|
+
let out;
|
|
229
|
+
try {
|
|
230
|
+
out = execFileSync(opts.bun ?? "bun", [
|
|
231
|
+
"run",
|
|
232
|
+
scriptPath,
|
|
233
|
+
"--json"
|
|
234
|
+
], {
|
|
235
|
+
cwd: opts.cwd,
|
|
236
|
+
encoding: "utf8",
|
|
237
|
+
stdio: [
|
|
238
|
+
"pipe",
|
|
239
|
+
"pipe",
|
|
240
|
+
"pipe"
|
|
241
|
+
],
|
|
242
|
+
maxBuffer: 16 * 1024 * 1024
|
|
243
|
+
});
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const e = err;
|
|
246
|
+
if (e.stdout) out = typeof e.stdout === "string" ? e.stdout : e.stdout.toString("utf8");
|
|
247
|
+
else throw err;
|
|
67
248
|
}
|
|
249
|
+
const parsed = _parseBundleBudgetsOutput(out, opts.cwd);
|
|
250
|
+
findings.push(...parsed.findings);
|
|
251
|
+
scannedPackages = parsed.scanned;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
findings.push({
|
|
254
|
+
category: "performance",
|
|
255
|
+
severity: "error",
|
|
256
|
+
code: "bundle-budgets/gate-failed",
|
|
257
|
+
gate: "bundle-budgets",
|
|
258
|
+
message: `bundle-budgets gate failed to run: ${err.message}`
|
|
259
|
+
});
|
|
68
260
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
261
|
+
return {
|
|
262
|
+
gate: "bundle-budgets",
|
|
263
|
+
category: "performance",
|
|
264
|
+
findings,
|
|
265
|
+
meta: {
|
|
266
|
+
scanned: scannedPackages,
|
|
267
|
+
elapsedMs: Date.now() - start
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/doctor/gates/distribution.ts
|
|
274
|
+
/**
|
|
275
|
+
* Distribution-hygiene gate — programmatic API.
|
|
276
|
+
*
|
|
277
|
+
* Two static invariants every published `@pyreon/*` package must hold:
|
|
278
|
+
* 1. `sideEffects` field declared (bundler tree-shaking)
|
|
279
|
+
* 2. `!lib/** /*.map` excluded from `files` array (no source-map ship)
|
|
280
|
+
*
|
|
281
|
+
* Plus a live `npm pack --dry-run` probe of `@pyreon/reactivity` to
|
|
282
|
+
* verify the exclusion actually works at publish time (the `files`
|
|
283
|
+
* field is technically right but npm's interpretation can diverge).
|
|
284
|
+
*
|
|
285
|
+
* Pure function — the standalone script `scripts/check-distribution.ts`
|
|
286
|
+
* is a thin wrapper that calls this and formats the output.
|
|
287
|
+
*
|
|
288
|
+
* Mirrors the script logic 1:1 — no behavior change, just makes the
|
|
289
|
+
* findings programmatically consumable by `pyreon doctor` aggregation
|
|
290
|
+
* (PR 2).
|
|
291
|
+
*/
|
|
292
|
+
const findPackages = (repoRoot) => {
|
|
293
|
+
const result = [];
|
|
294
|
+
const packagesRoot = join(repoRoot, "packages");
|
|
295
|
+
if (!existsSync(packagesRoot)) return result;
|
|
296
|
+
for (const cat of readdirSync(packagesRoot)) {
|
|
297
|
+
const catDir = join(packagesRoot, cat);
|
|
298
|
+
let pkgs;
|
|
299
|
+
try {
|
|
300
|
+
pkgs = readdirSync(catDir);
|
|
301
|
+
} catch {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
for (const pkg of pkgs) {
|
|
305
|
+
const pkgDir = join(catDir, pkg);
|
|
306
|
+
const pjPath = join(pkgDir, "package.json");
|
|
307
|
+
if (!existsSync(pjPath)) continue;
|
|
308
|
+
let pj;
|
|
309
|
+
try {
|
|
310
|
+
pj = JSON.parse(readFileSync(pjPath, "utf8"));
|
|
311
|
+
} catch {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (pj.private) continue;
|
|
315
|
+
if (typeof pj.name !== "string") continue;
|
|
316
|
+
result.push({
|
|
317
|
+
name: pj.name,
|
|
318
|
+
dir: pkgDir,
|
|
319
|
+
pj
|
|
320
|
+
});
|
|
78
321
|
}
|
|
79
322
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
323
|
+
return result;
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Pure parse-and-emit function for the `npm pack --dry-run` JSON
|
|
327
|
+
* output. Exported as `_internal` so tests can exercise the .map-
|
|
328
|
+
* detection + finding emission path without spawning the live npm
|
|
329
|
+
* subprocess — under CI parallel load the real probe runs 100s+,
|
|
330
|
+
* tripping the per-test timeout. Returns the finding (if any) for
|
|
331
|
+
* the caller to push onto the gate's findings array.
|
|
332
|
+
*/
|
|
333
|
+
const _detectMapsInPackOutput = (raw, cwd, probe, probePackage) => {
|
|
334
|
+
const maps = (JSON.parse(raw)[0]?.files.map((f) => f.path) ?? []).filter((f) => f.endsWith(".map"));
|
|
335
|
+
if (maps.length === 0) return null;
|
|
336
|
+
return {
|
|
337
|
+
category: "architecture",
|
|
338
|
+
severity: "error",
|
|
339
|
+
code: "distribution/tarball-contains-map",
|
|
340
|
+
gate: "distribution",
|
|
341
|
+
message: `${probePackage}: npm pack --dry-run reported ${maps.length} .map file(s) in the would-be-published tarball: ${maps.slice(0, 3).join(", ")}${maps.length > 3 ? ", …" : ""}`,
|
|
342
|
+
location: {
|
|
343
|
+
path: join(probe.dir, "package.json"),
|
|
344
|
+
relPath: relative(cwd, join(probe.dir, "package.json"))
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
};
|
|
348
|
+
/**
|
|
349
|
+
* Run the distribution-hygiene gate. Returns findings + metadata.
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* const result = await runDistributionGate({ cwd: process.cwd() })
|
|
353
|
+
* if (result.findings.length > 0) process.exit(1)
|
|
354
|
+
*/
|
|
355
|
+
const runDistributionGate = async (opts) => {
|
|
356
|
+
const start = Date.now();
|
|
357
|
+
const probePackage = opts.probePackage ?? "@pyreon/reactivity";
|
|
358
|
+
const findings = [];
|
|
359
|
+
const packages = findPackages(opts.cwd);
|
|
360
|
+
for (const p of packages) {
|
|
361
|
+
if (p.pj.sideEffects === void 0) findings.push({
|
|
362
|
+
category: "architecture",
|
|
363
|
+
severity: "error",
|
|
364
|
+
code: "distribution/missing-sideEffects",
|
|
365
|
+
gate: "distribution",
|
|
366
|
+
message: `${p.name} package.json must declare \`sideEffects\` (use \`false\` for pure libraries, an array of paths for entry-point side effects) — required for bundler tree-shaking.`,
|
|
367
|
+
location: {
|
|
368
|
+
path: join(p.dir, "package.json"),
|
|
369
|
+
relPath: relative(opts.cwd, join(p.dir, "package.json"))
|
|
370
|
+
},
|
|
371
|
+
fix: "Add `\"sideEffects\": false` to package.json"
|
|
372
|
+
});
|
|
373
|
+
if (Array.isArray(p.pj.files) && p.pj.files.includes("lib")) {
|
|
374
|
+
if (!p.pj.files.includes("!lib/**/*.map")) findings.push({
|
|
375
|
+
category: "architecture",
|
|
376
|
+
severity: "error",
|
|
377
|
+
code: "distribution/missing-map-exclusion",
|
|
378
|
+
gate: "distribution",
|
|
379
|
+
message: `${p.name} package.json \`files\` must include \`"!lib/**/*.map"\` to exclude source maps from the published tarball.`,
|
|
380
|
+
location: {
|
|
381
|
+
path: join(p.dir, "package.json"),
|
|
382
|
+
relPath: relative(opts.cwd, join(p.dir, "package.json"))
|
|
383
|
+
},
|
|
384
|
+
fix: "Add `\"!lib/**/*.map\"` to the `files` array"
|
|
385
|
+
});
|
|
89
386
|
}
|
|
90
387
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
388
|
+
if (!opts.skipPackProbe) {
|
|
389
|
+
const probe = packages.find((p) => p.name === probePackage);
|
|
390
|
+
if (probe) try {
|
|
391
|
+
const finding = _detectMapsInPackOutput(execFileSync("npm", [
|
|
392
|
+
"pack",
|
|
393
|
+
"--dry-run",
|
|
394
|
+
"--json"
|
|
395
|
+
], {
|
|
396
|
+
cwd: probe.dir,
|
|
397
|
+
encoding: "utf8",
|
|
398
|
+
stdio: [
|
|
399
|
+
"pipe",
|
|
400
|
+
"pipe",
|
|
401
|
+
"pipe"
|
|
402
|
+
]
|
|
403
|
+
}), opts.cwd, probe, probePackage);
|
|
404
|
+
if (finding) findings.push(finding);
|
|
405
|
+
} catch {}
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
gate: "distribution",
|
|
409
|
+
category: "architecture",
|
|
410
|
+
findings,
|
|
411
|
+
meta: {
|
|
412
|
+
scanned: packages.length,
|
|
413
|
+
elapsedMs: Date.now() - start
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
//#endregion
|
|
419
|
+
//#region src/doctor/gates/doc-claims.ts
|
|
420
|
+
/**
|
|
421
|
+
* Doc-claims gate — programmatic API.
|
|
422
|
+
*
|
|
423
|
+
* Catches numeric-drift between human-written docs and the underlying
|
|
424
|
+
* source of truth. Recurring failure mode: a hand-quoted count
|
|
425
|
+
* ("34 signal-based hooks…") appears in 3-5 places; one bumps when a
|
|
426
|
+
* new hook lands, the others don't. Audit caught the README claiming
|
|
427
|
+
* 16 vs actual 34 — drift that shipped to users for weeks.
|
|
428
|
+
*
|
|
429
|
+
* Pure function — `scripts/check-doc-claims.ts` wraps this for the
|
|
430
|
+
* standalone CLI invocation.
|
|
431
|
+
*/
|
|
432
|
+
const countHookExports = (repoRoot) => {
|
|
433
|
+
const indexPath = join(repoRoot, "packages/fundamentals/hooks/src/index.ts");
|
|
434
|
+
if (!existsSync(indexPath)) return 0;
|
|
435
|
+
const matched = readFileSync(indexPath, "utf8").matchAll(/^export \{ (?:default as )?(use[A-Z][a-zA-Z]+) \}/gm);
|
|
436
|
+
const names = /* @__PURE__ */ new Set();
|
|
437
|
+
for (const [, name] of matched) if (name) names.add(name);
|
|
438
|
+
return names.size;
|
|
439
|
+
};
|
|
440
|
+
const countDocPages = (repoRoot) => {
|
|
441
|
+
const docsDir = join(repoRoot, "docs");
|
|
442
|
+
if (!existsSync(docsDir)) return 0;
|
|
443
|
+
let count = 0;
|
|
444
|
+
const walk = (dir) => {
|
|
445
|
+
let entries;
|
|
446
|
+
try {
|
|
447
|
+
entries = readdirSync(dir);
|
|
448
|
+
} catch {
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
for (const name of entries) {
|
|
452
|
+
if (name === "node_modules" || name === "cache" || name === "dist" || name.startsWith(".")) continue;
|
|
453
|
+
const full = join(dir, name);
|
|
454
|
+
let isDir = false;
|
|
455
|
+
try {
|
|
456
|
+
isDir = statSync(full).isDirectory();
|
|
457
|
+
} catch {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
if (isDir) walk(full);
|
|
461
|
+
else if (name.endsWith(".md")) count++;
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
walk(docsDir);
|
|
465
|
+
return count;
|
|
466
|
+
};
|
|
467
|
+
const checks = [{
|
|
468
|
+
name: "hook export count",
|
|
469
|
+
codeId: "hook-count",
|
|
470
|
+
actual: countHookExports,
|
|
471
|
+
claims: [
|
|
472
|
+
{
|
|
473
|
+
file: "packages/fundamentals/hooks/README.md",
|
|
474
|
+
pattern: /^(\d+) signal-based reactive utilities/m
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
file: "packages/fundamentals/hooks/src/manifest.ts",
|
|
478
|
+
pattern: /'(\d+) signal-based hooks:/
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
file: "packages/fundamentals/hooks/src/manifest.ts",
|
|
482
|
+
pattern: /Signal-based hooks for Pyreon — (\d+) reactive primitives/
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
file: "CLAUDE.md",
|
|
486
|
+
pattern: /\| `@pyreon\/hooks` *\| (\d+) signal-based hooks/,
|
|
487
|
+
rejectHedged: /\| `@pyreon\/hooks` *\| (\d+)\+ signal-based hooks/
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
file: "CLAUDE.md",
|
|
491
|
+
pattern: /^- (\d+) signal-based hooks across 6 categories/m
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
file: "docs/docs/index.md",
|
|
495
|
+
pattern: /\| (\d+) signal-based hooks for common UI patterns/
|
|
496
|
+
}
|
|
497
|
+
]
|
|
498
|
+
}, {
|
|
499
|
+
name: "doc page count",
|
|
500
|
+
codeId: "doc-count",
|
|
501
|
+
actual: countDocPages,
|
|
502
|
+
claims: [{
|
|
503
|
+
file: "CLAUDE.md",
|
|
504
|
+
pattern: /(\d+) doc pages covering all packages/
|
|
505
|
+
}]
|
|
506
|
+
}];
|
|
507
|
+
const runDocClaimsGate = async (opts) => {
|
|
508
|
+
const start = Date.now();
|
|
509
|
+
const findings = [];
|
|
510
|
+
if (!checks.some((c) => c.claims.some((cl) => existsSync(join(opts.cwd, cl.file))))) return {
|
|
511
|
+
gate: "doc-claims",
|
|
512
|
+
category: "documentation",
|
|
513
|
+
findings: [],
|
|
514
|
+
meta: {
|
|
515
|
+
scanned: 0,
|
|
516
|
+
elapsedMs: Date.now() - start,
|
|
517
|
+
skipped: true,
|
|
518
|
+
skipReason: "no claim sites found in this project (gate targets Pyreon monorepo paths)"
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
for (const check of checks) {
|
|
522
|
+
const actual = check.actual(opts.cwd);
|
|
523
|
+
for (const claim of check.claims) {
|
|
524
|
+
const filePath = join(opts.cwd, claim.file);
|
|
525
|
+
const relPath = claim.file;
|
|
526
|
+
if (!existsSync(filePath)) {
|
|
527
|
+
findings.push({
|
|
528
|
+
category: "documentation",
|
|
529
|
+
severity: "error",
|
|
530
|
+
code: `doc-claims/${check.codeId}-file-missing`,
|
|
531
|
+
gate: "doc-claims",
|
|
532
|
+
message: `${check.name}: claim file ${claim.file} not found (claim may have been deleted or moved). Actual: ${actual}.`,
|
|
533
|
+
location: {
|
|
534
|
+
path: filePath,
|
|
535
|
+
relPath
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const content = readFileSync(filePath, "utf8");
|
|
541
|
+
if (claim.rejectHedged) {
|
|
542
|
+
const hedged = content.match(claim.rejectHedged);
|
|
543
|
+
if (hedged?.[1]) {
|
|
544
|
+
findings.push({
|
|
545
|
+
category: "documentation",
|
|
546
|
+
severity: "error",
|
|
547
|
+
code: `doc-claims/${check.codeId}-hedged`,
|
|
548
|
+
gate: "doc-claims",
|
|
549
|
+
message: `${check.name}: rejected hedged claim "${hedged[1]}+" in ${claim.file} — write the exact count instead. Actual: ${actual}.`,
|
|
550
|
+
location: {
|
|
551
|
+
path: filePath,
|
|
552
|
+
relPath
|
|
553
|
+
},
|
|
554
|
+
fix: `Replace "${hedged[1]}+" with "${actual}"`
|
|
555
|
+
});
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const claimedRaw = content.match(claim.pattern)?.[1];
|
|
560
|
+
if (!claimedRaw) {
|
|
561
|
+
findings.push({
|
|
562
|
+
category: "documentation",
|
|
563
|
+
severity: "warning",
|
|
564
|
+
code: `doc-claims/${check.codeId}-pattern-miss`,
|
|
565
|
+
gate: "doc-claims",
|
|
566
|
+
message: `${check.name}: pattern not found in ${claim.file} (claim was likely deleted or rephrased). Actual: ${actual}.`,
|
|
567
|
+
location: {
|
|
568
|
+
path: filePath,
|
|
569
|
+
relPath
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const claimed = parseInt(claimedRaw, 10);
|
|
575
|
+
if (claimed !== actual) findings.push({
|
|
576
|
+
category: "documentation",
|
|
577
|
+
severity: "error",
|
|
578
|
+
code: `doc-claims/${check.codeId}-drift`,
|
|
579
|
+
gate: "doc-claims",
|
|
580
|
+
message: `${check.name}: ${claim.file} claims ${claimed}, actual ${actual}.`,
|
|
581
|
+
location: {
|
|
582
|
+
path: filePath,
|
|
583
|
+
relPath
|
|
584
|
+
},
|
|
585
|
+
fix: `Update the claim in ${claim.file} from ${claimed} to ${actual}`
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
gate: "doc-claims",
|
|
591
|
+
category: "documentation",
|
|
592
|
+
findings,
|
|
593
|
+
meta: {
|
|
594
|
+
scanned: checks.reduce((n, c) => n + c.claims.length, 0),
|
|
595
|
+
elapsedMs: Date.now() - start
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
//#endregion
|
|
601
|
+
//#region src/doctor/utils/walk.ts
|
|
602
|
+
/**
|
|
603
|
+
* Shared source-file walker for the per-file scanning gates
|
|
604
|
+
* (react-patterns, pyreon-patterns).
|
|
605
|
+
*
|
|
606
|
+
* The walker skips the standard non-source dirs (`node_modules`,
|
|
607
|
+
* `dist`, `lib`, `.git`, etc.) and matches `.ts` / `.tsx` / `.js` /
|
|
608
|
+
* `.jsx`. It's a thin wrapper around the original `collectSourceFiles`
|
|
609
|
+
* that lived in `doctor.ts` pre-PR-2; extracted here so any gate can
|
|
610
|
+
* use it without import-cycling through the doctor module.
|
|
611
|
+
*/
|
|
612
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
94
613
|
".tsx",
|
|
95
614
|
".jsx",
|
|
96
615
|
".ts",
|
|
97
616
|
".js"
|
|
98
617
|
]);
|
|
99
|
-
const
|
|
618
|
+
const IGNORE_DIRS = new Set([
|
|
100
619
|
"node_modules",
|
|
101
620
|
"dist",
|
|
102
621
|
"lib",
|
|
@@ -105,11 +624,11 @@ const sourceIgnoreDirs = new Set([
|
|
|
105
624
|
".next",
|
|
106
625
|
"build"
|
|
107
626
|
]);
|
|
108
|
-
|
|
627
|
+
const shouldSkipDirEntry = (entry) => {
|
|
109
628
|
if (!entry.isDirectory()) return false;
|
|
110
|
-
return entry.name.startsWith(".") ||
|
|
111
|
-
}
|
|
112
|
-
|
|
629
|
+
return entry.name.startsWith(".") || IGNORE_DIRS.has(entry.name);
|
|
630
|
+
};
|
|
631
|
+
const walk = (dir, results) => {
|
|
113
632
|
let entries;
|
|
114
633
|
try {
|
|
115
634
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -119,125 +638,892 @@ function walkSourceFiles(dir, results) {
|
|
|
119
638
|
for (const entry of entries) {
|
|
120
639
|
if (shouldSkipDirEntry(entry)) continue;
|
|
121
640
|
const fullPath = path.join(dir, entry.name);
|
|
122
|
-
if (entry.isDirectory())
|
|
123
|
-
else if (entry.isFile() &&
|
|
641
|
+
if (entry.isDirectory()) walk(fullPath, results);
|
|
642
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) results.push(fullPath);
|
|
124
643
|
}
|
|
125
|
-
}
|
|
126
|
-
|
|
644
|
+
};
|
|
645
|
+
const collectSourceFiles = (cwd) => {
|
|
127
646
|
const results = [];
|
|
128
|
-
|
|
647
|
+
walk(cwd, results);
|
|
129
648
|
return results;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/doctor/gates/react-patterns.ts
|
|
653
|
+
/**
|
|
654
|
+
* react-patterns gate — wraps `@pyreon/compiler:detectReactPatterns`.
|
|
655
|
+
*
|
|
656
|
+
* Catches "coming from React" mistakes: `useState` / `useEffect`,
|
|
657
|
+
* `className` / `htmlFor`, `onChange` on inputs, React-package
|
|
658
|
+
* imports, etc. The detector is already used standalone by the
|
|
659
|
+
* pre-PR-2 `pyreon doctor` legacy path; this adapter just emits its
|
|
660
|
+
* findings in the unified `Finding[]` shape so the v2 aggregator can
|
|
661
|
+
* fold them into the score.
|
|
662
|
+
*
|
|
663
|
+
* `--fix` mode delegates to `migrateReactCode` and reports BOTH the
|
|
664
|
+
* applied changes (as `info` findings) AND any residual diagnostics
|
|
665
|
+
* the migration didn't auto-resolve.
|
|
666
|
+
*/
|
|
667
|
+
const runReactPatternsGate = async (opts) => {
|
|
668
|
+
const start = Date.now();
|
|
669
|
+
const findings = [];
|
|
670
|
+
const files = collectSourceFiles(opts.cwd);
|
|
671
|
+
for (const file of files) {
|
|
672
|
+
let code;
|
|
673
|
+
try {
|
|
674
|
+
code = fs.readFileSync(file, "utf-8");
|
|
675
|
+
} catch {
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
if (!hasReactPatterns(code)) continue;
|
|
679
|
+
const relPath = path.relative(opts.cwd, file);
|
|
680
|
+
if (opts.fix) {
|
|
681
|
+
const migrated = migrateReactCode(code, relPath);
|
|
682
|
+
if (migrated.changes.length > 0) {
|
|
683
|
+
fs.writeFileSync(file, migrated.code, "utf-8");
|
|
684
|
+
for (const ch of migrated.changes) findings.push({
|
|
685
|
+
category: "correctness",
|
|
686
|
+
severity: "info",
|
|
687
|
+
code: `react-patterns/auto-fixed-${ch.type}`,
|
|
688
|
+
gate: "react-patterns",
|
|
689
|
+
message: `Auto-fixed: ${ch.description}`,
|
|
690
|
+
location: {
|
|
691
|
+
path: file,
|
|
692
|
+
relPath,
|
|
693
|
+
line: ch.line
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
const remaining = detectReactPatterns(migrated.code, relPath);
|
|
698
|
+
for (const diag of remaining) findings.push({
|
|
699
|
+
category: "correctness",
|
|
700
|
+
severity: diag.fixable ? "warning" : "error",
|
|
701
|
+
code: `react-patterns/${diag.code}`,
|
|
702
|
+
gate: "react-patterns",
|
|
703
|
+
message: diag.message,
|
|
704
|
+
location: {
|
|
705
|
+
path: file,
|
|
706
|
+
relPath,
|
|
707
|
+
line: diag.line,
|
|
708
|
+
column: diag.column
|
|
709
|
+
},
|
|
710
|
+
fix: diag.suggested,
|
|
711
|
+
fixable: diag.fixable
|
|
712
|
+
});
|
|
713
|
+
} else {
|
|
714
|
+
const diagnostics = detectReactPatterns(code, relPath);
|
|
715
|
+
for (const diag of diagnostics) findings.push({
|
|
716
|
+
category: "correctness",
|
|
717
|
+
severity: diag.fixable ? "warning" : "error",
|
|
718
|
+
code: `react-patterns/${diag.code}`,
|
|
719
|
+
gate: "react-patterns",
|
|
720
|
+
message: diag.message,
|
|
721
|
+
location: {
|
|
722
|
+
path: file,
|
|
723
|
+
relPath,
|
|
724
|
+
line: diag.line,
|
|
725
|
+
column: diag.column
|
|
726
|
+
},
|
|
727
|
+
fix: diag.suggested,
|
|
728
|
+
fixable: diag.fixable
|
|
729
|
+
});
|
|
730
|
+
}
|
|
140
731
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
result: {
|
|
150
|
-
file: relPath,
|
|
151
|
-
diagnostics: remaining,
|
|
152
|
-
fixed: migrated.changes.length > 0
|
|
153
|
-
},
|
|
154
|
-
fixCount: migrated.changes.length
|
|
732
|
+
return {
|
|
733
|
+
gate: "react-patterns",
|
|
734
|
+
category: "correctness",
|
|
735
|
+
findings,
|
|
736
|
+
meta: {
|
|
737
|
+
scanned: files.length,
|
|
738
|
+
elapsedMs: Date.now() - start
|
|
739
|
+
}
|
|
155
740
|
};
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/doctor/gates/pyreon-patterns.ts
|
|
745
|
+
/**
|
|
746
|
+
* pyreon-patterns gate — wraps `@pyreon/compiler:detectPyreonPatterns`.
|
|
747
|
+
*
|
|
748
|
+
* Catches "using Pyreon wrong" mistakes — 12 detector codes today
|
|
749
|
+
* (for-missing-by, props-destructured, signal-write-as-call, etc.).
|
|
750
|
+
* The detector matches the anti-patterns catalogue in
|
|
751
|
+
* `.claude/rules/anti-patterns.md` (entries tagged `[detector: ...]`)
|
|
752
|
+
* 1:1 — so the user reading the doctor output gets the same advice
|
|
753
|
+
* as someone running `validate` via MCP.
|
|
754
|
+
*/
|
|
755
|
+
const runPyreonPatternsGate = async (opts) => {
|
|
756
|
+
const start = Date.now();
|
|
757
|
+
const findings = [];
|
|
758
|
+
const files = collectSourceFiles(opts.cwd);
|
|
759
|
+
for (const file of files) {
|
|
760
|
+
let code;
|
|
761
|
+
try {
|
|
762
|
+
code = fs.readFileSync(file, "utf-8");
|
|
763
|
+
} catch {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (!hasPyreonPatterns(code)) continue;
|
|
767
|
+
const relPath = path.relative(opts.cwd, file);
|
|
768
|
+
const diagnostics = detectPyreonPatterns(code, relPath);
|
|
769
|
+
for (const diag of diagnostics) findings.push({
|
|
770
|
+
category: "correctness",
|
|
771
|
+
severity: "warning",
|
|
772
|
+
code: `pyreon-patterns/${diag.code}`,
|
|
773
|
+
gate: "pyreon-patterns",
|
|
774
|
+
message: diag.message,
|
|
775
|
+
location: {
|
|
776
|
+
path: file,
|
|
777
|
+
relPath,
|
|
778
|
+
line: diag.line,
|
|
779
|
+
column: diag.column
|
|
780
|
+
},
|
|
781
|
+
fix: diag.suggested
|
|
782
|
+
});
|
|
783
|
+
}
|
|
156
784
|
return {
|
|
157
|
-
|
|
158
|
-
|
|
785
|
+
gate: "pyreon-patterns",
|
|
786
|
+
category: "correctness",
|
|
787
|
+
findings,
|
|
788
|
+
meta: {
|
|
789
|
+
scanned: files.length,
|
|
790
|
+
elapsedMs: Date.now() - start
|
|
791
|
+
}
|
|
159
792
|
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
//#endregion
|
|
796
|
+
//#region src/doctor/gates/audit-tests.ts
|
|
797
|
+
/**
|
|
798
|
+
* audit-tests gate — wraps `@pyreon/compiler:auditTestEnvironment`.
|
|
799
|
+
*
|
|
800
|
+
* Catches mock-vnode test patterns (the PR #197 bug class — tests
|
|
801
|
+
* that hand-construct `{ type, props, children }` literals or use a
|
|
802
|
+
* `vnode()` helper instead of going through real `h()` from
|
|
803
|
+
* `@pyreon/core`). Three risk tiers (HIGH / MEDIUM / LOW) from the
|
|
804
|
+
* balance of mockVNodeLiteralCount + mockHelperCount +
|
|
805
|
+
* mockHelperCallCount + realHCallCount + importsH. The adapter
|
|
806
|
+
* maps tier → severity (high=error, medium=warning, low=info).
|
|
807
|
+
*/
|
|
808
|
+
const SEVERITY_BY_RISK = {
|
|
809
|
+
high: "error",
|
|
810
|
+
medium: "warning",
|
|
811
|
+
low: "info"
|
|
812
|
+
};
|
|
813
|
+
const RISK_RANK = {
|
|
814
|
+
high: 3,
|
|
815
|
+
medium: 2,
|
|
816
|
+
low: 1
|
|
817
|
+
};
|
|
818
|
+
const runAuditTestsGate = async (opts) => {
|
|
819
|
+
const start = Date.now();
|
|
820
|
+
const findings = [];
|
|
821
|
+
const minRank = RISK_RANK[opts.minRisk ?? "medium"] ?? 0;
|
|
822
|
+
const result = auditTestEnvironment(opts.cwd);
|
|
823
|
+
for (const entry of result.entries) {
|
|
824
|
+
if ((RISK_RANK[entry.risk] ?? 0) < minRank) continue;
|
|
825
|
+
const severity = SEVERITY_BY_RISK[entry.risk] ?? "warning";
|
|
826
|
+
findings.push({
|
|
827
|
+
category: "testing",
|
|
828
|
+
severity,
|
|
829
|
+
code: `audit-tests/mock-vnode-${entry.risk}`,
|
|
830
|
+
gate: "audit-tests",
|
|
831
|
+
message: `Mock-vnode test pattern (risk: ${entry.risk}). Literals: ${entry.mockVNodeLiteralCount}, helper defs: ${entry.mockHelperCount}, helper calls: ${entry.mockHelperCallCount}, real h() calls: ${entry.realHCallCount}. ${entry.realHCallCount === 0 ? "No real-h() coverage — every contract assertion is mock-only." : "Has real-h() coverage but mock-vnode patterns still dominate."}`,
|
|
832
|
+
location: {
|
|
833
|
+
path: entry.path,
|
|
834
|
+
relPath: entry.relPath
|
|
835
|
+
}
|
|
836
|
+
});
|
|
167
837
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
838
|
+
return {
|
|
839
|
+
gate: "audit-tests",
|
|
840
|
+
category: "testing",
|
|
841
|
+
findings,
|
|
842
|
+
meta: {
|
|
843
|
+
scanned: result.entries.length,
|
|
844
|
+
elapsedMs: Date.now() - start
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/doctor/gates/islands-audit.ts
|
|
851
|
+
/**
|
|
852
|
+
* islands-audit gate — wraps `@pyreon/compiler:auditIslands`.
|
|
853
|
+
*
|
|
854
|
+
* Project-wide cross-file detectors for the island architecture
|
|
855
|
+
* (duplicate names, dead islands, registry drift, nested islands,
|
|
856
|
+
* never-with-registry-entry). Per-finding severity is derived from
|
|
857
|
+
* the finding code: `dead-island` is a warning (might be intentional
|
|
858
|
+
* during refactor), everything else is an error (silent runtime
|
|
859
|
+
* failure mode).
|
|
860
|
+
*/
|
|
861
|
+
const SEVERITY_BY_CODE$1 = {
|
|
862
|
+
"duplicate-name": "error",
|
|
863
|
+
"never-with-registry-entry": "error",
|
|
864
|
+
"registry-mismatch": "error",
|
|
865
|
+
"nested-island": "error",
|
|
866
|
+
"dead-island": "warning"
|
|
867
|
+
};
|
|
868
|
+
const runIslandsAuditGate = async (opts) => {
|
|
869
|
+
const start = Date.now();
|
|
870
|
+
const findings = [];
|
|
871
|
+
const result = auditIslands(opts.cwd);
|
|
872
|
+
for (const f of result.findings) findings.push({
|
|
873
|
+
category: "architecture",
|
|
874
|
+
severity: SEVERITY_BY_CODE$1[f.code] ?? "error",
|
|
875
|
+
code: `islands-audit/${f.code}`,
|
|
876
|
+
gate: "islands-audit",
|
|
877
|
+
message: f.message,
|
|
878
|
+
location: {
|
|
879
|
+
path: f.location.path,
|
|
880
|
+
relPath: f.location.relPath,
|
|
881
|
+
line: f.location.line,
|
|
882
|
+
column: f.location.column
|
|
883
|
+
},
|
|
884
|
+
relatedLocations: f.related?.map((r) => ({
|
|
885
|
+
path: r.path,
|
|
886
|
+
relPath: r.relPath,
|
|
887
|
+
line: r.line,
|
|
888
|
+
column: r.column
|
|
889
|
+
}))
|
|
890
|
+
});
|
|
891
|
+
return {
|
|
892
|
+
gate: "islands-audit",
|
|
893
|
+
category: "architecture",
|
|
894
|
+
findings,
|
|
895
|
+
meta: {
|
|
896
|
+
scanned: result.findings.length,
|
|
897
|
+
elapsedMs: Date.now() - start
|
|
898
|
+
}
|
|
174
899
|
};
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
//#endregion
|
|
903
|
+
//#region src/doctor/gates/ssg-audit.ts
|
|
904
|
+
/**
|
|
905
|
+
* ssg-audit gate — wraps `@pyreon/compiler:auditSsg`.
|
|
906
|
+
*
|
|
907
|
+
* SSG / ISR convention checker (M3.4): `_404.tsx` placement, dynamic
|
|
908
|
+
* routes missing `getStaticPaths`, non-literal revalidate exports.
|
|
909
|
+
* Severities mirror the gate's intent: missing-getStaticPaths is a
|
|
910
|
+
* warn (legit under `mode: 'ssr' | 'isr'`), the other two are errors
|
|
911
|
+
* (silently broken under `mode: 'ssg'`).
|
|
912
|
+
*/
|
|
913
|
+
const SEVERITY_BY_CODE = {
|
|
914
|
+
"404-outside-layout-dir": "error",
|
|
915
|
+
"dynamic-route-missing-get-static-paths": "warning",
|
|
916
|
+
"non-literal-revalidate-export": "error"
|
|
917
|
+
};
|
|
918
|
+
const runSsgAuditGate = async (opts) => {
|
|
919
|
+
const start = Date.now();
|
|
920
|
+
const findings = [];
|
|
921
|
+
const result = auditSsg(opts.cwd);
|
|
922
|
+
for (const f of result.findings) findings.push({
|
|
923
|
+
category: "architecture",
|
|
924
|
+
severity: SEVERITY_BY_CODE[f.code] ?? "error",
|
|
925
|
+
code: `ssg-audit/${f.code}`,
|
|
926
|
+
gate: "ssg-audit",
|
|
927
|
+
message: f.message,
|
|
928
|
+
location: {
|
|
929
|
+
path: f.location.path,
|
|
930
|
+
relPath: f.location.relPath,
|
|
931
|
+
line: f.location.line,
|
|
932
|
+
column: f.location.column
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
return {
|
|
936
|
+
gate: "ssg-audit",
|
|
937
|
+
category: "architecture",
|
|
938
|
+
findings,
|
|
939
|
+
meta: {
|
|
940
|
+
scanned: result.findings.length,
|
|
941
|
+
elapsedMs: Date.now() - start
|
|
942
|
+
}
|
|
943
|
+
};
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/doctor/gates/lint.ts
|
|
948
|
+
/**
|
|
949
|
+
* lint gate — wraps `@pyreon/lint:lint`.
|
|
950
|
+
*
|
|
951
|
+
* Runs the project's configured Pyreon lint rules across the source
|
|
952
|
+
* tree. Per-finding category is derived from the rule ID's prefix
|
|
953
|
+
* (the lint rule categories: reactivity, jsx, lifecycle, performance,
|
|
954
|
+
* ssr, architecture, store, form, styling, hooks, accessibility,
|
|
955
|
+
* router, ssg) — `performance` rules emit `category: 'performance'`,
|
|
956
|
+
* `architecture` rules emit `category: 'architecture'`, the rest fold
|
|
957
|
+
* to `'correctness'` since they're all "your code is broken in some
|
|
958
|
+
* way" findings from the doctor's perspective.
|
|
959
|
+
*
|
|
960
|
+
* Severity passes through as-is from lint's `Diagnostic.severity`
|
|
961
|
+
* ('error' | 'warning' | 'info' all map 1:1 to the doctor severity
|
|
962
|
+
* shape).
|
|
963
|
+
*/
|
|
964
|
+
const mapLintSeverity = (s) => {
|
|
965
|
+
if (s === "error") return "error";
|
|
966
|
+
if (s === "warn") return "warning";
|
|
967
|
+
if (s === "info") return "info";
|
|
175
968
|
return null;
|
|
969
|
+
};
|
|
970
|
+
const RULE_CATEGORY = (() => {
|
|
971
|
+
const map = /* @__PURE__ */ new Map();
|
|
972
|
+
for (const rule of allRules) {
|
|
973
|
+
const cat = mapLintCategory(rule.meta.category);
|
|
974
|
+
map.set(rule.meta.id, cat);
|
|
975
|
+
}
|
|
976
|
+
return map;
|
|
977
|
+
})();
|
|
978
|
+
function mapLintCategory(c) {
|
|
979
|
+
switch (c) {
|
|
980
|
+
case "performance": return "performance";
|
|
981
|
+
case "architecture":
|
|
982
|
+
case "ssr":
|
|
983
|
+
case "ssg":
|
|
984
|
+
case "router": return "architecture";
|
|
985
|
+
case "styling":
|
|
986
|
+
case "accessibility": return "architecture";
|
|
987
|
+
default: return "correctness";
|
|
988
|
+
}
|
|
176
989
|
}
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
990
|
+
const runLintGate = async (opts) => {
|
|
991
|
+
const start = Date.now();
|
|
992
|
+
const findings = [];
|
|
993
|
+
const result = await lint({
|
|
994
|
+
paths: [opts.cwd],
|
|
995
|
+
fix: opts.fix ?? false
|
|
996
|
+
});
|
|
997
|
+
for (const fileResult of result.files) for (const diag of fileResult.diagnostics) {
|
|
998
|
+
const severity = mapLintSeverity(diag.severity);
|
|
999
|
+
if (severity === null) continue;
|
|
1000
|
+
const category = RULE_CATEGORY.get(diag.ruleId) ?? "correctness";
|
|
1001
|
+
findings.push({
|
|
1002
|
+
category,
|
|
1003
|
+
severity,
|
|
1004
|
+
code: `lint/${diag.ruleId}`,
|
|
1005
|
+
gate: "lint",
|
|
1006
|
+
message: diag.message,
|
|
1007
|
+
location: {
|
|
1008
|
+
path: fileResult.filePath,
|
|
1009
|
+
relPath: path.relative(opts.cwd, fileResult.filePath),
|
|
1010
|
+
line: diag.loc.line,
|
|
1011
|
+
column: diag.loc.column
|
|
1012
|
+
},
|
|
1013
|
+
fixable: diag.fix !== void 0
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
for (const cd of result.configDiagnostics) {
|
|
1017
|
+
const severity = mapLintSeverity(cd.severity);
|
|
1018
|
+
if (severity === null) continue;
|
|
1019
|
+
findings.push({
|
|
1020
|
+
category: "architecture",
|
|
1021
|
+
severity,
|
|
1022
|
+
code: `lint/config-${cd.ruleId}`,
|
|
1023
|
+
gate: "lint",
|
|
1024
|
+
message: cd.message
|
|
1025
|
+
});
|
|
190
1026
|
}
|
|
191
|
-
const totalErrors = fileResults.reduce((sum, f) => sum + f.diagnostics.length, 0);
|
|
192
|
-
const totalFixable = fileResults.reduce((sum, f) => sum + f.diagnostics.filter((d) => d.fixable).length, 0);
|
|
193
1027
|
return {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
totalFixable,
|
|
201
|
-
totalFixed
|
|
1028
|
+
gate: "lint",
|
|
1029
|
+
category: "correctness",
|
|
1030
|
+
findings,
|
|
1031
|
+
meta: {
|
|
1032
|
+
scanned: result.files.length,
|
|
1033
|
+
elapsedMs: Date.now() - start
|
|
202
1034
|
}
|
|
203
1035
|
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
//#endregion
|
|
1039
|
+
//#region src/doctor/score.ts
|
|
1040
|
+
const CATEGORIES = [
|
|
1041
|
+
"correctness",
|
|
1042
|
+
"performance",
|
|
1043
|
+
"architecture",
|
|
1044
|
+
"testing",
|
|
1045
|
+
"documentation"
|
|
1046
|
+
];
|
|
1047
|
+
const SEVERITY_WEIGHTS = {
|
|
1048
|
+
error: 10,
|
|
1049
|
+
warning: 3,
|
|
1050
|
+
info: 1
|
|
1051
|
+
};
|
|
1052
|
+
/** Pure scorer — no I/O, deterministic given findings. */
|
|
1053
|
+
const scoreCategory = (category, findings, included) => {
|
|
1054
|
+
const inCat = findings.filter((f) => f.category === category);
|
|
1055
|
+
const errors = inCat.filter((f) => f.severity === "error").length;
|
|
1056
|
+
const warnings = inCat.filter((f) => f.severity === "warning").length;
|
|
1057
|
+
const infos = inCat.filter((f) => f.severity === "info").length;
|
|
1058
|
+
const penalty = errors * SEVERITY_WEIGHTS.error + warnings * SEVERITY_WEIGHTS.warning + infos * SEVERITY_WEIGHTS.info;
|
|
1059
|
+
const score = Math.max(0, Math.min(100, 100 - penalty));
|
|
1060
|
+
return {
|
|
1061
|
+
category,
|
|
1062
|
+
score,
|
|
1063
|
+
errors,
|
|
1064
|
+
warnings,
|
|
1065
|
+
infos,
|
|
1066
|
+
grade: gradeFor(score),
|
|
1067
|
+
included
|
|
1068
|
+
};
|
|
1069
|
+
};
|
|
1070
|
+
const gradeFor = (score) => {
|
|
1071
|
+
if (score >= 90) return "A";
|
|
1072
|
+
if (score >= 80) return "B";
|
|
1073
|
+
if (score >= 70) return "C";
|
|
1074
|
+
if (score >= 60) return "D";
|
|
1075
|
+
return "F";
|
|
1076
|
+
};
|
|
1077
|
+
/**
|
|
1078
|
+
* Compute per-category subscores + overall score.
|
|
1079
|
+
*
|
|
1080
|
+
* `gates` is used to decide which categories are `included` — a
|
|
1081
|
+
* category is included if at least one non-skipped gate emits in it.
|
|
1082
|
+
* If no gate ran for `documentation`, the category is excluded from
|
|
1083
|
+
* the overall mean rather than counted as 100/100.
|
|
1084
|
+
*/
|
|
1085
|
+
const computeScore = (findings, gates) => {
|
|
1086
|
+
const includedCats = /* @__PURE__ */ new Set();
|
|
1087
|
+
for (const g of gates) if (!g.meta.skipped) includedCats.add(g.category);
|
|
1088
|
+
for (const f of findings) includedCats.add(f.category);
|
|
1089
|
+
const categories = CATEGORIES.map((c) => scoreCategory(c, findings, includedCats.has(c)));
|
|
1090
|
+
const included = categories.filter((c) => c.included);
|
|
1091
|
+
if (included.length === 0) return {
|
|
1092
|
+
score: 100,
|
|
1093
|
+
grade: "A",
|
|
1094
|
+
categories
|
|
1095
|
+
};
|
|
1096
|
+
const sum = included.reduce((s, c) => s + c.score, 0);
|
|
1097
|
+
const score = Math.round(sum / included.length);
|
|
1098
|
+
return {
|
|
1099
|
+
score,
|
|
1100
|
+
grade: gradeFor(score),
|
|
1101
|
+
categories
|
|
1102
|
+
};
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
//#endregion
|
|
1106
|
+
//#region src/doctor/report.ts
|
|
1107
|
+
/**
|
|
1108
|
+
* `DoctorReport` aggregator — collects gate results, builds findings
|
|
1109
|
+
* list, computes the 0-100 score, returns the report the renderers
|
|
1110
|
+
* consume. Pure-function: takes gate results in, returns report out.
|
|
1111
|
+
*
|
|
1112
|
+
* The orchestration layer (which gates to run, in what order, with
|
|
1113
|
+
* what timeout) lives in the doctor command itself; this module is
|
|
1114
|
+
* just the "merge + score" step. Splitting them keeps the score
|
|
1115
|
+
* formula testable in isolation and makes it trivial to add a new
|
|
1116
|
+
* gate (drop into the orchestrator's list, no aggregator changes).
|
|
1117
|
+
*/
|
|
1118
|
+
const SEVERITY_RANK = {
|
|
1119
|
+
error: 0,
|
|
1120
|
+
warning: 1,
|
|
1121
|
+
info: 2
|
|
1122
|
+
};
|
|
1123
|
+
/**
|
|
1124
|
+
* Sort findings: errors first, then warnings, then info. Within
|
|
1125
|
+
* each severity, group by category for predictable output.
|
|
1126
|
+
*/
|
|
1127
|
+
const sortFindings = (findings) => [...findings].sort((a, b) => {
|
|
1128
|
+
const sevDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
1129
|
+
if (sevDelta !== 0) return sevDelta;
|
|
1130
|
+
if (a.category !== b.category) return a.category.localeCompare(b.category);
|
|
1131
|
+
return a.code.localeCompare(b.code);
|
|
1132
|
+
});
|
|
1133
|
+
const buildReport = (gates) => {
|
|
1134
|
+
const findings = sortFindings(gates.flatMap((g) => g.findings));
|
|
1135
|
+
const totals = {
|
|
1136
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
1137
|
+
warnings: findings.filter((f) => f.severity === "warning").length,
|
|
1138
|
+
infos: findings.filter((f) => f.severity === "info").length
|
|
1139
|
+
};
|
|
1140
|
+
const { score, grade, categories } = computeScore(findings, gates);
|
|
1141
|
+
return {
|
|
1142
|
+
score,
|
|
1143
|
+
grade,
|
|
1144
|
+
categories,
|
|
1145
|
+
gates,
|
|
1146
|
+
findings,
|
|
1147
|
+
totals,
|
|
1148
|
+
elapsedMs: gates.reduce((s, g) => s + g.meta.elapsedMs, 0),
|
|
1149
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1150
|
+
};
|
|
1151
|
+
};
|
|
1152
|
+
|
|
1153
|
+
//#endregion
|
|
1154
|
+
//#region src/doctor/orchestrator.ts
|
|
1155
|
+
/**
|
|
1156
|
+
* Doctor orchestrator — picks the gate list per mode, runs them in
|
|
1157
|
+
* parallel where safe, collects results, hands off to the aggregator.
|
|
1158
|
+
*
|
|
1159
|
+
* **Gate categorization by speed**:
|
|
1160
|
+
* - FAST gates (default): react-patterns, pyreon-patterns, lint,
|
|
1161
|
+
* distribution, doc-claims, islands-audit, ssg-audit, audit-tests.
|
|
1162
|
+
* Total runtime ~2-5s on the real Pyreon repo.
|
|
1163
|
+
* - SLOW gates (`--full` opt-in): audit-types (TS compiler-API walk
|
|
1164
|
+
* across 6 packages, ~1-30s), bundle-budgets (Bun.build of every
|
|
1165
|
+
* published package, ~15-30s).
|
|
1166
|
+
*
|
|
1167
|
+
* **Why parallel**: the gates are fully independent — no shared
|
|
1168
|
+
* state, no file-write contention (only `--fix` writes, and lint /
|
|
1169
|
+
* react-patterns target disjoint file patterns). Running them via
|
|
1170
|
+
* `Promise.all` cuts wall-clock from ~5s sequential to ~1-2s for the
|
|
1171
|
+
* fast set on a warm cache.
|
|
1172
|
+
*
|
|
1173
|
+
* **Skip filtering**: `--only` and `--skip` operate on gate names.
|
|
1174
|
+
* Skipped gates appear in the report with `meta.skipped: true` so
|
|
1175
|
+
* the renderer shows them in the footer ("Skipped: bundle-budgets").
|
|
1176
|
+
*/
|
|
1177
|
+
/** Gates that run by default (fast). */
|
|
1178
|
+
const FAST_GATES = [
|
|
1179
|
+
"react-patterns",
|
|
1180
|
+
"pyreon-patterns",
|
|
1181
|
+
"lint",
|
|
1182
|
+
"distribution",
|
|
1183
|
+
"doc-claims",
|
|
1184
|
+
"islands-audit",
|
|
1185
|
+
"ssg-audit",
|
|
1186
|
+
"audit-tests"
|
|
1187
|
+
];
|
|
1188
|
+
/** Gates that require `--full` to enable. */
|
|
1189
|
+
const SLOW_GATES = ["audit-types", "bundle-budgets"];
|
|
1190
|
+
const skippedGate = (gate, category, reason) => ({
|
|
1191
|
+
gate,
|
|
1192
|
+
category,
|
|
1193
|
+
findings: [],
|
|
1194
|
+
meta: {
|
|
1195
|
+
elapsedMs: 0,
|
|
1196
|
+
skipped: true,
|
|
1197
|
+
skipReason: reason
|
|
217
1198
|
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
1199
|
+
});
|
|
1200
|
+
const ALL_GATE_CATEGORIES = {
|
|
1201
|
+
"react-patterns": "correctness",
|
|
1202
|
+
"pyreon-patterns": "correctness",
|
|
1203
|
+
lint: "correctness",
|
|
1204
|
+
distribution: "architecture",
|
|
1205
|
+
"doc-claims": "documentation",
|
|
1206
|
+
"audit-tests": "testing",
|
|
1207
|
+
"islands-audit": "architecture",
|
|
1208
|
+
"ssg-audit": "architecture",
|
|
1209
|
+
"audit-types": "architecture",
|
|
1210
|
+
"bundle-budgets": "performance"
|
|
1211
|
+
};
|
|
1212
|
+
/**
|
|
1213
|
+
* Resolve which gates to run.
|
|
1214
|
+
*
|
|
1215
|
+
* Precedence: `--only` > `--skip` > (`--full` toggles slow gates) > default fast set.
|
|
1216
|
+
*/
|
|
1217
|
+
const resolveGates = (opts) => {
|
|
1218
|
+
if (opts.only && opts.only.length > 0) {
|
|
1219
|
+
const skip = new Set(opts.skip ?? []);
|
|
1220
|
+
return opts.only.filter((g) => !skip.has(g));
|
|
233
1221
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
1222
|
+
const base = opts.full ? [...FAST_GATES, ...SLOW_GATES] : [...FAST_GATES];
|
|
1223
|
+
const skip = new Set(opts.skip ?? []);
|
|
1224
|
+
return base.filter((g) => !skip.has(g));
|
|
1225
|
+
};
|
|
1226
|
+
/**
|
|
1227
|
+
* Run the gates and build the report. Wall-clock is measured here
|
|
1228
|
+
* (vs the aggregator's sum-of-elapsedMs which is total CPU time).
|
|
1229
|
+
*/
|
|
1230
|
+
const runDoctor = async (opts) => {
|
|
1231
|
+
const start = Date.now();
|
|
1232
|
+
const selected = new Set(resolveGates(opts));
|
|
1233
|
+
const promises = [...FAST_GATES, ...SLOW_GATES].map(async (gate) => {
|
|
1234
|
+
if (!selected.has(gate)) {
|
|
1235
|
+
const reason = SLOW_GATES.includes(gate) && !opts.full ? "enable with --full" : "skipped";
|
|
1236
|
+
return skippedGate(gate, ALL_GATE_CATEGORIES[gate], reason);
|
|
1237
|
+
}
|
|
1238
|
+
return runGate(gate, opts);
|
|
1239
|
+
});
|
|
1240
|
+
return {
|
|
1241
|
+
...buildReport(await Promise.all(promises)),
|
|
1242
|
+
elapsedMs: Date.now() - start
|
|
1243
|
+
};
|
|
1244
|
+
};
|
|
1245
|
+
const runGate = async (gate, opts) => {
|
|
1246
|
+
switch (gate) {
|
|
1247
|
+
case "react-patterns": return runReactPatternsGate({
|
|
1248
|
+
cwd: opts.cwd,
|
|
1249
|
+
fix: opts.fix
|
|
1250
|
+
});
|
|
1251
|
+
case "pyreon-patterns": return runPyreonPatternsGate({ cwd: opts.cwd });
|
|
1252
|
+
case "lint": return runLintGate({
|
|
1253
|
+
cwd: opts.cwd,
|
|
1254
|
+
fix: opts.fix
|
|
1255
|
+
});
|
|
1256
|
+
case "distribution": return runDistributionGate({
|
|
1257
|
+
cwd: opts.cwd,
|
|
1258
|
+
skipPackProbe: true
|
|
1259
|
+
});
|
|
1260
|
+
case "doc-claims": return runDocClaimsGate({ cwd: opts.cwd });
|
|
1261
|
+
case "audit-tests": return runAuditTestsGate({
|
|
1262
|
+
cwd: opts.cwd,
|
|
1263
|
+
minRisk: opts.auditMinRisk ?? "medium"
|
|
1264
|
+
});
|
|
1265
|
+
case "islands-audit": return runIslandsAuditGate({ cwd: opts.cwd });
|
|
1266
|
+
case "ssg-audit": return runSsgAuditGate({ cwd: opts.cwd });
|
|
1267
|
+
case "audit-types": return runAuditTypesGate({ cwd: opts.cwd });
|
|
1268
|
+
case "bundle-budgets": return runBundleBudgetsGate({ cwd: opts.cwd });
|
|
237
1269
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
//#endregion
|
|
1273
|
+
//#region src/doctor/render/ansi.ts
|
|
1274
|
+
/**
|
|
1275
|
+
* Minimal ANSI helpers for `pyreon doctor` human output.
|
|
1276
|
+
*
|
|
1277
|
+
* We deliberately don't pull in `chalk` / `kleur` / etc. — the doctor
|
|
1278
|
+
* already lives in `@pyreon/cli` (the entrypoint package) and adding
|
|
1279
|
+
* a runtime ANSI library bumps the install footprint. The escapes
|
|
1280
|
+
* are tiny and stable.
|
|
1281
|
+
*
|
|
1282
|
+
* The `enabled` flag respects:
|
|
1283
|
+
* - `NO_COLOR=1` → disable (de-facto standard)
|
|
1284
|
+
* - `FORCE_COLOR=1` / `0` → force on / off
|
|
1285
|
+
* - `process.stdout.isTTY` → default if no override
|
|
1286
|
+
* - CI tools like GitHub Actions set `CI=1` but their terminal DOES
|
|
1287
|
+
* render ANSI — so CI alone doesn't disable color.
|
|
1288
|
+
*/
|
|
1289
|
+
const ESC = String.fromCharCode(27);
|
|
1290
|
+
const CSI = `${ESC}[`;
|
|
1291
|
+
const OSC = `${ESC}]`;
|
|
1292
|
+
const ST = `${ESC}\\`;
|
|
1293
|
+
const isColorEnabled = () => {
|
|
1294
|
+
if (process.env.NO_COLOR) return false;
|
|
1295
|
+
if (process.env.FORCE_COLOR === "0") return false;
|
|
1296
|
+
if (process.env.FORCE_COLOR) return true;
|
|
1297
|
+
return Boolean(process.stdout.isTTY);
|
|
1298
|
+
};
|
|
1299
|
+
const colorEnabled = isColorEnabled();
|
|
1300
|
+
const wrap = (open, close) => (s) => colorEnabled ? `${CSI}${open}m${s}${CSI}${close}m` : s;
|
|
1301
|
+
const bold = wrap("1", "22");
|
|
1302
|
+
const dim = wrap("2", "22");
|
|
1303
|
+
const red = wrap("31", "39");
|
|
1304
|
+
const green = wrap("32", "39");
|
|
1305
|
+
const yellow = wrap("33", "39");
|
|
1306
|
+
const blue = wrap("34", "39");
|
|
1307
|
+
const magenta = wrap("35", "39");
|
|
1308
|
+
const cyan = wrap("36", "39");
|
|
1309
|
+
const gray = wrap("90", "39");
|
|
1310
|
+
/**
|
|
1311
|
+
* OSC-8 hyperlink. iTerm2, WezTerm, kitty, modern VSCode terminals
|
|
1312
|
+
* render this as a clickable link; other terminals show the visible
|
|
1313
|
+
* text and ignore the escape. We use this for file paths so the user
|
|
1314
|
+
* can cmd-click a finding's location and jump to it.
|
|
1315
|
+
*
|
|
1316
|
+
* `url` should be a `file://` URL with optional line/column:
|
|
1317
|
+
* `file:///path/to/file.ts#L42`
|
|
1318
|
+
*/
|
|
1319
|
+
const hyperlink = (text, url) => {
|
|
1320
|
+
if (!colorEnabled) return text;
|
|
1321
|
+
return `${OSC}8;;${url}${ST}${text}${OSC}8;;${ST}`;
|
|
1322
|
+
};
|
|
1323
|
+
/** Build a `file://` URL with optional line / column suffix. */
|
|
1324
|
+
const fileUrl = (absPath, line, _column) => {
|
|
1325
|
+
let url = `file://${absPath}`;
|
|
1326
|
+
if (line !== void 0) url += `#L${line}`;
|
|
1327
|
+
return url;
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
//#endregion
|
|
1331
|
+
//#region src/doctor/render/text.ts
|
|
1332
|
+
const BAR_WIDTH = 12;
|
|
1333
|
+
const FILLED = "█";
|
|
1334
|
+
const EMPTY = "░";
|
|
1335
|
+
const SEV_ICON = {
|
|
1336
|
+
error: "✖",
|
|
1337
|
+
warning: "⚠",
|
|
1338
|
+
info: "ℹ"
|
|
1339
|
+
};
|
|
1340
|
+
const colorForGrade = (g) => {
|
|
1341
|
+
if (g === "A") return green;
|
|
1342
|
+
if (g === "B" || g === "C") return yellow;
|
|
1343
|
+
return red;
|
|
1344
|
+
};
|
|
1345
|
+
const colorForSeverity = (s) => {
|
|
1346
|
+
if (s === "error") return red;
|
|
1347
|
+
if (s === "warning") return yellow;
|
|
1348
|
+
return cyan;
|
|
1349
|
+
};
|
|
1350
|
+
const renderBar = (score, color) => {
|
|
1351
|
+
const filled = Math.round(score / 100 * BAR_WIDTH);
|
|
1352
|
+
const empty = BAR_WIDTH - filled;
|
|
1353
|
+
return color(FILLED.repeat(filled)) + gray(EMPTY.repeat(empty));
|
|
1354
|
+
};
|
|
1355
|
+
const padRight = (s, n) => s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
1356
|
+
const renderCategory = (c) => {
|
|
1357
|
+
if (!c.included) return ` ${dim(padRight(c.category, 14))} ${gray("skipped")}`;
|
|
1358
|
+
const color = colorForGrade(c.grade);
|
|
1359
|
+
const bar = renderBar(c.score, color);
|
|
1360
|
+
const score = color(padRight(String(c.score), 3));
|
|
1361
|
+
const breakdown = c.errors + c.warnings + c.infos === 0 ? gray("clean") : [
|
|
1362
|
+
c.errors > 0 ? red(`${c.errors}E`) : "",
|
|
1363
|
+
c.warnings > 0 ? yellow(`${c.warnings}W`) : "",
|
|
1364
|
+
c.infos > 0 ? cyan(`${c.infos}i`) : ""
|
|
1365
|
+
].filter(Boolean).join(" ");
|
|
1366
|
+
return ` ${padRight(c.category, 14)} ${bar} ${score} ${dim("·")} ${breakdown}`;
|
|
1367
|
+
};
|
|
1368
|
+
const renderBanner = (report) => {
|
|
1369
|
+
const gColor = colorForGrade(report.grade);
|
|
1370
|
+
const score = gColor(bold(String(report.score)));
|
|
1371
|
+
const grade = gColor(bold(report.grade));
|
|
1372
|
+
return [
|
|
1373
|
+
"",
|
|
1374
|
+
` ${bold("pyreon doctor")} ${dim("· project health audit")}`,
|
|
1375
|
+
"",
|
|
1376
|
+
` Score: ${score}/100 Grade: ${grade}`,
|
|
1377
|
+
""
|
|
1378
|
+
].join("\n");
|
|
1379
|
+
};
|
|
1380
|
+
const renderFinding = (f) => {
|
|
1381
|
+
const icon = colorForSeverity(f.severity)(SEV_ICON[f.severity]);
|
|
1382
|
+
const code = dim(f.code);
|
|
1383
|
+
const lines = [` ${icon} ${bold(f.message)} ${code}`];
|
|
1384
|
+
if (f.location) {
|
|
1385
|
+
const linked = hyperlink(cyan(`${f.location.relPath || f.location.path}${f.location.line ? `:${f.location.line}${f.location.column !== void 0 ? `:${f.location.column}` : ""}` : ""}`), fileUrl(f.location.path, f.location.line, f.location.column));
|
|
1386
|
+
lines.push(` ${linked}`);
|
|
1387
|
+
}
|
|
1388
|
+
if (f.relatedLocations && f.relatedLocations.length > 0) for (const rl of f.relatedLocations) {
|
|
1389
|
+
const relPath = rl.relPath || rl.path;
|
|
1390
|
+
const lineCol = rl.line ? `:${rl.line}` : "";
|
|
1391
|
+
const label = rl.label ? ` ${dim(`(${rl.label})`)}` : "";
|
|
1392
|
+
lines.push(` ${dim("↳")} ${cyan(relPath + lineCol)}${label}`);
|
|
1393
|
+
}
|
|
1394
|
+
if (f.fix) lines.push(` ${dim("fix:")} ${f.fix}`);
|
|
1395
|
+
return lines.join("\n");
|
|
1396
|
+
};
|
|
1397
|
+
const renderFindings = (report, topN) => {
|
|
1398
|
+
if (report.findings.length === 0) return ` ${green("✓")} No findings. Your project is healthy.\n`;
|
|
1399
|
+
const shown = report.findings.slice(0, topN);
|
|
1400
|
+
const remaining = report.findings.length - shown.length;
|
|
1401
|
+
const lines = [bold(` Top findings (${shown.length} of ${report.findings.length}):`), ""];
|
|
1402
|
+
for (const f of shown) {
|
|
1403
|
+
lines.push(renderFinding(f));
|
|
1404
|
+
lines.push("");
|
|
1405
|
+
}
|
|
1406
|
+
if (remaining > 0) {
|
|
1407
|
+
lines.push(dim(` …and ${remaining} more. Run with ${bold("--json")} for the full list.`));
|
|
1408
|
+
lines.push("");
|
|
1409
|
+
}
|
|
1410
|
+
return lines.join("\n");
|
|
1411
|
+
};
|
|
1412
|
+
const renderSkipped = (report) => {
|
|
1413
|
+
const skipped = report.gates.filter((g) => g.meta.skipped);
|
|
1414
|
+
if (skipped.length === 0) return "";
|
|
1415
|
+
const names = skipped.map((g) => `${g.gate}${g.meta.skipReason ? ` (${g.meta.skipReason})` : ""}`).join(", ");
|
|
1416
|
+
return ` ${dim("Skipped:")} ${names}\n`;
|
|
1417
|
+
};
|
|
1418
|
+
const renderFooter = (report) => {
|
|
1419
|
+
const { errors, warnings, infos } = report.totals;
|
|
1420
|
+
const totalSummary = [
|
|
1421
|
+
errors > 0 ? red(`${errors} error${errors === 1 ? "" : "s"}`) : "",
|
|
1422
|
+
warnings > 0 ? yellow(`${warnings} warning${warnings === 1 ? "" : "s"}`) : "",
|
|
1423
|
+
infos > 0 ? cyan(`${infos} info`) : ""
|
|
1424
|
+
].filter(Boolean).join(`${dim(" · ")}`) || green("no findings");
|
|
1425
|
+
const elapsed = `${(report.elapsedMs / 1e3).toFixed(1)}s`;
|
|
1426
|
+
return ` ${totalSummary} ${dim(`· ${report.gates.filter((g) => !g.meta.skipped).length} gates · ${elapsed}`)}\n`;
|
|
1427
|
+
};
|
|
1428
|
+
const renderText = (report, opts = {}) => {
|
|
1429
|
+
const topN = opts.topN ?? 10;
|
|
1430
|
+
return [
|
|
1431
|
+
renderBanner(report),
|
|
1432
|
+
bold(" Per category:"),
|
|
1433
|
+
"",
|
|
1434
|
+
...report.categories.map(renderCategory),
|
|
1435
|
+
"",
|
|
1436
|
+
renderFindings(report, topN),
|
|
1437
|
+
renderSkipped(report),
|
|
1438
|
+
renderFooter(report)
|
|
1439
|
+
].join("\n");
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/doctor/render/json.ts
|
|
1444
|
+
const renderJson = (report) => JSON.stringify(report, null, 2);
|
|
1445
|
+
|
|
1446
|
+
//#endregion
|
|
1447
|
+
//#region src/doctor/render/gha.ts
|
|
1448
|
+
const GHA_LEVEL = {
|
|
1449
|
+
error: "error",
|
|
1450
|
+
warning: "warning",
|
|
1451
|
+
info: "notice"
|
|
1452
|
+
};
|
|
1453
|
+
const escape = (s) => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
1454
|
+
const renderGha = (report) => {
|
|
1455
|
+
const lines = [];
|
|
1456
|
+
lines.push(`::notice::pyreon doctor score: ${report.score}/100 (${report.grade}) — ${report.totals.errors} errors, ${report.totals.warnings} warnings, ${report.totals.infos} info`);
|
|
1457
|
+
for (const f of report.findings) {
|
|
1458
|
+
const level = GHA_LEVEL[f.severity];
|
|
1459
|
+
const props = [];
|
|
1460
|
+
props.push(`title=${escape(f.code)}`);
|
|
1461
|
+
if (f.location?.relPath) props.push(`file=${escape(f.location.relPath)}`);
|
|
1462
|
+
if (f.location?.line) props.push(`line=${f.location.line}`);
|
|
1463
|
+
if (f.location?.column) props.push(`col=${f.location.column}`);
|
|
1464
|
+
const msg = f.fix ? `${f.message} — ${f.fix}` : f.message;
|
|
1465
|
+
lines.push(`::${level} ${props.join(",")}::${escape(msg)}`);
|
|
1466
|
+
}
|
|
1467
|
+
return lines.join("\n");
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1470
|
+
//#endregion
|
|
1471
|
+
//#region src/doctor.ts
|
|
1472
|
+
/**
|
|
1473
|
+
* `pyreon doctor` — project-wide health audit.
|
|
1474
|
+
*
|
|
1475
|
+
* PR 2 rewrites this entrypoint around the unified gate API
|
|
1476
|
+
* (`packages/tools/cli/src/doctor/`). The orchestrator runs every
|
|
1477
|
+
* gate in parallel, the aggregator builds a `DoctorReport` with a
|
|
1478
|
+
* 0-100 score, the renderer formats it for text / JSON / GHA.
|
|
1479
|
+
*
|
|
1480
|
+
* The legacy single-purpose flags (`--audit-tests`, `--check-islands`,
|
|
1481
|
+
* `--check-ssg`) are interpreted as `--only <gate>` shortcuts so any
|
|
1482
|
+
* existing CI script that relied on the old shape keeps working.
|
|
1483
|
+
* Without those flags, doctor runs the full fast-gate set + computes
|
|
1484
|
+
* a score — the new default behaviour.
|
|
1485
|
+
*
|
|
1486
|
+
* Output modes:
|
|
1487
|
+
* - text (default): big-score banner + per-category bars + top-N findings
|
|
1488
|
+
* - --json: full `DoctorReport` as JSON
|
|
1489
|
+
* - --gha: GitHub Actions annotation lines (one per finding)
|
|
1490
|
+
*
|
|
1491
|
+
* Modes:
|
|
1492
|
+
* - --full: enable slow gates (audit-types, bundle-budgets)
|
|
1493
|
+
* - --only <gates>: run ONLY the listed comma-separated gates
|
|
1494
|
+
* - --skip <gates>: exclude these gates
|
|
1495
|
+
* - --fix: auto-fix where possible (lint + react-patterns)
|
|
1496
|
+
* - --ci: exit non-zero on any error finding
|
|
1497
|
+
*/
|
|
1498
|
+
const resolveFormat = (options) => {
|
|
1499
|
+
if (options.format) return options.format;
|
|
1500
|
+
if (options.json) return "json";
|
|
1501
|
+
return "text";
|
|
1502
|
+
};
|
|
1503
|
+
const resolveOnly = (options) => {
|
|
1504
|
+
if (options.only && options.only.length > 0) return options.only;
|
|
1505
|
+
const legacyOnly = [];
|
|
1506
|
+
if (options.auditTests) legacyOnly.push("audit-tests");
|
|
1507
|
+
if (options.checkIslands) legacyOnly.push("islands-audit");
|
|
1508
|
+
if (options.checkSsg) legacyOnly.push("ssg-audit");
|
|
1509
|
+
return legacyOnly.length > 0 ? legacyOnly : void 0;
|
|
1510
|
+
};
|
|
1511
|
+
const doctor = async (options) => {
|
|
1512
|
+
const report = await runDoctor({
|
|
1513
|
+
cwd: options.cwd,
|
|
1514
|
+
full: options.full,
|
|
1515
|
+
only: resolveOnly(options),
|
|
1516
|
+
skip: options.skip,
|
|
1517
|
+
fix: options.fix,
|
|
1518
|
+
auditMinRisk: options.auditMinRisk
|
|
1519
|
+
});
|
|
1520
|
+
const format = resolveFormat(options);
|
|
1521
|
+
if (format === "json") console.log(renderJson(report));
|
|
1522
|
+
else if (format === "gha") console.log(renderGha(report));
|
|
1523
|
+
else console.log(renderText(report, { cwd: options.cwd }));
|
|
1524
|
+
if (options.ci) return report.totals.errors;
|
|
1525
|
+
return report.totals.errors + report.totals.warnings + report.totals.infos;
|
|
1526
|
+
};
|
|
241
1527
|
|
|
242
1528
|
//#endregion
|
|
243
1529
|
//#region src/index.ts
|
|
@@ -245,9 +1531,21 @@ function printHuman(result, elapsed) {
|
|
|
245
1531
|
* @pyreon/cli — Developer tools for Pyreon
|
|
246
1532
|
*
|
|
247
1533
|
* Commands:
|
|
248
|
-
* pyreon doctor
|
|
249
|
-
* pyreon context
|
|
1534
|
+
* pyreon doctor — project-wide health audit (score + per-category bars + findings)
|
|
1535
|
+
* pyreon context — generate .pyreon/context.json for AI tools
|
|
250
1536
|
*/
|
|
1537
|
+
const VALID_GATES = [
|
|
1538
|
+
"react-patterns",
|
|
1539
|
+
"pyreon-patterns",
|
|
1540
|
+
"lint",
|
|
1541
|
+
"distribution",
|
|
1542
|
+
"doc-claims",
|
|
1543
|
+
"audit-tests",
|
|
1544
|
+
"islands-audit",
|
|
1545
|
+
"ssg-audit",
|
|
1546
|
+
"audit-types",
|
|
1547
|
+
"bundle-budgets"
|
|
1548
|
+
];
|
|
251
1549
|
const args = process.argv.slice(2);
|
|
252
1550
|
const command = args[0];
|
|
253
1551
|
function printUsage() {
|
|
@@ -255,23 +1553,62 @@ function printUsage() {
|
|
|
255
1553
|
pyreon <command> [options]
|
|
256
1554
|
|
|
257
1555
|
Commands:
|
|
258
|
-
doctor [
|
|
259
|
-
|
|
260
|
-
--audit-tests appends mock-vnode test-audit (PR #197 class).
|
|
261
|
-
--audit-min-risk is high|medium|low (default medium).
|
|
262
|
-
--check-islands appends project-wide islands audit
|
|
263
|
-
(duplicate names, dead islands, registry drift, nested,
|
|
264
|
-
never-with-registry).
|
|
265
|
-
--check-ssg appends project-wide SSG / ISR audit
|
|
266
|
-
(_404.tsx placement, dynamic routes missing
|
|
267
|
-
getStaticPaths, non-literal revalidate exports).
|
|
1556
|
+
doctor [options] Project-wide health audit with 0-100 score.
|
|
1557
|
+
Runs 8 fast gates by default; --full enables 2 slow gates.
|
|
268
1558
|
context [--out <path>] Generate .pyreon/context.json for AI tools
|
|
269
1559
|
|
|
1560
|
+
doctor options:
|
|
1561
|
+
--fix Auto-fix what we can (lint + react-patterns).
|
|
1562
|
+
--full Include slow gates (audit-types, bundle-budgets).
|
|
1563
|
+
--only <gates> Run ONLY these gates (comma-separated).
|
|
1564
|
+
--skip <gates> Skip these gates (comma-separated).
|
|
1565
|
+
--format text|json|gha Output format (default: text).
|
|
1566
|
+
--json Shortcut for --format=json.
|
|
1567
|
+
--gha Shortcut for --format=gha (GitHub Actions annotations).
|
|
1568
|
+
--ci Exit non-zero on error findings only.
|
|
1569
|
+
--audit-min-risk high|medium|low Minimum risk for test-env audit (default: medium).
|
|
1570
|
+
|
|
1571
|
+
doctor gates:
|
|
1572
|
+
Fast: ${VALID_GATES.slice(0, 8).join(", ")}
|
|
1573
|
+
Slow: ${VALID_GATES.slice(8).join(", ")} (require --full)
|
|
1574
|
+
|
|
1575
|
+
Legacy doctor flags (still work — map to --only shortcuts):
|
|
1576
|
+
--audit-tests Equivalent to --only audit-tests
|
|
1577
|
+
--check-islands Equivalent to --only islands-audit
|
|
1578
|
+
--check-ssg Equivalent to --only ssg-audit
|
|
1579
|
+
|
|
270
1580
|
Options:
|
|
271
1581
|
--help Show this help message
|
|
272
1582
|
--version Show version
|
|
273
1583
|
`);
|
|
274
1584
|
}
|
|
1585
|
+
const parseGateList = (raw) => {
|
|
1586
|
+
if (!raw) return void 0;
|
|
1587
|
+
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1588
|
+
const invalid = names.filter((n) => !VALID_GATES.includes(n));
|
|
1589
|
+
if (invalid.length > 0) {
|
|
1590
|
+
console.error(`Unknown gate(s): ${invalid.join(", ")}. Valid: ${VALID_GATES.join(", ")}`);
|
|
1591
|
+
process.exit(1);
|
|
1592
|
+
}
|
|
1593
|
+
return names;
|
|
1594
|
+
};
|
|
1595
|
+
const getFlagValue = (flag) => {
|
|
1596
|
+
const idx = args.indexOf(flag);
|
|
1597
|
+
if (idx < 0) return void 0;
|
|
1598
|
+
return args[idx + 1];
|
|
1599
|
+
};
|
|
1600
|
+
const parseFormat = (raw) => {
|
|
1601
|
+
if (!raw) return void 0;
|
|
1602
|
+
if (raw === "text" || raw === "json" || raw === "gha") return raw;
|
|
1603
|
+
console.error(`--format must be text|json|gha, got '${raw}'`);
|
|
1604
|
+
process.exit(1);
|
|
1605
|
+
};
|
|
1606
|
+
const parseMinRisk = (raw) => {
|
|
1607
|
+
if (!raw) return void 0;
|
|
1608
|
+
if (raw === "high" || raw === "medium" || raw === "low") return raw;
|
|
1609
|
+
console.error(`--audit-min-risk must be high|medium|low, got '${raw}'`);
|
|
1610
|
+
process.exit(1);
|
|
1611
|
+
};
|
|
275
1612
|
async function main() {
|
|
276
1613
|
if (!command || command === "--help" || command === "-h") {
|
|
277
1614
|
printUsage();
|
|
@@ -282,19 +1619,18 @@ async function main() {
|
|
|
282
1619
|
return;
|
|
283
1620
|
}
|
|
284
1621
|
if (command === "doctor") {
|
|
285
|
-
const
|
|
286
|
-
const rawRisk = riskIdx >= 0 ? args[riskIdx + 1] : void 0;
|
|
287
|
-
if (rawRisk !== void 0 && rawRisk !== "high" && rawRisk !== "medium" && rawRisk !== "low") {
|
|
288
|
-
console.error(`--audit-min-risk must be high | medium | low, got '${rawRisk}'`);
|
|
289
|
-
process.exit(1);
|
|
290
|
-
}
|
|
1622
|
+
const format = args.includes("--gha") ? "gha" : parseFormat(getFlagValue("--format"));
|
|
291
1623
|
const options = {
|
|
292
1624
|
fix: args.includes("--fix"),
|
|
293
1625
|
json: args.includes("--json"),
|
|
294
1626
|
ci: args.includes("--ci"),
|
|
295
1627
|
cwd: process.cwd(),
|
|
1628
|
+
format,
|
|
1629
|
+
full: args.includes("--full"),
|
|
1630
|
+
only: parseGateList(getFlagValue("--only")),
|
|
1631
|
+
skip: parseGateList(getFlagValue("--skip")),
|
|
296
1632
|
auditTests: args.includes("--audit-tests"),
|
|
297
|
-
auditMinRisk:
|
|
1633
|
+
auditMinRisk: parseMinRisk(getFlagValue("--audit-min-risk")),
|
|
298
1634
|
checkIslands: args.includes("--check-islands"),
|
|
299
1635
|
checkSsg: args.includes("--check-ssg")
|
|
300
1636
|
};
|