@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
|
+
* Human-readable renderer for `DoctorReport`.
|
|
3
|
+
*
|
|
4
|
+
* Output shape (after react.doctor.com):
|
|
5
|
+
* 1. Big score banner with letter grade + label
|
|
6
|
+
* 2. Per-category bar chart (filled cells = score)
|
|
7
|
+
* 3. Top-N findings with severity icon, code, location, message, fix
|
|
8
|
+
* 4. Footer: skipped gates, totals, elapsed, run hints
|
|
9
|
+
*
|
|
10
|
+
* Colors degrade gracefully — see `ansi.ts:colorEnabled`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
CategoryScore,
|
|
15
|
+
DoctorReport,
|
|
16
|
+
Finding,
|
|
17
|
+
Grade,
|
|
18
|
+
Severity,
|
|
19
|
+
} from '../types'
|
|
20
|
+
import {
|
|
21
|
+
bold,
|
|
22
|
+
cyan,
|
|
23
|
+
dim,
|
|
24
|
+
fileUrl,
|
|
25
|
+
gray,
|
|
26
|
+
green,
|
|
27
|
+
hyperlink,
|
|
28
|
+
red,
|
|
29
|
+
yellow,
|
|
30
|
+
} from './ansi'
|
|
31
|
+
|
|
32
|
+
const BAR_WIDTH = 12
|
|
33
|
+
const FILLED = '█'
|
|
34
|
+
const EMPTY = '░'
|
|
35
|
+
|
|
36
|
+
const SEV_ICON: Record<Severity, string> = {
|
|
37
|
+
error: '✖',
|
|
38
|
+
warning: '⚠',
|
|
39
|
+
info: 'ℹ',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const colorForGrade = (g: Grade): ((s: string) => string) => {
|
|
43
|
+
if (g === 'A') return green
|
|
44
|
+
if (g === 'B' || g === 'C') return yellow
|
|
45
|
+
return red
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const colorForSeverity = (s: Severity): ((str: string) => string) => {
|
|
49
|
+
if (s === 'error') return red
|
|
50
|
+
if (s === 'warning') return yellow
|
|
51
|
+
return cyan
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const renderBar = (score: number, color: (s: string) => string): string => {
|
|
55
|
+
const filled = Math.round((score / 100) * BAR_WIDTH)
|
|
56
|
+
const empty = BAR_WIDTH - filled
|
|
57
|
+
return color(FILLED.repeat(filled)) + gray(EMPTY.repeat(empty))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const padRight = (s: string, n: number): string =>
|
|
61
|
+
s.length >= n ? s : s + ' '.repeat(n - s.length)
|
|
62
|
+
|
|
63
|
+
const renderCategory = (c: CategoryScore): string => {
|
|
64
|
+
if (!c.included) {
|
|
65
|
+
return ` ${dim(padRight(c.category, 14))} ${gray('skipped')}`
|
|
66
|
+
}
|
|
67
|
+
const color = colorForGrade(c.grade)
|
|
68
|
+
const bar = renderBar(c.score, color)
|
|
69
|
+
const score = color(padRight(String(c.score), 3))
|
|
70
|
+
const breakdown =
|
|
71
|
+
c.errors + c.warnings + c.infos === 0
|
|
72
|
+
? gray('clean')
|
|
73
|
+
: [
|
|
74
|
+
c.errors > 0 ? red(`${c.errors}E`) : '',
|
|
75
|
+
c.warnings > 0 ? yellow(`${c.warnings}W`) : '',
|
|
76
|
+
c.infos > 0 ? cyan(`${c.infos}i`) : '',
|
|
77
|
+
]
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
.join(' ')
|
|
80
|
+
return ` ${padRight(c.category, 14)} ${bar} ${score} ${dim('·')} ${breakdown}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const renderBanner = (report: DoctorReport): string => {
|
|
84
|
+
const gColor = colorForGrade(report.grade)
|
|
85
|
+
const score = gColor(bold(String(report.score)))
|
|
86
|
+
const grade = gColor(bold(report.grade))
|
|
87
|
+
const lines = [
|
|
88
|
+
'',
|
|
89
|
+
` ${bold('pyreon doctor')} ${dim('· project health audit')}`,
|
|
90
|
+
'',
|
|
91
|
+
` Score: ${score}/100 Grade: ${grade}`,
|
|
92
|
+
'',
|
|
93
|
+
]
|
|
94
|
+
return lines.join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const renderFinding = (f: Finding): string => {
|
|
98
|
+
const icon = colorForSeverity(f.severity)(SEV_ICON[f.severity])
|
|
99
|
+
const code = dim(f.code)
|
|
100
|
+
const header = ` ${icon} ${bold(f.message)} ${code}`
|
|
101
|
+
|
|
102
|
+
const lines = [header]
|
|
103
|
+
|
|
104
|
+
if (f.location) {
|
|
105
|
+
const relPath = f.location.relPath || f.location.path
|
|
106
|
+
const lineCol = f.location.line
|
|
107
|
+
? `:${f.location.line}${f.location.column !== undefined ? `:${f.location.column}` : ''}`
|
|
108
|
+
: ''
|
|
109
|
+
const visible = `${relPath}${lineCol}`
|
|
110
|
+
const linked = hyperlink(
|
|
111
|
+
cyan(visible),
|
|
112
|
+
fileUrl(f.location.path, f.location.line, f.location.column),
|
|
113
|
+
)
|
|
114
|
+
lines.push(` ${linked}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (f.relatedLocations && f.relatedLocations.length > 0) {
|
|
118
|
+
for (const rl of f.relatedLocations) {
|
|
119
|
+
const relPath = rl.relPath || rl.path
|
|
120
|
+
const lineCol = rl.line ? `:${rl.line}` : ''
|
|
121
|
+
const label = rl.label ? ` ${dim(`(${rl.label})`)}` : ''
|
|
122
|
+
lines.push(` ${dim('↳')} ${cyan(relPath + lineCol)}${label}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (f.fix) {
|
|
127
|
+
lines.push(` ${dim('fix:')} ${f.fix}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return lines.join('\n')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const renderFindings = (report: DoctorReport, topN: number): string => {
|
|
134
|
+
if (report.findings.length === 0) {
|
|
135
|
+
return ` ${green('✓')} No findings. Your project is healthy.\n`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const shown = report.findings.slice(0, topN)
|
|
139
|
+
const remaining = report.findings.length - shown.length
|
|
140
|
+
|
|
141
|
+
const lines = [bold(` Top findings (${shown.length} of ${report.findings.length}):`), '']
|
|
142
|
+
for (const f of shown) {
|
|
143
|
+
lines.push(renderFinding(f))
|
|
144
|
+
lines.push('')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (remaining > 0) {
|
|
148
|
+
lines.push(
|
|
149
|
+
dim(
|
|
150
|
+
` …and ${remaining} more. Run with ${bold('--json')} for the full list.`,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
lines.push('')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines.join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const renderSkipped = (report: DoctorReport): string => {
|
|
160
|
+
const skipped = report.gates.filter((g) => g.meta.skipped)
|
|
161
|
+
if (skipped.length === 0) return ''
|
|
162
|
+
const names = skipped
|
|
163
|
+
.map((g) => `${g.gate}${g.meta.skipReason ? ` (${g.meta.skipReason})` : ''}`)
|
|
164
|
+
.join(', ')
|
|
165
|
+
return ` ${dim('Skipped:')} ${names}\n`
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const renderFooter = (report: DoctorReport): string => {
|
|
169
|
+
const { errors, warnings, infos } = report.totals
|
|
170
|
+
const counts = [
|
|
171
|
+
errors > 0 ? red(`${errors} error${errors === 1 ? '' : 's'}`) : '',
|
|
172
|
+
warnings > 0 ? yellow(`${warnings} warning${warnings === 1 ? '' : 's'}`) : '',
|
|
173
|
+
infos > 0 ? cyan(`${infos} info`) : '',
|
|
174
|
+
]
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.join(`${dim(' · ')}`)
|
|
177
|
+
|
|
178
|
+
const totalSummary = counts || green('no findings')
|
|
179
|
+
const elapsed = `${(report.elapsedMs / 1000).toFixed(1)}s`
|
|
180
|
+
return ` ${totalSummary} ${dim(`· ${report.gates.filter((g) => !g.meta.skipped).length} gates · ${elapsed}`)}\n`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface TextRenderOptions {
|
|
184
|
+
/** cwd kept for future relative-path display in findings (unused today). */
|
|
185
|
+
cwd?: string | undefined
|
|
186
|
+
/** Max findings to show in human output. Default 10. */
|
|
187
|
+
topN?: number | undefined
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const renderText = (
|
|
191
|
+
report: DoctorReport,
|
|
192
|
+
opts: TextRenderOptions = {},
|
|
193
|
+
): string => {
|
|
194
|
+
const topN = opts.topN ?? 10
|
|
195
|
+
const sections = [
|
|
196
|
+
renderBanner(report),
|
|
197
|
+
bold(' Per category:'),
|
|
198
|
+
'',
|
|
199
|
+
...report.categories.map(renderCategory),
|
|
200
|
+
'',
|
|
201
|
+
renderFindings(report, topN),
|
|
202
|
+
renderSkipped(report),
|
|
203
|
+
renderFooter(report),
|
|
204
|
+
]
|
|
205
|
+
return sections.join('\n')
|
|
206
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DoctorReport` aggregator — collects gate results, builds findings
|
|
3
|
+
* list, computes the 0-100 score, returns the report the renderers
|
|
4
|
+
* consume. Pure-function: takes gate results in, returns report out.
|
|
5
|
+
*
|
|
6
|
+
* The orchestration layer (which gates to run, in what order, with
|
|
7
|
+
* what timeout) lives in the doctor command itself; this module is
|
|
8
|
+
* just the "merge + score" step. Splitting them keeps the score
|
|
9
|
+
* formula testable in isolation and makes it trivial to add a new
|
|
10
|
+
* gate (drop into the orchestrator's list, no aggregator changes).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { computeScore } from './score'
|
|
14
|
+
import type { DoctorReport, Finding, GateResult, Severity } from './types'
|
|
15
|
+
|
|
16
|
+
const SEVERITY_RANK: Record<Severity, number> = {
|
|
17
|
+
error: 0,
|
|
18
|
+
warning: 1,
|
|
19
|
+
info: 2,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sort findings: errors first, then warnings, then info. Within
|
|
24
|
+
* each severity, group by category for predictable output.
|
|
25
|
+
*/
|
|
26
|
+
const sortFindings = (findings: Finding[]): Finding[] =>
|
|
27
|
+
[...findings].sort((a, b) => {
|
|
28
|
+
const sevDelta = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]
|
|
29
|
+
if (sevDelta !== 0) return sevDelta
|
|
30
|
+
if (a.category !== b.category) return a.category.localeCompare(b.category)
|
|
31
|
+
return a.code.localeCompare(b.code)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export const buildReport = (gates: GateResult[]): DoctorReport => {
|
|
35
|
+
const findings = sortFindings(gates.flatMap((g) => g.findings))
|
|
36
|
+
|
|
37
|
+
const totals = {
|
|
38
|
+
errors: findings.filter((f) => f.severity === 'error').length,
|
|
39
|
+
warnings: findings.filter((f) => f.severity === 'warning').length,
|
|
40
|
+
infos: findings.filter((f) => f.severity === 'info').length,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const { score, grade, categories } = computeScore(findings, gates)
|
|
44
|
+
|
|
45
|
+
// Sum of per-gate elapsedMs — note this is NOT wall-clock if gates
|
|
46
|
+
// run in parallel. The orchestrator can override with a measured
|
|
47
|
+
// wall-clock when it has one; otherwise the sum is a useful proxy
|
|
48
|
+
// for "total work done".
|
|
49
|
+
const elapsedMs = gates.reduce((s, g) => s + g.meta.elapsedMs, 0)
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
score,
|
|
53
|
+
grade,
|
|
54
|
+
categories,
|
|
55
|
+
gates,
|
|
56
|
+
findings,
|
|
57
|
+
totals,
|
|
58
|
+
elapsedMs,
|
|
59
|
+
timestamp: new Date().toISOString(),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0-100 health-score formula for `pyreon doctor`.
|
|
3
|
+
*
|
|
4
|
+
* Per-finding penalty by severity:
|
|
5
|
+
* error = 10 points
|
|
6
|
+
* warning = 3 points
|
|
7
|
+
* info = 1 point
|
|
8
|
+
*
|
|
9
|
+
* Per-category subscore: `100 - clamp(sum(penalties), 0, 100)` — so
|
|
10
|
+
* 10 errors saturate to 0 in one category, 33 warnings = 1 point, etc.
|
|
11
|
+
* Linear-without-multiplier was chosen over multiplicative weighting
|
|
12
|
+
* because it gives the user predictable mental math: "fix one error,
|
|
13
|
+
* gain 10 points (capped at 100)".
|
|
14
|
+
*
|
|
15
|
+
* Overall score: simple mean of `included` category scores. Categories
|
|
16
|
+
* with no contributing gates are NOT included (a category that wasn't
|
|
17
|
+
* measured shouldn't pull the average up to 100). Five categories
|
|
18
|
+
* with equal weight gives any one bug class proportional impact —
|
|
19
|
+
* we don't try to weight "correctness > documentation" in code; the
|
|
20
|
+
* user reads the per-category bar chart and knows which to focus on.
|
|
21
|
+
*
|
|
22
|
+
* Letter grades:
|
|
23
|
+
* A: 90+
|
|
24
|
+
* B: 80-89
|
|
25
|
+
* C: 70-79
|
|
26
|
+
* D: 60-69
|
|
27
|
+
* F: <60
|
|
28
|
+
*
|
|
29
|
+
* Reference: react.doctor uses a similar shape — score, letter, per-
|
|
30
|
+
* category bars. The constants are tuned for Pyreon's larger gate set
|
|
31
|
+
* (10+ vs react.doctor's 4).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type {
|
|
35
|
+
CategoryScore,
|
|
36
|
+
Finding,
|
|
37
|
+
FindingCategory,
|
|
38
|
+
GateResult,
|
|
39
|
+
Grade,
|
|
40
|
+
Severity,
|
|
41
|
+
} from './types'
|
|
42
|
+
|
|
43
|
+
const CATEGORIES: FindingCategory[] = [
|
|
44
|
+
'correctness',
|
|
45
|
+
'performance',
|
|
46
|
+
'architecture',
|
|
47
|
+
'testing',
|
|
48
|
+
'documentation',
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
const SEVERITY_WEIGHTS: Record<Severity, number> = {
|
|
52
|
+
error: 10,
|
|
53
|
+
warning: 3,
|
|
54
|
+
info: 1,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pure scorer — no I/O, deterministic given findings. */
|
|
58
|
+
export const scoreCategory = (
|
|
59
|
+
category: FindingCategory,
|
|
60
|
+
findings: Finding[],
|
|
61
|
+
included: boolean,
|
|
62
|
+
): CategoryScore => {
|
|
63
|
+
const inCat = findings.filter((f) => f.category === category)
|
|
64
|
+
const errors = inCat.filter((f) => f.severity === 'error').length
|
|
65
|
+
const warnings = inCat.filter((f) => f.severity === 'warning').length
|
|
66
|
+
const infos = inCat.filter((f) => f.severity === 'info').length
|
|
67
|
+
|
|
68
|
+
const penalty =
|
|
69
|
+
errors * SEVERITY_WEIGHTS.error +
|
|
70
|
+
warnings * SEVERITY_WEIGHTS.warning +
|
|
71
|
+
infos * SEVERITY_WEIGHTS.info
|
|
72
|
+
|
|
73
|
+
const score = Math.max(0, Math.min(100, 100 - penalty))
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
category,
|
|
77
|
+
score,
|
|
78
|
+
errors,
|
|
79
|
+
warnings,
|
|
80
|
+
infos,
|
|
81
|
+
grade: gradeFor(score),
|
|
82
|
+
included,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const gradeFor = (score: number): Grade => {
|
|
87
|
+
if (score >= 90) return 'A'
|
|
88
|
+
if (score >= 80) return 'B'
|
|
89
|
+
if (score >= 70) return 'C'
|
|
90
|
+
if (score >= 60) return 'D'
|
|
91
|
+
return 'F'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute per-category subscores + overall score.
|
|
96
|
+
*
|
|
97
|
+
* `gates` is used to decide which categories are `included` — a
|
|
98
|
+
* category is included if at least one non-skipped gate emits in it.
|
|
99
|
+
* If no gate ran for `documentation`, the category is excluded from
|
|
100
|
+
* the overall mean rather than counted as 100/100.
|
|
101
|
+
*/
|
|
102
|
+
export const computeScore = (
|
|
103
|
+
findings: Finding[],
|
|
104
|
+
gates: GateResult[],
|
|
105
|
+
): { score: number; grade: Grade; categories: CategoryScore[] } => {
|
|
106
|
+
// A category is included if any non-skipped gate's default category
|
|
107
|
+
// matches OR any finding lands in that category. The findings check
|
|
108
|
+
// covers the "lint emits a perf-flavored finding" cross-cutting case
|
|
109
|
+
// (lint's default category may be 'correctness' but its findings can
|
|
110
|
+
// still register a perf hit).
|
|
111
|
+
const includedCats = new Set<FindingCategory>()
|
|
112
|
+
for (const g of gates) {
|
|
113
|
+
if (!g.meta.skipped) includedCats.add(g.category)
|
|
114
|
+
}
|
|
115
|
+
for (const f of findings) {
|
|
116
|
+
includedCats.add(f.category)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const categories = CATEGORIES.map((c) =>
|
|
120
|
+
scoreCategory(c, findings, includedCats.has(c)),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const included = categories.filter((c) => c.included)
|
|
124
|
+
// No gates ran at all — degenerate case. Surface a perfect score
|
|
125
|
+
// with an A so the human path doesn't crash on division-by-zero,
|
|
126
|
+
// but the renderer should detect this and show "no gates ran".
|
|
127
|
+
if (included.length === 0) {
|
|
128
|
+
return { score: 100, grade: 'A', categories }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sum = included.reduce((s, c) => s + c.score, 0)
|
|
132
|
+
const score = Math.round(sum / included.length)
|
|
133
|
+
return { score, grade: gradeFor(score), categories }
|
|
134
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified `Finding` + `GateResult` types shared by every doctor gate.
|
|
3
|
+
*
|
|
4
|
+
* Each programmatic gate (`runDistributionGate`, `runDocClaimsGate`, ...)
|
|
5
|
+
* returns `GateResult`. The aggregator in `pyreon doctor` merges every
|
|
6
|
+
* gate's findings into a `DoctorReport` with per-category subscores + an
|
|
7
|
+
* overall 0-100 health score — that aggregation layer lands in the
|
|
8
|
+
* follow-up PR (this PR is foundation-only).
|
|
9
|
+
*
|
|
10
|
+
* Why a unified shape now (PR 1) instead of together with the aggregator
|
|
11
|
+
* (PR 2): the gates are independently usable today via standalone scripts
|
|
12
|
+
* (`bun run check-distribution`, etc.). Locking the shape early means the
|
|
13
|
+
* scripts and the future aggregator consume the same `Finding[]` — no
|
|
14
|
+
* shim layer.
|
|
15
|
+
*
|
|
16
|
+
* Mirrors the existing per-detector shapes (`IslandFinding`, `SsgFinding`,
|
|
17
|
+
* `TestAuditEntry`) but elevated to a cross-gate vocabulary. Categories
|
|
18
|
+
* map onto the five react.doctor-style buckets so the score formula has
|
|
19
|
+
* a clear assignment per gate without case-by-case classification.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type FindingCategory =
|
|
23
|
+
| 'correctness'
|
|
24
|
+
| 'performance'
|
|
25
|
+
| 'architecture'
|
|
26
|
+
| 'testing'
|
|
27
|
+
| 'documentation'
|
|
28
|
+
|
|
29
|
+
export type Severity = 'error' | 'warning' | 'info'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A single actionable diagnostic. Every doctor gate emits Findings in
|
|
33
|
+
* this shape. Aggregation by category + severity drives the health score.
|
|
34
|
+
*/
|
|
35
|
+
export interface Finding {
|
|
36
|
+
/**
|
|
37
|
+
* Bucket the finding lands in for score aggregation. Each gate picks
|
|
38
|
+
* a default category for its emitted findings; an individual finding
|
|
39
|
+
* may override (e.g. a perf-flavored lint rule would still emit
|
|
40
|
+
* `category: 'performance'` even though the gate is `'lint'`).
|
|
41
|
+
*/
|
|
42
|
+
category: FindingCategory
|
|
43
|
+
|
|
44
|
+
/** Severity drives per-finding weight in the score formula. */
|
|
45
|
+
severity: Severity
|
|
46
|
+
|
|
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
|
+
/**
|
|
56
|
+
* Identifier of the gate that produced this finding. Useful for
|
|
57
|
+
* grouping in human output and `--skip <gate>` filtering. Examples:
|
|
58
|
+
* `'lint'`, `'audit-types'`, `'check-distribution'`, `'islands-audit'`.
|
|
59
|
+
*/
|
|
60
|
+
gate: string
|
|
61
|
+
|
|
62
|
+
/** One-paragraph human-readable explanation, including the fix path. */
|
|
63
|
+
message: string
|
|
64
|
+
|
|
65
|
+
/** Where the finding surfaces. Optional for project-wide findings. */
|
|
66
|
+
location?:
|
|
67
|
+
| {
|
|
68
|
+
/** Absolute path */
|
|
69
|
+
path: string
|
|
70
|
+
/** Path relative to the repo root for readable reporting */
|
|
71
|
+
relPath: string
|
|
72
|
+
/** 1-based line number */
|
|
73
|
+
line?: number | undefined
|
|
74
|
+
/** 1-based column number */
|
|
75
|
+
column?: number | undefined
|
|
76
|
+
}
|
|
77
|
+
| undefined
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Companion locations for cross-file findings (e.g. duplicate-island-
|
|
81
|
+
* name lists the second occurrence). Surfaces in human output below
|
|
82
|
+
* the primary location with an `↳` marker.
|
|
83
|
+
*/
|
|
84
|
+
relatedLocations?:
|
|
85
|
+
| Array<{
|
|
86
|
+
path: string
|
|
87
|
+
relPath: string
|
|
88
|
+
line?: number | undefined
|
|
89
|
+
column?: number | undefined
|
|
90
|
+
label?: string | undefined
|
|
91
|
+
}>
|
|
92
|
+
| undefined
|
|
93
|
+
|
|
94
|
+
/** Optional short fix hint shown under the message in human output. */
|
|
95
|
+
fix?: string | undefined
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `true` if `pyreon doctor --fix` can auto-resolve this. Currently
|
|
99
|
+
* limited to lint findings whose rule has an auto-fixer.
|
|
100
|
+
*/
|
|
101
|
+
fixable?: boolean | undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Result of running a single doctor gate. The aggregator collects N
|
|
106
|
+
* GateResults and computes the report.
|
|
107
|
+
*/
|
|
108
|
+
export interface GateResult {
|
|
109
|
+
/** Gate identifier (matches Finding.gate) */
|
|
110
|
+
gate: string
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Default category for findings this gate produces. The aggregator
|
|
114
|
+
* uses this as the fallback when a Finding doesn't override
|
|
115
|
+
* `category` itself — but Finding.category is the source of truth
|
|
116
|
+
* for score attribution.
|
|
117
|
+
*/
|
|
118
|
+
category: FindingCategory
|
|
119
|
+
|
|
120
|
+
/** All findings produced by this gate. May be empty. */
|
|
121
|
+
findings: Finding[]
|
|
122
|
+
|
|
123
|
+
/** Per-gate metadata for the human + JSON reports. */
|
|
124
|
+
meta: {
|
|
125
|
+
/** Number of files / packages / records the gate scanned. */
|
|
126
|
+
scanned?: number | undefined
|
|
127
|
+
/** Wall-clock duration in milliseconds. */
|
|
128
|
+
elapsedMs: number
|
|
129
|
+
/**
|
|
130
|
+
* `true` if the gate was skipped (e.g. `--skip <gate>`, missing
|
|
131
|
+
* prerequisite tool, mode-incompatible). The aggregator excludes
|
|
132
|
+
* skipped gates from the score and surfaces them in a "skipped"
|
|
133
|
+
* footer.
|
|
134
|
+
*/
|
|
135
|
+
skipped?: boolean | undefined
|
|
136
|
+
/**
|
|
137
|
+
* Why the gate was skipped (only meaningful when `skipped: true`).
|
|
138
|
+
*/
|
|
139
|
+
skipReason?: string | undefined
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Convenience constructor for in-source readability. */
|
|
144
|
+
export const finding = (f: Finding): Finding => f
|
|
145
|
+
|
|
146
|
+
// ─── DoctorReport (PR 2 — aggregation + score) ──────────────────────────
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Per-category subscore + raw counts. The aggregator builds one
|
|
150
|
+
* `CategoryScore` per `FindingCategory`, then averages them into the
|
|
151
|
+
* overall score. Categories with no findings AND no contributing gates
|
|
152
|
+
* (skipped or filtered out) get `included: false` and are excluded
|
|
153
|
+
* from the mean — keeping a perfect 100 for an unmeasured category
|
|
154
|
+
* would be misleading.
|
|
155
|
+
*/
|
|
156
|
+
export interface CategoryScore {
|
|
157
|
+
category: FindingCategory
|
|
158
|
+
/** 0-100 subscore for this bucket */
|
|
159
|
+
score: number
|
|
160
|
+
errors: number
|
|
161
|
+
warnings: number
|
|
162
|
+
infos: number
|
|
163
|
+
/** Letter grade derived from `score` (A/B/C/D/F) */
|
|
164
|
+
grade: Grade
|
|
165
|
+
/** False if no gate covered this category — drop from mean */
|
|
166
|
+
included: boolean
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type Grade = 'A' | 'B' | 'C' | 'D' | 'F'
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Final aggregated report `pyreon doctor` produces. The renderer
|
|
173
|
+
* (text / json / gha) consumes this; gate orchestration is upstream.
|
|
174
|
+
*/
|
|
175
|
+
export interface DoctorReport {
|
|
176
|
+
/** 0-100 weighted mean of included `categories[].score` */
|
|
177
|
+
score: number
|
|
178
|
+
/** Letter grade for `score` */
|
|
179
|
+
grade: Grade
|
|
180
|
+
/** Per-category breakdown (always 5 entries — `included` flags coverage) */
|
|
181
|
+
categories: CategoryScore[]
|
|
182
|
+
/** Every gate that ran (or was skipped, with `meta.skipped: true`) */
|
|
183
|
+
gates: GateResult[]
|
|
184
|
+
/** Flat list of all findings across gates, ordered by severity then category */
|
|
185
|
+
findings: Finding[]
|
|
186
|
+
/** Aggregate counts across all findings */
|
|
187
|
+
totals: {
|
|
188
|
+
errors: number
|
|
189
|
+
warnings: number
|
|
190
|
+
infos: number
|
|
191
|
+
}
|
|
192
|
+
/** Top-level wall-clock — sum of gates' elapsedMs (parallel sum, not max) */
|
|
193
|
+
elapsedMs: number
|
|
194
|
+
/** ISO timestamp of when the report was produced (for diffing across runs) */
|
|
195
|
+
timestamp: string
|
|
196
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared source-file walker for the per-file scanning gates
|
|
3
|
+
* (react-patterns, pyreon-patterns).
|
|
4
|
+
*
|
|
5
|
+
* The walker skips the standard non-source dirs (`node_modules`,
|
|
6
|
+
* `dist`, `lib`, `.git`, etc.) and matches `.ts` / `.tsx` / `.js` /
|
|
7
|
+
* `.jsx`. It's a thin wrapper around the original `collectSourceFiles`
|
|
8
|
+
* that lived in `doctor.ts` pre-PR-2; extracted here so any gate can
|
|
9
|
+
* use it without import-cycling through the doctor module.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs'
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set(['.tsx', '.jsx', '.ts', '.js'])
|
|
16
|
+
const IGNORE_DIRS = new Set([
|
|
17
|
+
'node_modules',
|
|
18
|
+
'dist',
|
|
19
|
+
'lib',
|
|
20
|
+
'.pyreon',
|
|
21
|
+
'.git',
|
|
22
|
+
'.next',
|
|
23
|
+
'build',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const shouldSkipDirEntry = (entry: fs.Dirent): boolean => {
|
|
27
|
+
if (!entry.isDirectory()) return false
|
|
28
|
+
return entry.name.startsWith('.') || IGNORE_DIRS.has(entry.name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const walk = (dir: string, results: string[]): void => {
|
|
32
|
+
let entries: fs.Dirent[]
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
35
|
+
} catch {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (shouldSkipDirEntry(entry)) continue
|
|
41
|
+
|
|
42
|
+
const fullPath = path.join(dir, entry.name)
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
walk(fullPath, results)
|
|
45
|
+
} else if (
|
|
46
|
+
entry.isFile() &&
|
|
47
|
+
SOURCE_EXTENSIONS.has(path.extname(entry.name))
|
|
48
|
+
) {
|
|
49
|
+
results.push(fullPath)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const collectSourceFiles = (cwd: string): string[] => {
|
|
55
|
+
const results: string[] = []
|
|
56
|
+
walk(cwd, results)
|
|
57
|
+
return results
|
|
58
|
+
}
|