@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/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 { auditIslands, auditSsg, auditTestEnvironment, detectReactPatterns, formatIslandAudit, formatSsgAudit, formatTestAudit, generateContext as generateContext$1, hasReactPatterns, migrateReactCode } from "@pyreon/compiler";
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
- * pyreon doctorproject-wide health check for AI-friendly development
42
+ * audit-types gateprogrammatic API.
39
43
  *
40
- * Runs a pipeline of checks:
41
- * 1. React pattern detection (imports, hooks, JSX attributes)
42
- * 2. Import source validation (@pyreon/* vs react/vue)
43
- * 3. Common Pyreon mistakes (signal without call, key vs by, etc.)
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
- * Output modes:
46
- * - Human-readable (default): colored terminal output
47
- * - JSON (--json): structured output for AI agent consumption
48
- * - CI (--ci): exits with code 1 on any error
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
- * Fix mode (--fix): auto-applies safe transforms via migrateReactCode
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
- async function doctor(options) {
53
- const startTime = performance.now();
54
- const result = runChecks(collectSourceFiles(options.cwd), options);
55
- const elapsed = Math.round(performance.now() - startTime);
56
- if (options.json) printJson(result);
57
- else printHuman(result, elapsed);
58
- if (options.auditTests) {
59
- const auditResult = auditTestEnvironment(options.cwd);
60
- if (options.json) {
61
- console.log("");
62
- console.log(JSON.stringify({ testAudit: auditResult }, null, 2));
63
- } else {
64
- console.log("");
65
- console.log(formatTestAudit(auditResult, { minRisk: options.auditMinRisk ?? "medium" }));
66
- console.log("");
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
- if (options.checkIslands) {
70
- const islandsResult = auditIslands(options.cwd);
71
- if (options.json) {
72
- console.log("");
73
- console.log(JSON.stringify({ islandAudit: islandsResult }, null, 2));
74
- } else {
75
- console.log("");
76
- console.log(formatIslandAudit(islandsResult));
77
- console.log("");
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
- if (options.checkSsg) {
81
- const ssgResult = auditSsg(options.cwd);
82
- if (options.json) {
83
- console.log("");
84
- console.log(JSON.stringify({ ssgAudit: ssgResult }, null, 2));
85
- } else {
86
- console.log("");
87
- console.log(formatSsgAudit(ssgResult));
88
- console.log("");
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
- return result.summary.totalErrors;
92
- }
93
- const sourceExtensions = new Set([
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 sourceIgnoreDirs = new Set([
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
- function shouldSkipDirEntry(entry) {
627
+ const shouldSkipDirEntry = (entry) => {
109
628
  if (!entry.isDirectory()) return false;
110
- return entry.name.startsWith(".") || sourceIgnoreDirs.has(entry.name);
111
- }
112
- function walkSourceFiles(dir, results) {
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()) walkSourceFiles(fullPath, results);
123
- else if (entry.isFile() && sourceExtensions.has(path.extname(entry.name))) results.push(fullPath);
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
- function collectSourceFiles(cwd) {
644
+ };
645
+ const collectSourceFiles = (cwd) => {
127
646
  const results = [];
128
- walkSourceFiles(cwd, results);
647
+ walk(cwd, results);
129
648
  return results;
130
- }
131
- function checkFileWithFix(file, relPath) {
132
- let code;
133
- try {
134
- code = fs.readFileSync(file, "utf-8");
135
- } catch {
136
- return {
137
- result: null,
138
- fixCount: 0
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
- if (!hasReactPatterns(code)) return {
142
- result: null,
143
- fixCount: 0
144
- };
145
- const migrated = migrateReactCode(code, relPath);
146
- if (migrated.changes.length > 0) fs.writeFileSync(file, migrated.code, "utf-8");
147
- const remaining = detectReactPatterns(migrated.code, relPath);
148
- if (remaining.length > 0 || migrated.changes.length > 0) return {
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
- result: null,
158
- fixCount: 0
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
- function checkFileDetectOnly(file, relPath) {
162
- let code;
163
- try {
164
- code = fs.readFileSync(file, "utf-8");
165
- } catch {
166
- return null;
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
- if (!hasReactPatterns(code)) return null;
169
- const diagnostics = detectReactPatterns(code, relPath);
170
- if (diagnostics.length > 0) return {
171
- file: relPath,
172
- diagnostics,
173
- fixed: false
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
- function runChecks(files, options) {
178
- const fileResults = [];
179
- let totalFixed = 0;
180
- for (const file of files) {
181
- const relPath = path.relative(options.cwd, file);
182
- if (options.fix) {
183
- const { result, fixCount } = checkFileWithFix(file, relPath);
184
- totalFixed += fixCount;
185
- if (result) fileResults.push(result);
186
- } else {
187
- const result = checkFileDetectOnly(file, relPath);
188
- if (result) fileResults.push(result);
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
- passed: totalErrors === 0,
195
- files: fileResults,
196
- summary: {
197
- filesScanned: files.length,
198
- filesWithIssues: fileResults.length,
199
- totalErrors,
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
- function printJson(result) {
206
- console.log(JSON.stringify(result, null, 2));
207
- }
208
- function printFileResult(fileResult) {
209
- if (fileResult.diagnostics.length === 0) return;
210
- console.log(` ${fileResult.file}${fileResult.fixed ? " (partially fixed)" : ""}`);
211
- for (const diag of fileResult.diagnostics) {
212
- const fixTag = diag.fixable ? " [fixable]" : "";
213
- console.log(` ${diag.line}:${diag.column} — ${diag.message}${fixTag}`);
214
- console.log(` Current: ${diag.current}`);
215
- console.log(` Suggested: ${diag.suggested}`);
216
- console.log("");
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
- function printSummary(summary) {
220
- console.log(` ${summary.totalErrors} issue${summary.totalErrors === 1 ? "" : "s"} in ${summary.filesWithIssues} file${summary.filesWithIssues === 1 ? "" : "s"}`);
221
- if (summary.totalFixable > 0) console.log(` ${summary.totalFixable} auto-fixable — run 'pyreon doctor --fix' to apply`);
222
- console.log("");
223
- }
224
- function printHuman(result, elapsed) {
225
- const { summary } = result;
226
- console.log("");
227
- console.log(` Pyreon Doctor — scanned ${summary.filesScanned} files in ${elapsed}ms`);
228
- console.log("");
229
- if (result.passed && summary.totalFixed === 0) {
230
- console.log(" ✓ No issues found. Your code is Pyreon-native!");
231
- console.log("");
232
- return;
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
- if (summary.totalFixed > 0) {
235
- console.log(` ✓ Auto-fixed ${summary.totalFixed} issue${summary.totalFixed === 1 ? "" : "s"}`);
236
- console.log("");
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
- for (const fileResult of result.files) printFileResult(fileResult);
239
- printSummary(summary);
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 [--fix] [--json] — Scan project for React patterns, bad imports, etc.
249
- * pyreon context Generate .pyreon/context.json for AI tools
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 [--fix] [--json] [--ci] [--audit-tests] [--audit-min-risk <level>] [--check-islands] [--check-ssg]
259
- Scan for React patterns, bad imports, common mistakes.
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 riskIdx = args.indexOf("--audit-min-risk");
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: rawRisk,
1633
+ auditMinRisk: parseMinRisk(getFlagValue("--audit-min-risk")),
298
1634
  checkIslands: args.includes("--check-islands"),
299
1635
  checkSsg: args.includes("--check-ssg")
300
1636
  };