@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/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ProjectContext, ProjectContext as ProjectContext$1 } from "@pyreon/compiler";
|
|
2
2
|
|
|
3
3
|
//#region src/context.d.ts
|
|
4
4
|
interface ContextOptions {
|
|
@@ -7,48 +7,199 @@ interface ContextOptions {
|
|
|
7
7
|
}
|
|
8
8
|
declare function generateContext(options: ContextOptions): Promise<ProjectContext$1>;
|
|
9
9
|
//#endregion
|
|
10
|
+
//#region src/doctor/types.d.ts
|
|
11
|
+
/**
|
|
12
|
+
* Unified `Finding` + `GateResult` types shared by every doctor gate.
|
|
13
|
+
*
|
|
14
|
+
* Each programmatic gate (`runDistributionGate`, `runDocClaimsGate`, ...)
|
|
15
|
+
* returns `GateResult`. The aggregator in `pyreon doctor` merges every
|
|
16
|
+
* gate's findings into a `DoctorReport` with per-category subscores + an
|
|
17
|
+
* overall 0-100 health score — that aggregation layer lands in the
|
|
18
|
+
* follow-up PR (this PR is foundation-only).
|
|
19
|
+
*
|
|
20
|
+
* Why a unified shape now (PR 1) instead of together with the aggregator
|
|
21
|
+
* (PR 2): the gates are independently usable today via standalone scripts
|
|
22
|
+
* (`bun run check-distribution`, etc.). Locking the shape early means the
|
|
23
|
+
* scripts and the future aggregator consume the same `Finding[]` — no
|
|
24
|
+
* shim layer.
|
|
25
|
+
*
|
|
26
|
+
* Mirrors the existing per-detector shapes (`IslandFinding`, `SsgFinding`,
|
|
27
|
+
* `TestAuditEntry`) but elevated to a cross-gate vocabulary. Categories
|
|
28
|
+
* map onto the five react.doctor-style buckets so the score formula has
|
|
29
|
+
* a clear assignment per gate without case-by-case classification.
|
|
30
|
+
*/
|
|
31
|
+
type FindingCategory = 'correctness' | 'performance' | 'architecture' | 'testing' | 'documentation';
|
|
32
|
+
type Severity = 'error' | 'warning' | 'info';
|
|
33
|
+
/**
|
|
34
|
+
* A single actionable diagnostic. Every doctor gate emits Findings in
|
|
35
|
+
* this shape. Aggregation by category + severity drives the health score.
|
|
36
|
+
*/
|
|
37
|
+
interface Finding {
|
|
38
|
+
/**
|
|
39
|
+
* Bucket the finding lands in for score aggregation. Each gate picks
|
|
40
|
+
* a default category for its emitted findings; an individual finding
|
|
41
|
+
* may override (e.g. a perf-flavored lint rule would still emit
|
|
42
|
+
* `category: 'performance'` even though the gate is `'lint'`).
|
|
43
|
+
*/
|
|
44
|
+
category: FindingCategory;
|
|
45
|
+
/** Severity drives per-finding weight in the score formula. */
|
|
46
|
+
severity: Severity;
|
|
47
|
+
/**
|
|
48
|
+
* Stable code identifying the specific check. Format: `<gate>/<rule>`
|
|
49
|
+
* — e.g. `'audit-types/typed-but-unimplemented'`,
|
|
50
|
+
* `'distribution/missing-sideEffects'`, `'pyreon/for-missing-by'`.
|
|
51
|
+
* Used for filtering + cross-referencing in JSON output.
|
|
52
|
+
*/
|
|
53
|
+
code: string;
|
|
54
|
+
/**
|
|
55
|
+
* Identifier of the gate that produced this finding. Useful for
|
|
56
|
+
* grouping in human output and `--skip <gate>` filtering. Examples:
|
|
57
|
+
* `'lint'`, `'audit-types'`, `'check-distribution'`, `'islands-audit'`.
|
|
58
|
+
*/
|
|
59
|
+
gate: string;
|
|
60
|
+
/** One-paragraph human-readable explanation, including the fix path. */
|
|
61
|
+
message: string;
|
|
62
|
+
/** Where the finding surfaces. Optional for project-wide findings. */
|
|
63
|
+
location?: {
|
|
64
|
+
/** Absolute path */path: string; /** Path relative to the repo root for readable reporting */
|
|
65
|
+
relPath: string; /** 1-based line number */
|
|
66
|
+
line?: number | undefined; /** 1-based column number */
|
|
67
|
+
column?: number | undefined;
|
|
68
|
+
} | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Companion locations for cross-file findings (e.g. duplicate-island-
|
|
71
|
+
* name lists the second occurrence). Surfaces in human output below
|
|
72
|
+
* the primary location with an `↳` marker.
|
|
73
|
+
*/
|
|
74
|
+
relatedLocations?: Array<{
|
|
75
|
+
path: string;
|
|
76
|
+
relPath: string;
|
|
77
|
+
line?: number | undefined;
|
|
78
|
+
column?: number | undefined;
|
|
79
|
+
label?: string | undefined;
|
|
80
|
+
}> | undefined;
|
|
81
|
+
/** Optional short fix hint shown under the message in human output. */
|
|
82
|
+
fix?: string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* `true` if `pyreon doctor --fix` can auto-resolve this. Currently
|
|
85
|
+
* limited to lint findings whose rule has an auto-fixer.
|
|
86
|
+
*/
|
|
87
|
+
fixable?: boolean | undefined;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Result of running a single doctor gate. The aggregator collects N
|
|
91
|
+
* GateResults and computes the report.
|
|
92
|
+
*/
|
|
93
|
+
interface GateResult {
|
|
94
|
+
/** Gate identifier (matches Finding.gate) */
|
|
95
|
+
gate: string;
|
|
96
|
+
/**
|
|
97
|
+
* Default category for findings this gate produces. The aggregator
|
|
98
|
+
* uses this as the fallback when a Finding doesn't override
|
|
99
|
+
* `category` itself — but Finding.category is the source of truth
|
|
100
|
+
* for score attribution.
|
|
101
|
+
*/
|
|
102
|
+
category: FindingCategory;
|
|
103
|
+
/** All findings produced by this gate. May be empty. */
|
|
104
|
+
findings: Finding[];
|
|
105
|
+
/** Per-gate metadata for the human + JSON reports. */
|
|
106
|
+
meta: {
|
|
107
|
+
/** Number of files / packages / records the gate scanned. */scanned?: number | undefined; /** Wall-clock duration in milliseconds. */
|
|
108
|
+
elapsedMs: number;
|
|
109
|
+
/**
|
|
110
|
+
* `true` if the gate was skipped (e.g. `--skip <gate>`, missing
|
|
111
|
+
* prerequisite tool, mode-incompatible). The aggregator excludes
|
|
112
|
+
* skipped gates from the score and surfaces them in a "skipped"
|
|
113
|
+
* footer.
|
|
114
|
+
*/
|
|
115
|
+
skipped?: boolean | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Why the gate was skipped (only meaningful when `skipped: true`).
|
|
118
|
+
*/
|
|
119
|
+
skipReason?: string | undefined;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Per-category subscore + raw counts. The aggregator builds one
|
|
124
|
+
* `CategoryScore` per `FindingCategory`, then averages them into the
|
|
125
|
+
* overall score. Categories with no findings AND no contributing gates
|
|
126
|
+
* (skipped or filtered out) get `included: false` and are excluded
|
|
127
|
+
* from the mean — keeping a perfect 100 for an unmeasured category
|
|
128
|
+
* would be misleading.
|
|
129
|
+
*/
|
|
130
|
+
interface CategoryScore {
|
|
131
|
+
category: FindingCategory;
|
|
132
|
+
/** 0-100 subscore for this bucket */
|
|
133
|
+
score: number;
|
|
134
|
+
errors: number;
|
|
135
|
+
warnings: number;
|
|
136
|
+
infos: number;
|
|
137
|
+
/** Letter grade derived from `score` (A/B/C/D/F) */
|
|
138
|
+
grade: Grade;
|
|
139
|
+
/** False if no gate covered this category — drop from mean */
|
|
140
|
+
included: boolean;
|
|
141
|
+
}
|
|
142
|
+
type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
|
|
143
|
+
/**
|
|
144
|
+
* Final aggregated report `pyreon doctor` produces. The renderer
|
|
145
|
+
* (text / json / gha) consumes this; gate orchestration is upstream.
|
|
146
|
+
*/
|
|
147
|
+
interface DoctorReport {
|
|
148
|
+
/** 0-100 weighted mean of included `categories[].score` */
|
|
149
|
+
score: number;
|
|
150
|
+
/** Letter grade for `score` */
|
|
151
|
+
grade: Grade;
|
|
152
|
+
/** Per-category breakdown (always 5 entries — `included` flags coverage) */
|
|
153
|
+
categories: CategoryScore[];
|
|
154
|
+
/** Every gate that ran (or was skipped, with `meta.skipped: true`) */
|
|
155
|
+
gates: GateResult[];
|
|
156
|
+
/** Flat list of all findings across gates, ordered by severity then category */
|
|
157
|
+
findings: Finding[];
|
|
158
|
+
/** Aggregate counts across all findings */
|
|
159
|
+
totals: {
|
|
160
|
+
errors: number;
|
|
161
|
+
warnings: number;
|
|
162
|
+
infos: number;
|
|
163
|
+
};
|
|
164
|
+
/** Top-level wall-clock — sum of gates' elapsedMs (parallel sum, not max) */
|
|
165
|
+
elapsedMs: number;
|
|
166
|
+
/** ISO timestamp of when the report was produced (for diffing across runs) */
|
|
167
|
+
timestamp: string;
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/doctor/orchestrator.d.ts
|
|
171
|
+
type GateName = 'react-patterns' | 'pyreon-patterns' | 'lint' | 'distribution' | 'doc-claims' | 'audit-tests' | 'islands-audit' | 'ssg-audit' | 'audit-types' | 'bundle-budgets';
|
|
172
|
+
//#endregion
|
|
10
173
|
//#region src/doctor.d.ts
|
|
174
|
+
type DoctorFormat = 'text' | 'json' | 'gha';
|
|
11
175
|
interface DoctorOptions {
|
|
12
176
|
fix: boolean;
|
|
177
|
+
/** Legacy boolean — interpreted as `format = 'json'` if true. */
|
|
13
178
|
json: boolean;
|
|
14
179
|
ci: boolean;
|
|
15
180
|
cwd: string;
|
|
181
|
+
/** Explicit format override (wins over `json` boolean). */
|
|
182
|
+
format?: DoctorFormat | undefined;
|
|
183
|
+
/** Enable slow gates (audit-types, bundle-budgets). */
|
|
184
|
+
full?: boolean | undefined;
|
|
185
|
+
/** Run ONLY these gates. */
|
|
186
|
+
only?: GateName[] | undefined;
|
|
187
|
+
/** Skip these gates. */
|
|
188
|
+
skip?: GateName[] | undefined;
|
|
16
189
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* the React-migration check pipeline, so we gate it to avoid noise
|
|
21
|
-
* in the typical "is my migration done?" call.
|
|
190
|
+
* @deprecated Prefer `--only audit-tests`. Both forms behave
|
|
191
|
+
* identically: include the test-environment audit gate in the
|
|
192
|
+
* report. Kept so existing CI scripts continue to work.
|
|
22
193
|
*/
|
|
23
194
|
auditTests?: boolean | undefined;
|
|
24
|
-
/** Minimum risk
|
|
25
|
-
auditMinRisk?:
|
|
26
|
-
/**
|
|
27
|
-
* When true, run the project-wide islands audit and append the result
|
|
28
|
-
* to the doctor output. Catches cross-file foot-guns (duplicate names,
|
|
29
|
-
* dead islands, registry drift, nested islands, never-with-registry)
|
|
30
|
-
* that PR G's per-file detector and PR B's auto-registry can't reach
|
|
31
|
-
* (manual `hydrateIslands({...})` for non-Vite consumers, library
|
|
32
|
-
* authors, multi-package projects). Default false.
|
|
33
|
-
*/
|
|
195
|
+
/** Minimum risk for the test-environment audit. Default 'medium'. */
|
|
196
|
+
auditMinRisk?: 'high' | 'medium' | 'low' | undefined;
|
|
197
|
+
/** @deprecated Prefer `--only islands-audit`. */
|
|
34
198
|
checkIslands?: boolean | undefined;
|
|
35
|
-
/**
|
|
36
|
-
* When true, run the project-wide SSG / ISR audit (M3.4) and append
|
|
37
|
-
* the result to the doctor output. Catches:
|
|
38
|
-
* - `_404.tsx` not co-located with `_layout.tsx` (PR L5 carve-out)
|
|
39
|
-
* - dynamic routes (`[id].tsx`) without `getStaticPaths` (PR A
|
|
40
|
-
* silently skips them under `mode: 'ssg'`)
|
|
41
|
-
* - `export const revalidate = X` where X isn't a numeric literal
|
|
42
|
-
* (PR I's extractor silently drops non-literal forms)
|
|
43
|
-
*
|
|
44
|
-
* Like the islands audit, this is a "should review" signal — the exit
|
|
45
|
-
* code is unaffected (the build doesn't break) but CI can pipe
|
|
46
|
-
* `--check-ssg --json` and grep for findings.length > 0 to gate on
|
|
47
|
-
* it. Default false.
|
|
48
|
-
*/
|
|
199
|
+
/** @deprecated Prefer `--only ssg-audit`. */
|
|
49
200
|
checkSsg?: boolean | undefined;
|
|
50
201
|
}
|
|
51
|
-
declare
|
|
202
|
+
declare const doctor: (options: DoctorOptions) => Promise<number>;
|
|
52
203
|
//#endregion
|
|
53
|
-
export { type ContextOptions, type DoctorOptions, type ProjectContext, doctor, generateContext };
|
|
204
|
+
export { type ContextOptions, type DoctorOptions, type DoctorReport, type GateName, type ProjectContext, doctor, generateContext };
|
|
54
205
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "CLI tools for Pyreon — doctor, generate, context",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/cli#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"prepublishOnly": "bun run build"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@pyreon/compiler": "^0.
|
|
49
|
+
"@pyreon/compiler": "^0.18.0",
|
|
50
|
+
"@pyreon/lint": "^0.18.0"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
53
|
"typescript": ">=5.0.0"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit-tests gate — wraps `@pyreon/compiler:auditTestEnvironment`.
|
|
3
|
+
*
|
|
4
|
+
* Catches mock-vnode test patterns (the PR #197 bug class — tests
|
|
5
|
+
* that hand-construct `{ type, props, children }` literals or use a
|
|
6
|
+
* `vnode()` helper instead of going through real `h()` from
|
|
7
|
+
* `@pyreon/core`). Three risk tiers (HIGH / MEDIUM / LOW) from the
|
|
8
|
+
* balance of mockVNodeLiteralCount + mockHelperCount +
|
|
9
|
+
* mockHelperCallCount + realHCallCount + importsH. The adapter
|
|
10
|
+
* maps tier → severity (high=error, medium=warning, low=info).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { auditTestEnvironment } from '@pyreon/compiler'
|
|
14
|
+
import type { AuditRisk } from '@pyreon/compiler'
|
|
15
|
+
|
|
16
|
+
import type { Finding, GateResult, Severity } from '../types'
|
|
17
|
+
|
|
18
|
+
const SEVERITY_BY_RISK: Record<AuditRisk, Severity> = {
|
|
19
|
+
high: 'error',
|
|
20
|
+
medium: 'warning',
|
|
21
|
+
low: 'info',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuditTestsGateOptions {
|
|
25
|
+
cwd: string
|
|
26
|
+
/** Minimum risk to surface. Defaults to `'medium'`. */
|
|
27
|
+
minRisk?: AuditRisk
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const RISK_RANK: Record<AuditRisk, number> = {
|
|
31
|
+
high: 3,
|
|
32
|
+
medium: 2,
|
|
33
|
+
low: 1,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const runAuditTestsGate = async (
|
|
37
|
+
opts: AuditTestsGateOptions,
|
|
38
|
+
): Promise<GateResult> => {
|
|
39
|
+
const start = Date.now()
|
|
40
|
+
const findings: Finding[] = []
|
|
41
|
+
const minRisk = opts.minRisk ?? 'medium'
|
|
42
|
+
const minRank = RISK_RANK[minRisk] ?? 0
|
|
43
|
+
|
|
44
|
+
const result = auditTestEnvironment(opts.cwd)
|
|
45
|
+
|
|
46
|
+
for (const entry of result.entries) {
|
|
47
|
+
const rank = RISK_RANK[entry.risk] ?? 0
|
|
48
|
+
if (rank < minRank) continue
|
|
49
|
+
const severity = SEVERITY_BY_RISK[entry.risk] ?? 'warning'
|
|
50
|
+
|
|
51
|
+
findings.push({
|
|
52
|
+
category: 'testing',
|
|
53
|
+
severity,
|
|
54
|
+
code: `audit-tests/mock-vnode-${entry.risk}`,
|
|
55
|
+
gate: 'audit-tests',
|
|
56
|
+
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.'}`,
|
|
57
|
+
location: { path: entry.path, relPath: entry.relPath },
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
gate: 'audit-tests',
|
|
63
|
+
category: 'testing',
|
|
64
|
+
findings,
|
|
65
|
+
meta: {
|
|
66
|
+
scanned: result.entries.length,
|
|
67
|
+
elapsedMs: Date.now() - start,
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit-types gate — programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Catches typed-but-unimplemented public-interface fields. Walks every
|
|
5
|
+
* exported interface in each high-risk package and counts non-type
|
|
6
|
+
* references; fields with zero references are flagged HIGH. Catches the
|
|
7
|
+
* 0.14.0-class bug (`mode: "ssg"` typed but never read by runtime).
|
|
8
|
+
*
|
|
9
|
+
* **Implementation note (subprocess adapter).** This gate invokes the
|
|
10
|
+
* standalone `scripts/audit-types.ts` script via `--json --all` and
|
|
11
|
+
* parses the output. The script is 476 lines of mature AST-walking
|
|
12
|
+
* logic with its own test suite; rather than surgically extract it
|
|
13
|
+
* mid-shape, the adapter shape keeps PR 1 tractable and lets PR 2's
|
|
14
|
+
* aggregation layer consume the same `Finding[]` shape as the other
|
|
15
|
+
* gates. Adapter cost is ~50ms subprocess overhead — noise within the
|
|
16
|
+
* gate's 1-5s scan runtime. Full extraction is a deferred follow-up
|
|
17
|
+
* (the doctor aggregator doesn't care HOW the gate runs).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import type { Finding, GateResult, Severity } from '../types'
|
|
23
|
+
|
|
24
|
+
interface ScriptFieldFinding {
|
|
25
|
+
package: string
|
|
26
|
+
interface: string
|
|
27
|
+
field: string
|
|
28
|
+
declaredIn: string
|
|
29
|
+
declaredLine: number
|
|
30
|
+
refCount: number
|
|
31
|
+
severity: 'HIGH' | 'MEDIUM' | 'LOW' | 'OK'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ScriptAuditResult {
|
|
35
|
+
package: string
|
|
36
|
+
packageDir: string
|
|
37
|
+
findings: ScriptFieldFinding[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const mapSeverity = (s: ScriptFieldFinding['severity']): Severity | null => {
|
|
41
|
+
switch (s) {
|
|
42
|
+
case 'HIGH':
|
|
43
|
+
return 'error'
|
|
44
|
+
case 'MEDIUM':
|
|
45
|
+
return 'warning'
|
|
46
|
+
case 'LOW':
|
|
47
|
+
return 'info'
|
|
48
|
+
case 'OK':
|
|
49
|
+
return null // suppress — field has references, not a finding
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Pure parse-and-map function — public so tests can exercise the JSON
|
|
55
|
+
* → `Finding[]` translation without spawning a subprocess. Returns the
|
|
56
|
+
* findings plus the count of packages scanned. Exported as `_internal`
|
|
57
|
+
* (unstable API surface — may move when PR 2 lands the aggregator).
|
|
58
|
+
*/
|
|
59
|
+
export const _parseAuditTypesOutput = (
|
|
60
|
+
raw: string,
|
|
61
|
+
cwd: string,
|
|
62
|
+
): { findings: Finding[]; scanned: number } => {
|
|
63
|
+
const results = JSON.parse(raw) as ScriptAuditResult[]
|
|
64
|
+
const findings: Finding[] = []
|
|
65
|
+
for (const r of results) {
|
|
66
|
+
for (const f of r.findings) {
|
|
67
|
+
const severity = mapSeverity(f.severity)
|
|
68
|
+
if (severity === null) continue
|
|
69
|
+
|
|
70
|
+
findings.push({
|
|
71
|
+
category: 'architecture',
|
|
72
|
+
severity,
|
|
73
|
+
code: `audit-types/typed-but-unimplemented-${f.severity.toLowerCase()}`,
|
|
74
|
+
gate: 'audit-types',
|
|
75
|
+
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.`,
|
|
76
|
+
location: {
|
|
77
|
+
path: join(cwd, f.declaredIn),
|
|
78
|
+
relPath: f.declaredIn,
|
|
79
|
+
line: f.declaredLine,
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { findings, scanned: results.length }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface AuditTypesGateOptions {
|
|
88
|
+
/** Repository root directory */
|
|
89
|
+
cwd: string
|
|
90
|
+
/** Path to bun executable. Defaults to `'bun'`. */
|
|
91
|
+
bun?: string
|
|
92
|
+
/**
|
|
93
|
+
* Specific packages to audit. Defaults to `--all` (high-risk list
|
|
94
|
+
* baked into the script: zero, router, core, server, runtime-server,
|
|
95
|
+
* vite-plugin).
|
|
96
|
+
*/
|
|
97
|
+
packages?: string[]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const runAuditTypesGate = async (
|
|
101
|
+
opts: AuditTypesGateOptions,
|
|
102
|
+
): Promise<GateResult> => {
|
|
103
|
+
const start = Date.now()
|
|
104
|
+
const findings: Finding[] = []
|
|
105
|
+
const scriptPath = join(opts.cwd, 'scripts/audit-types.ts')
|
|
106
|
+
const args = ['run', scriptPath, '--json']
|
|
107
|
+
if (opts.packages && opts.packages.length > 0) {
|
|
108
|
+
args.push(...opts.packages)
|
|
109
|
+
} else {
|
|
110
|
+
args.push('--all')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let scannedPackages = 0
|
|
114
|
+
try {
|
|
115
|
+
const out = execFileSync(opts.bun ?? 'bun', args, {
|
|
116
|
+
cwd: opts.cwd,
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
|
+
maxBuffer: 16 * 1024 * 1024, // 16MB — audit can produce large output
|
|
120
|
+
})
|
|
121
|
+
const parsed = _parseAuditTypesOutput(out, opts.cwd)
|
|
122
|
+
findings.push(...parsed.findings)
|
|
123
|
+
scannedPackages = parsed.scanned
|
|
124
|
+
} catch (err) {
|
|
125
|
+
// Script failure — surface as a single ERROR finding so the
|
|
126
|
+
// gate doesn't silently skip. Captures "script not found",
|
|
127
|
+
// parse errors, exec errors, etc.
|
|
128
|
+
findings.push({
|
|
129
|
+
category: 'architecture',
|
|
130
|
+
severity: 'error',
|
|
131
|
+
code: 'audit-types/gate-failed',
|
|
132
|
+
gate: 'audit-types',
|
|
133
|
+
message: `audit-types gate failed to run: ${(err as Error).message}`,
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
gate: 'audit-types',
|
|
139
|
+
category: 'architecture',
|
|
140
|
+
findings,
|
|
141
|
+
meta: {
|
|
142
|
+
scanned: scannedPackages,
|
|
143
|
+
elapsedMs: Date.now() - start,
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bundle-budgets gate — programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Locks the gzipped main-entry size of every published `@pyreon/*`
|
|
5
|
+
* package against `scripts/bundle-budgets.json` (current + 25% headroom).
|
|
6
|
+
* Three classes of finding land here:
|
|
7
|
+
*
|
|
8
|
+
* 1. **violations** — package bundles past its budget (real regression).
|
|
9
|
+
* Severity: `error`. Code: `bundle-budgets/over-budget`.
|
|
10
|
+
* 2. **missing** — package has no entry in `bundle-budgets.json` yet
|
|
11
|
+
* (new published package — author needs to commit a budget).
|
|
12
|
+
* Severity: `warning`. Code: `bundle-budgets/missing-budget`.
|
|
13
|
+
* 3. **failures** — the bundler couldn't measure a package (unresolved
|
|
14
|
+
* transitive dep, build artifact issue). Severity: `error`. Code:
|
|
15
|
+
* `bundle-budgets/bundle-failed`. Surfaced as a finding rather than
|
|
16
|
+
* silently dropped — same lesson as PR #434.
|
|
17
|
+
*
|
|
18
|
+
* **Implementation note (subprocess adapter).** This gate invokes the
|
|
19
|
+
* standalone `scripts/check-bundle-budgets.ts` script via `--json` and
|
|
20
|
+
* parses the output. The script is 466 lines of bundler orchestration
|
|
21
|
+
* + AST-walking dep collection logic; extracting it surgically into a
|
|
22
|
+
* pure function carries too much risk for PR 1. The adapter shape lets
|
|
23
|
+
* the doctor aggregator consume the same `Finding[]` shape as the other
|
|
24
|
+
* gates; full extraction is a deferred follow-up (`pyreon doctor`
|
|
25
|
+
* doesn't care HOW the gate runs — only that it returns `GateResult`).
|
|
26
|
+
*
|
|
27
|
+
* The full-bundle measurement is the slowest gate (~15-30s against
|
|
28
|
+
* 50+ published packages). Doctor's default fast mode opts this gate
|
|
29
|
+
* OUT; `--full` enables it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { execFileSync } from 'node:child_process'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import type { Finding, GateResult } from '../types'
|
|
35
|
+
|
|
36
|
+
interface ScriptViolation {
|
|
37
|
+
name: string
|
|
38
|
+
current: number
|
|
39
|
+
budget: number
|
|
40
|
+
overBy: number
|
|
41
|
+
overByPct: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ScriptMissing {
|
|
45
|
+
name: string
|
|
46
|
+
current: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ScriptFailure {
|
|
50
|
+
name: string
|
|
51
|
+
error: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ScriptMeasured {
|
|
55
|
+
name: string
|
|
56
|
+
raw: number
|
|
57
|
+
gzip: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface ScriptOutput {
|
|
61
|
+
violations: ScriptViolation[]
|
|
62
|
+
missing: ScriptMissing[]
|
|
63
|
+
failures: ScriptFailure[]
|
|
64
|
+
measured: ScriptMeasured[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const formatKB = (bytes: number): string => `${(bytes / 1024).toFixed(2)} KB`
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pure parse-and-map function — public so tests can exercise the JSON
|
|
71
|
+
* → `Finding[]` translation without spawning a subprocess. Returns the
|
|
72
|
+
* findings plus the count of packages scanned (measured + failures).
|
|
73
|
+
* Exported as `_internal` (unstable API surface — may move when PR 2
|
|
74
|
+
* lands the aggregator).
|
|
75
|
+
*/
|
|
76
|
+
export const _parseBundleBudgetsOutput = (
|
|
77
|
+
raw: string,
|
|
78
|
+
cwd: string,
|
|
79
|
+
): { findings: Finding[]; scanned: number } => {
|
|
80
|
+
const result = JSON.parse(raw) as ScriptOutput
|
|
81
|
+
const findings: Finding[] = []
|
|
82
|
+
const budgetsRelPath = 'scripts/bundle-budgets.json'
|
|
83
|
+
const budgetsPath = join(cwd, budgetsRelPath)
|
|
84
|
+
|
|
85
|
+
for (const v of result.violations) {
|
|
86
|
+
findings.push({
|
|
87
|
+
category: 'performance',
|
|
88
|
+
severity: 'error',
|
|
89
|
+
code: 'bundle-budgets/over-budget',
|
|
90
|
+
gate: 'bundle-budgets',
|
|
91
|
+
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.`,
|
|
92
|
+
location: { path: budgetsPath, relPath: budgetsRelPath },
|
|
93
|
+
fix: `Run \`bun run check-bundle-budgets --update\` to regenerate budgets after intentional growth.`,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const m of result.missing) {
|
|
98
|
+
findings.push({
|
|
99
|
+
category: 'performance',
|
|
100
|
+
severity: 'warning',
|
|
101
|
+
code: 'bundle-budgets/missing-budget',
|
|
102
|
+
gate: 'bundle-budgets',
|
|
103
|
+
message: `${m.name}: ${formatKB(m.current)} (no budget entry). New published package?`,
|
|
104
|
+
location: { path: budgetsPath, relPath: budgetsRelPath },
|
|
105
|
+
fix: `Run \`bun run check-bundle-budgets --update\` and review the diff.`,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const f of result.failures) {
|
|
110
|
+
findings.push({
|
|
111
|
+
category: 'performance',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
code: 'bundle-budgets/bundle-failed',
|
|
114
|
+
gate: 'bundle-budgets',
|
|
115
|
+
message: `${f.name}: bundle failed — ${f.error.split('\n')[0]}. Likely an unresolved third-party dep that the auto-external scan missed.`,
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
findings,
|
|
121
|
+
scanned: result.measured.length + result.failures.length,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BundleBudgetsGateOptions {
|
|
126
|
+
/** Repository root directory */
|
|
127
|
+
cwd: string
|
|
128
|
+
/** Path to bun executable. Defaults to `'bun'`. */
|
|
129
|
+
bun?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const runBundleBudgetsGate = async (
|
|
133
|
+
opts: BundleBudgetsGateOptions,
|
|
134
|
+
): Promise<GateResult> => {
|
|
135
|
+
const start = Date.now()
|
|
136
|
+
const findings: Finding[] = []
|
|
137
|
+
const scriptPath = join(opts.cwd, 'scripts/check-bundle-budgets.ts')
|
|
138
|
+
|
|
139
|
+
let scannedPackages = 0
|
|
140
|
+
try {
|
|
141
|
+
// The script always exits 1 when there are violations/missing/failures
|
|
142
|
+
// — but writes valid JSON to stdout regardless. Use a try/catch to
|
|
143
|
+
// capture stdout from the non-zero exit. `execFileSync` throws on
|
|
144
|
+
// non-zero, attaching `.stdout` to the error object.
|
|
145
|
+
let out: string
|
|
146
|
+
try {
|
|
147
|
+
out = execFileSync(opts.bun ?? 'bun', ['run', scriptPath, '--json'], {
|
|
148
|
+
cwd: opts.cwd,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
151
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
152
|
+
})
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const e = err as { stdout?: string | Buffer; message?: string }
|
|
155
|
+
if (e.stdout) {
|
|
156
|
+
out = typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf8')
|
|
157
|
+
} else {
|
|
158
|
+
throw err
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const parsed = _parseBundleBudgetsOutput(out, opts.cwd)
|
|
163
|
+
findings.push(...parsed.findings)
|
|
164
|
+
scannedPackages = parsed.scanned
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Script failure — surface as a single ERROR finding so the
|
|
167
|
+
// gate doesn't silently skip. Captures parse errors, script-not-
|
|
168
|
+
// found, missing bundle-budgets.json, etc.
|
|
169
|
+
findings.push({
|
|
170
|
+
category: 'performance',
|
|
171
|
+
severity: 'error',
|
|
172
|
+
code: 'bundle-budgets/gate-failed',
|
|
173
|
+
gate: 'bundle-budgets',
|
|
174
|
+
message: `bundle-budgets gate failed to run: ${(err as Error).message}`,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
gate: 'bundle-budgets',
|
|
180
|
+
category: 'performance',
|
|
181
|
+
findings,
|
|
182
|
+
meta: {
|
|
183
|
+
scanned: scannedPackages,
|
|
184
|
+
elapsedMs: Date.now() - start,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|