@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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distribution-hygiene gate — programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Two static invariants every published `@pyreon/*` package must hold:
|
|
5
|
+
* 1. `sideEffects` field declared (bundler tree-shaking)
|
|
6
|
+
* 2. `!lib/** /*.map` excluded from `files` array (no source-map ship)
|
|
7
|
+
*
|
|
8
|
+
* Plus a live `npm pack --dry-run` probe of `@pyreon/reactivity` to
|
|
9
|
+
* verify the exclusion actually works at publish time (the `files`
|
|
10
|
+
* field is technically right but npm's interpretation can diverge).
|
|
11
|
+
*
|
|
12
|
+
* Pure function — the standalone script `scripts/check-distribution.ts`
|
|
13
|
+
* is a thin wrapper that calls this and formats the output.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the script logic 1:1 — no behavior change, just makes the
|
|
16
|
+
* findings programmatically consumable by `pyreon doctor` aggregation
|
|
17
|
+
* (PR 2).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
22
|
+
import { join, relative } from 'node:path'
|
|
23
|
+
import type { Finding, GateResult } from '../types'
|
|
24
|
+
|
|
25
|
+
interface PackageInfo {
|
|
26
|
+
name: string
|
|
27
|
+
dir: string
|
|
28
|
+
pj: {
|
|
29
|
+
name?: string
|
|
30
|
+
private?: boolean
|
|
31
|
+
sideEffects?: unknown
|
|
32
|
+
files?: string[]
|
|
33
|
+
main?: string
|
|
34
|
+
exports?: unknown
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const findPackages = (repoRoot: string): PackageInfo[] => {
|
|
39
|
+
const result: PackageInfo[] = []
|
|
40
|
+
const packagesRoot = join(repoRoot, 'packages')
|
|
41
|
+
if (!existsSync(packagesRoot)) return result
|
|
42
|
+
for (const cat of readdirSync(packagesRoot)) {
|
|
43
|
+
const catDir = join(packagesRoot, cat)
|
|
44
|
+
let pkgs: string[]
|
|
45
|
+
try {
|
|
46
|
+
pkgs = readdirSync(catDir)
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
for (const pkg of pkgs) {
|
|
51
|
+
const pkgDir = join(catDir, pkg)
|
|
52
|
+
const pjPath = join(pkgDir, 'package.json')
|
|
53
|
+
if (!existsSync(pjPath)) continue
|
|
54
|
+
let pj: PackageInfo['pj']
|
|
55
|
+
try {
|
|
56
|
+
pj = JSON.parse(readFileSync(pjPath, 'utf8'))
|
|
57
|
+
} catch {
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (pj.private) continue
|
|
61
|
+
if (typeof pj.name !== 'string') continue
|
|
62
|
+
result.push({ name: pj.name, dir: pkgDir, pj })
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Pure parse-and-emit function for the `npm pack --dry-run` JSON
|
|
70
|
+
* output. Exported as `_internal` so tests can exercise the .map-
|
|
71
|
+
* detection + finding emission path without spawning the live npm
|
|
72
|
+
* subprocess — under CI parallel load the real probe runs 100s+,
|
|
73
|
+
* tripping the per-test timeout. Returns the finding (if any) for
|
|
74
|
+
* the caller to push onto the gate's findings array.
|
|
75
|
+
*/
|
|
76
|
+
export const _detectMapsInPackOutput = (
|
|
77
|
+
raw: string,
|
|
78
|
+
cwd: string,
|
|
79
|
+
probe: { dir: string },
|
|
80
|
+
probePackage: string,
|
|
81
|
+
): Finding | null => {
|
|
82
|
+
const result = JSON.parse(raw) as Array<{ files: Array<{ path: string }> }>
|
|
83
|
+
const tarballFiles = result[0]?.files.map((f) => f.path) ?? []
|
|
84
|
+
const maps = tarballFiles.filter((f) => f.endsWith('.map'))
|
|
85
|
+
if (maps.length === 0) return null
|
|
86
|
+
return {
|
|
87
|
+
category: 'architecture',
|
|
88
|
+
severity: 'error',
|
|
89
|
+
code: 'distribution/tarball-contains-map',
|
|
90
|
+
gate: 'distribution',
|
|
91
|
+
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 ? ', …' : ''}`,
|
|
92
|
+
location: {
|
|
93
|
+
path: join(probe.dir, 'package.json'),
|
|
94
|
+
relPath: relative(cwd, join(probe.dir, 'package.json')),
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface DistributionGateOptions {
|
|
100
|
+
/**
|
|
101
|
+
* Repository root directory. The gate walks `<cwd>/packages/*` and
|
|
102
|
+
* shells out to `npm pack` from `<cwd>/packages/core/reactivity`.
|
|
103
|
+
*/
|
|
104
|
+
cwd: string
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Skip the `npm pack --dry-run` probe. Useful for unit tests +
|
|
108
|
+
* environments where npm isn't on PATH. Defaults to `false`.
|
|
109
|
+
*/
|
|
110
|
+
skipPackProbe?: boolean
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Package to probe via `npm pack --dry-run`. Defaults to
|
|
114
|
+
* `@pyreon/reactivity` — small, stable, canonical 4-element `files`
|
|
115
|
+
* shape used by ~37 other published packages.
|
|
116
|
+
*/
|
|
117
|
+
probePackage?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Run the distribution-hygiene gate. Returns findings + metadata.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* const result = await runDistributionGate({ cwd: process.cwd() })
|
|
125
|
+
* if (result.findings.length > 0) process.exit(1)
|
|
126
|
+
*/
|
|
127
|
+
export const runDistributionGate = async (
|
|
128
|
+
opts: DistributionGateOptions,
|
|
129
|
+
): Promise<GateResult> => {
|
|
130
|
+
const start = Date.now()
|
|
131
|
+
const probePackage = opts.probePackage ?? '@pyreon/reactivity'
|
|
132
|
+
const findings: Finding[] = []
|
|
133
|
+
const packages = findPackages(opts.cwd)
|
|
134
|
+
|
|
135
|
+
for (const p of packages) {
|
|
136
|
+
// Rule 1: sideEffects must be defined.
|
|
137
|
+
if (p.pj.sideEffects === undefined) {
|
|
138
|
+
findings.push({
|
|
139
|
+
category: 'architecture',
|
|
140
|
+
severity: 'error',
|
|
141
|
+
code: 'distribution/missing-sideEffects',
|
|
142
|
+
gate: 'distribution',
|
|
143
|
+
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.`,
|
|
144
|
+
location: {
|
|
145
|
+
path: join(p.dir, 'package.json'),
|
|
146
|
+
relPath: relative(opts.cwd, join(p.dir, 'package.json')),
|
|
147
|
+
},
|
|
148
|
+
fix: 'Add `"sideEffects": false` to package.json',
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Rule 2: if the package ships `lib`, the `files` array must
|
|
153
|
+
// exclude source maps.
|
|
154
|
+
if (Array.isArray(p.pj.files) && p.pj.files.includes('lib')) {
|
|
155
|
+
if (!p.pj.files.includes('!lib/**/*.map')) {
|
|
156
|
+
findings.push({
|
|
157
|
+
category: 'architecture',
|
|
158
|
+
severity: 'error',
|
|
159
|
+
code: 'distribution/missing-map-exclusion',
|
|
160
|
+
gate: 'distribution',
|
|
161
|
+
message: `${p.name} package.json \`files\` must include \`"!lib/**/*.map"\` to exclude source maps from the published tarball.`,
|
|
162
|
+
location: {
|
|
163
|
+
path: join(p.dir, 'package.json'),
|
|
164
|
+
relPath: relative(opts.cwd, join(p.dir, 'package.json')),
|
|
165
|
+
},
|
|
166
|
+
fix: 'Add `"!lib/**/*.map"` to the `files` array',
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Rule 3: live `npm pack --dry-run` probe.
|
|
173
|
+
if (!opts.skipPackProbe) {
|
|
174
|
+
const probe = packages.find((p) => p.name === probePackage)
|
|
175
|
+
if (probe) {
|
|
176
|
+
try {
|
|
177
|
+
const out = execFileSync('npm', ['pack', '--dry-run', '--json'], {
|
|
178
|
+
cwd: probe.dir,
|
|
179
|
+
encoding: 'utf8',
|
|
180
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
181
|
+
})
|
|
182
|
+
const finding = _detectMapsInPackOutput(
|
|
183
|
+
out,
|
|
184
|
+
opts.cwd,
|
|
185
|
+
probe,
|
|
186
|
+
probePackage,
|
|
187
|
+
)
|
|
188
|
+
if (finding) findings.push(finding)
|
|
189
|
+
} catch {
|
|
190
|
+
// npm not available or pack failed — silently skip. Locally
|
|
191
|
+
// this might run in an environment where npm isn't on PATH
|
|
192
|
+
// (Bun-only setup); CI has npm so the gate fires there.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
gate: 'distribution',
|
|
199
|
+
category: 'architecture',
|
|
200
|
+
findings,
|
|
201
|
+
meta: {
|
|
202
|
+
scanned: packages.length,
|
|
203
|
+
elapsedMs: Date.now() - start,
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doc-claims gate — programmatic API.
|
|
3
|
+
*
|
|
4
|
+
* Catches numeric-drift between human-written docs and the underlying
|
|
5
|
+
* source of truth. Recurring failure mode: a hand-quoted count
|
|
6
|
+
* ("34 signal-based hooks…") appears in 3-5 places; one bumps when a
|
|
7
|
+
* new hook lands, the others don't. Audit caught the README claiming
|
|
8
|
+
* 16 vs actual 34 — drift that shipped to users for weeks.
|
|
9
|
+
*
|
|
10
|
+
* Pure function — `scripts/check-doc-claims.ts` wraps this for the
|
|
11
|
+
* standalone CLI invocation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import type { Finding, GateResult } from '../types'
|
|
17
|
+
|
|
18
|
+
interface ClaimSpec {
|
|
19
|
+
/** Doc file relative to repo root */
|
|
20
|
+
file: string
|
|
21
|
+
/** Capture group 1 must contain the number */
|
|
22
|
+
pattern: RegExp
|
|
23
|
+
/** Optional pattern variant for "X+" hedged claims (also wrong) */
|
|
24
|
+
rejectHedged?: RegExp
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ClaimCheck {
|
|
28
|
+
/** Human-readable name shown in findings */
|
|
29
|
+
name: string
|
|
30
|
+
/** Stable code suffix used in `Finding.code` */
|
|
31
|
+
codeId: string
|
|
32
|
+
/** Source-of-truth function — produces the actual count */
|
|
33
|
+
actual: (repoRoot: string) => number
|
|
34
|
+
/** Doc files that carry the claim */
|
|
35
|
+
claims: ClaimSpec[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const countHookExports = (repoRoot: string): number => {
|
|
39
|
+
const indexPath = join(repoRoot, 'packages/fundamentals/hooks/src/index.ts')
|
|
40
|
+
if (!existsSync(indexPath)) return 0
|
|
41
|
+
const source = readFileSync(indexPath, 'utf8')
|
|
42
|
+
const matched = source.matchAll(
|
|
43
|
+
/^export \{ (?:default as )?(use[A-Z][a-zA-Z]+) \}/gm,
|
|
44
|
+
)
|
|
45
|
+
const names = new Set<string>()
|
|
46
|
+
for (const [, name] of matched) {
|
|
47
|
+
if (name) names.add(name)
|
|
48
|
+
}
|
|
49
|
+
return names.size
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const countDocPages = (repoRoot: string): number => {
|
|
53
|
+
const docsDir = join(repoRoot, 'docs')
|
|
54
|
+
if (!existsSync(docsDir)) return 0
|
|
55
|
+
let count = 0
|
|
56
|
+
const walk = (dir: string): void => {
|
|
57
|
+
let entries: string[]
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir)
|
|
60
|
+
} catch {
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
for (const name of entries) {
|
|
64
|
+
if (
|
|
65
|
+
name === 'node_modules' ||
|
|
66
|
+
name === 'cache' ||
|
|
67
|
+
name === 'dist' ||
|
|
68
|
+
name.startsWith('.')
|
|
69
|
+
) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
const full = join(dir, name)
|
|
73
|
+
let isDir = false
|
|
74
|
+
try {
|
|
75
|
+
isDir = statSync(full).isDirectory()
|
|
76
|
+
} catch {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
if (isDir) walk(full)
|
|
80
|
+
else if (name.endsWith('.md')) count++
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
walk(docsDir)
|
|
84
|
+
return count
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const checks: ClaimCheck[] = [
|
|
88
|
+
{
|
|
89
|
+
name: 'hook export count',
|
|
90
|
+
codeId: 'hook-count',
|
|
91
|
+
actual: countHookExports,
|
|
92
|
+
claims: [
|
|
93
|
+
{
|
|
94
|
+
file: 'packages/fundamentals/hooks/README.md',
|
|
95
|
+
pattern: /^(\d+) signal-based reactive utilities/m,
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
file: 'packages/fundamentals/hooks/src/manifest.ts',
|
|
99
|
+
pattern: /'(\d+) signal-based hooks:/,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
file: 'packages/fundamentals/hooks/src/manifest.ts',
|
|
103
|
+
pattern: /Signal-based hooks for Pyreon — (\d+) reactive primitives/,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
file: 'CLAUDE.md',
|
|
107
|
+
pattern: /\| `@pyreon\/hooks` *\| (\d+) signal-based hooks/,
|
|
108
|
+
rejectHedged: /\| `@pyreon\/hooks` *\| (\d+)\+ signal-based hooks/,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
file: 'CLAUDE.md',
|
|
112
|
+
pattern: /^- (\d+) signal-based hooks across 6 categories/m,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
file: 'docs/docs/index.md',
|
|
116
|
+
pattern: /\| (\d+) signal-based hooks for common UI patterns/,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'doc page count',
|
|
122
|
+
codeId: 'doc-count',
|
|
123
|
+
actual: countDocPages,
|
|
124
|
+
claims: [
|
|
125
|
+
{
|
|
126
|
+
file: 'CLAUDE.md',
|
|
127
|
+
pattern: /(\d+) doc pages covering all packages/,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
},
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
export interface DocClaimsGateOptions {
|
|
134
|
+
/** Repository root directory */
|
|
135
|
+
cwd: string
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const runDocClaimsGate = async (
|
|
139
|
+
opts: DocClaimsGateOptions,
|
|
140
|
+
): Promise<GateResult> => {
|
|
141
|
+
const start = Date.now()
|
|
142
|
+
const findings: Finding[] = []
|
|
143
|
+
|
|
144
|
+
// The claim sites are Pyreon-monorepo-specific paths (hooks README,
|
|
145
|
+
// CLAUDE.md, docs/docs/index.md, etc.). In a downstream consumer
|
|
146
|
+
// project NONE of them exist — firing the gate would emit a flood of
|
|
147
|
+
// spurious file-missing errors that don't reflect any real problem.
|
|
148
|
+
// Skip when zero claim files are present: signal that the gate
|
|
149
|
+
// doesn't apply rather than blame the user for not being Pyreon.
|
|
150
|
+
const anyClaimExists = checks.some((c) =>
|
|
151
|
+
c.claims.some((cl) => existsSync(join(opts.cwd, cl.file))),
|
|
152
|
+
)
|
|
153
|
+
if (!anyClaimExists) {
|
|
154
|
+
return {
|
|
155
|
+
gate: 'doc-claims',
|
|
156
|
+
category: 'documentation',
|
|
157
|
+
findings: [],
|
|
158
|
+
meta: {
|
|
159
|
+
scanned: 0,
|
|
160
|
+
elapsedMs: Date.now() - start,
|
|
161
|
+
skipped: true,
|
|
162
|
+
skipReason:
|
|
163
|
+
'no claim sites found in this project (gate targets Pyreon monorepo paths)',
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const check of checks) {
|
|
169
|
+
const actual = check.actual(opts.cwd)
|
|
170
|
+
for (const claim of check.claims) {
|
|
171
|
+
const filePath = join(opts.cwd, claim.file)
|
|
172
|
+
const relPath = claim.file
|
|
173
|
+
|
|
174
|
+
if (!existsSync(filePath)) {
|
|
175
|
+
findings.push({
|
|
176
|
+
category: 'documentation',
|
|
177
|
+
severity: 'error',
|
|
178
|
+
code: `doc-claims/${check.codeId}-file-missing`,
|
|
179
|
+
gate: 'doc-claims',
|
|
180
|
+
message: `${check.name}: claim file ${claim.file} not found (claim may have been deleted or moved). Actual: ${actual}.`,
|
|
181
|
+
location: { path: filePath, relPath },
|
|
182
|
+
})
|
|
183
|
+
continue
|
|
184
|
+
}
|
|
185
|
+
const content = readFileSync(filePath, 'utf8')
|
|
186
|
+
|
|
187
|
+
if (claim.rejectHedged) {
|
|
188
|
+
const hedged = content.match(claim.rejectHedged)
|
|
189
|
+
if (hedged?.[1]) {
|
|
190
|
+
findings.push({
|
|
191
|
+
category: 'documentation',
|
|
192
|
+
severity: 'error',
|
|
193
|
+
code: `doc-claims/${check.codeId}-hedged`,
|
|
194
|
+
gate: 'doc-claims',
|
|
195
|
+
message: `${check.name}: rejected hedged claim "${hedged[1]}+" in ${claim.file} — write the exact count instead. Actual: ${actual}.`,
|
|
196
|
+
location: { path: filePath, relPath },
|
|
197
|
+
fix: `Replace "${hedged[1]}+" with "${actual}"`,
|
|
198
|
+
})
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const match = content.match(claim.pattern)
|
|
204
|
+
const claimedRaw = match?.[1]
|
|
205
|
+
if (!claimedRaw) {
|
|
206
|
+
findings.push({
|
|
207
|
+
category: 'documentation',
|
|
208
|
+
severity: 'warning',
|
|
209
|
+
code: `doc-claims/${check.codeId}-pattern-miss`,
|
|
210
|
+
gate: 'doc-claims',
|
|
211
|
+
message: `${check.name}: pattern not found in ${claim.file} (claim was likely deleted or rephrased). Actual: ${actual}.`,
|
|
212
|
+
location: { path: filePath, relPath },
|
|
213
|
+
})
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const claimed = parseInt(claimedRaw, 10)
|
|
217
|
+
if (claimed !== actual) {
|
|
218
|
+
findings.push({
|
|
219
|
+
category: 'documentation',
|
|
220
|
+
severity: 'error',
|
|
221
|
+
code: `doc-claims/${check.codeId}-drift`,
|
|
222
|
+
gate: 'doc-claims',
|
|
223
|
+
message: `${check.name}: ${claim.file} claims ${claimed}, actual ${actual}.`,
|
|
224
|
+
location: { path: filePath, relPath },
|
|
225
|
+
fix: `Update the claim in ${claim.file} from ${claimed} to ${actual}`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
gate: 'doc-claims',
|
|
233
|
+
category: 'documentation',
|
|
234
|
+
findings,
|
|
235
|
+
meta: {
|
|
236
|
+
scanned: checks.reduce((n, c) => n + c.claims.length, 0),
|
|
237
|
+
elapsedMs: Date.now() - start,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export for all programmatic doctor gates.
|
|
3
|
+
*
|
|
4
|
+
* Each gate exports a `run<Name>Gate(opts): Promise<GateResult>` function.
|
|
5
|
+
* The aggregator iterates a curated list of these to produce the
|
|
6
|
+
* unified `DoctorReport`; consumers can also import individual gates
|
|
7
|
+
* for standalone use (the existing `scripts/check-*.ts` wrappers do this).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
runAuditTypesGate,
|
|
12
|
+
type AuditTypesGateOptions,
|
|
13
|
+
} from './audit-types'
|
|
14
|
+
export {
|
|
15
|
+
runBundleBudgetsGate,
|
|
16
|
+
type BundleBudgetsGateOptions,
|
|
17
|
+
} from './bundle-budgets'
|
|
18
|
+
export {
|
|
19
|
+
runDistributionGate,
|
|
20
|
+
type DistributionGateOptions,
|
|
21
|
+
} from './distribution'
|
|
22
|
+
export {
|
|
23
|
+
runDocClaimsGate,
|
|
24
|
+
type DocClaimsGateOptions,
|
|
25
|
+
} from './doc-claims'
|
|
26
|
+
export {
|
|
27
|
+
runReactPatternsGate,
|
|
28
|
+
type ReactPatternsGateOptions,
|
|
29
|
+
} from './react-patterns'
|
|
30
|
+
export {
|
|
31
|
+
runPyreonPatternsGate,
|
|
32
|
+
type PyreonPatternsGateOptions,
|
|
33
|
+
} from './pyreon-patterns'
|
|
34
|
+
export {
|
|
35
|
+
runAuditTestsGate,
|
|
36
|
+
type AuditTestsGateOptions,
|
|
37
|
+
} from './audit-tests'
|
|
38
|
+
export {
|
|
39
|
+
runIslandsAuditGate,
|
|
40
|
+
type IslandsAuditGateOptions,
|
|
41
|
+
} from './islands-audit'
|
|
42
|
+
export {
|
|
43
|
+
runSsgAuditGate,
|
|
44
|
+
type SsgAuditGateOptions,
|
|
45
|
+
} from './ssg-audit'
|
|
46
|
+
export { runLintGate, type LintGateOptions } from './lint'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* islands-audit gate — wraps `@pyreon/compiler:auditIslands`.
|
|
3
|
+
*
|
|
4
|
+
* Project-wide cross-file detectors for the island architecture
|
|
5
|
+
* (duplicate names, dead islands, registry drift, nested islands,
|
|
6
|
+
* never-with-registry-entry). Per-finding severity is derived from
|
|
7
|
+
* the finding code: `dead-island` is a warning (might be intentional
|
|
8
|
+
* during refactor), everything else is an error (silent runtime
|
|
9
|
+
* failure mode).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { auditIslands, type IslandFindingCode } from '@pyreon/compiler'
|
|
13
|
+
|
|
14
|
+
import type { Finding, GateResult, Severity } from '../types'
|
|
15
|
+
|
|
16
|
+
const SEVERITY_BY_CODE: Record<IslandFindingCode, Severity> = {
|
|
17
|
+
'duplicate-name': 'error',
|
|
18
|
+
'never-with-registry-entry': 'error',
|
|
19
|
+
'registry-mismatch': 'error',
|
|
20
|
+
'nested-island': 'error',
|
|
21
|
+
'dead-island': 'warning',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IslandsAuditGateOptions {
|
|
25
|
+
cwd: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const runIslandsAuditGate = async (
|
|
29
|
+
opts: IslandsAuditGateOptions,
|
|
30
|
+
): Promise<GateResult> => {
|
|
31
|
+
const start = Date.now()
|
|
32
|
+
const findings: Finding[] = []
|
|
33
|
+
const result = auditIslands(opts.cwd)
|
|
34
|
+
|
|
35
|
+
for (const f of result.findings) {
|
|
36
|
+
findings.push({
|
|
37
|
+
category: 'architecture',
|
|
38
|
+
severity: SEVERITY_BY_CODE[f.code] ?? 'error',
|
|
39
|
+
code: `islands-audit/${f.code}`,
|
|
40
|
+
gate: 'islands-audit',
|
|
41
|
+
message: f.message,
|
|
42
|
+
location: {
|
|
43
|
+
path: f.location.path,
|
|
44
|
+
relPath: f.location.relPath,
|
|
45
|
+
line: f.location.line,
|
|
46
|
+
column: f.location.column,
|
|
47
|
+
},
|
|
48
|
+
relatedLocations: f.related?.map((r) => ({
|
|
49
|
+
path: r.path,
|
|
50
|
+
relPath: r.relPath,
|
|
51
|
+
line: r.line,
|
|
52
|
+
column: r.column,
|
|
53
|
+
})),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
gate: 'islands-audit',
|
|
59
|
+
category: 'architecture',
|
|
60
|
+
findings,
|
|
61
|
+
meta: {
|
|
62
|
+
scanned: result.findings.length,
|
|
63
|
+
elapsedMs: Date.now() - start,
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lint gate — wraps `@pyreon/lint:lint`.
|
|
3
|
+
*
|
|
4
|
+
* Runs the project's configured Pyreon lint rules across the source
|
|
5
|
+
* tree. Per-finding category is derived from the rule ID's prefix
|
|
6
|
+
* (the lint rule categories: reactivity, jsx, lifecycle, performance,
|
|
7
|
+
* ssr, architecture, store, form, styling, hooks, accessibility,
|
|
8
|
+
* router, ssg) — `performance` rules emit `category: 'performance'`,
|
|
9
|
+
* `architecture` rules emit `category: 'architecture'`, the rest fold
|
|
10
|
+
* to `'correctness'` since they're all "your code is broken in some
|
|
11
|
+
* way" findings from the doctor's perspective.
|
|
12
|
+
*
|
|
13
|
+
* Severity passes through as-is from lint's `Diagnostic.severity`
|
|
14
|
+
* ('error' | 'warning' | 'info' all map 1:1 to the doctor severity
|
|
15
|
+
* shape).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as path from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { lint, allRules } from '@pyreon/lint'
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
Finding,
|
|
24
|
+
FindingCategory,
|
|
25
|
+
GateResult,
|
|
26
|
+
Severity,
|
|
27
|
+
} from '../types'
|
|
28
|
+
|
|
29
|
+
const mapLintSeverity = (s: string): Severity | null => {
|
|
30
|
+
if (s === 'error') return 'error'
|
|
31
|
+
if (s === 'warn') return 'warning'
|
|
32
|
+
if (s === 'info') return 'info'
|
|
33
|
+
return null // 'off'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build a rule-id → category lookup once at module load. The lint
|
|
37
|
+
// rule registry is the source of truth for which category a rule
|
|
38
|
+
// belongs to; this map mirrors the doctor's 5-bucket vocabulary.
|
|
39
|
+
const RULE_CATEGORY = (() => {
|
|
40
|
+
const map = new Map<string, FindingCategory>()
|
|
41
|
+
for (const rule of allRules) {
|
|
42
|
+
const cat = mapLintCategory(rule.meta.category)
|
|
43
|
+
map.set(rule.meta.id, cat)
|
|
44
|
+
}
|
|
45
|
+
return map
|
|
46
|
+
})()
|
|
47
|
+
|
|
48
|
+
function mapLintCategory(c: string): FindingCategory {
|
|
49
|
+
switch (c) {
|
|
50
|
+
case 'performance':
|
|
51
|
+
return 'performance'
|
|
52
|
+
case 'architecture':
|
|
53
|
+
case 'ssr':
|
|
54
|
+
case 'ssg':
|
|
55
|
+
case 'router':
|
|
56
|
+
return 'architecture'
|
|
57
|
+
case 'styling':
|
|
58
|
+
case 'accessibility':
|
|
59
|
+
return 'architecture'
|
|
60
|
+
default:
|
|
61
|
+
// reactivity, jsx, lifecycle, store, form, hooks → all
|
|
62
|
+
// user-code correctness from the doctor's vocabulary.
|
|
63
|
+
return 'correctness'
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LintGateOptions {
|
|
68
|
+
cwd: string
|
|
69
|
+
/** Apply lint auto-fixes during the run. */
|
|
70
|
+
fix?: boolean | undefined
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const runLintGate = async (
|
|
74
|
+
opts: LintGateOptions,
|
|
75
|
+
): Promise<GateResult> => {
|
|
76
|
+
const start = Date.now()
|
|
77
|
+
const findings: Finding[] = []
|
|
78
|
+
|
|
79
|
+
const result = await lint({
|
|
80
|
+
paths: [opts.cwd],
|
|
81
|
+
fix: opts.fix ?? false,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
for (const fileResult of result.files) {
|
|
85
|
+
for (const diag of fileResult.diagnostics) {
|
|
86
|
+
const severity = mapLintSeverity(diag.severity)
|
|
87
|
+
if (severity === null) continue
|
|
88
|
+
const category = RULE_CATEGORY.get(diag.ruleId) ?? 'correctness'
|
|
89
|
+
findings.push({
|
|
90
|
+
category,
|
|
91
|
+
severity,
|
|
92
|
+
code: `lint/${diag.ruleId}`,
|
|
93
|
+
gate: 'lint',
|
|
94
|
+
message: diag.message,
|
|
95
|
+
location: {
|
|
96
|
+
path: fileResult.filePath,
|
|
97
|
+
relPath: path.relative(opts.cwd, fileResult.filePath),
|
|
98
|
+
line: diag.loc.line,
|
|
99
|
+
column: diag.loc.column,
|
|
100
|
+
},
|
|
101
|
+
fixable: diag.fix !== undefined,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Surface config-level diagnostics as architecture errors — they
|
|
107
|
+
// mean the user's `.pyreonlintrc.json` has malformed rule options.
|
|
108
|
+
for (const cd of result.configDiagnostics) {
|
|
109
|
+
const severity = mapLintSeverity(cd.severity)
|
|
110
|
+
if (severity === null) continue
|
|
111
|
+
findings.push({
|
|
112
|
+
category: 'architecture',
|
|
113
|
+
severity,
|
|
114
|
+
code: `lint/config-${cd.ruleId}`,
|
|
115
|
+
gate: 'lint',
|
|
116
|
+
message: cd.message,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
gate: 'lint',
|
|
122
|
+
category: 'correctness',
|
|
123
|
+
findings,
|
|
124
|
+
meta: {
|
|
125
|
+
scanned: result.files.length,
|
|
126
|
+
elapsedMs: Date.now() - start,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|