@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,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pyreon-patterns gate — wraps `@pyreon/compiler:detectPyreonPatterns`.
|
|
3
|
+
*
|
|
4
|
+
* Catches "using Pyreon wrong" mistakes — 12 detector codes today
|
|
5
|
+
* (for-missing-by, props-destructured, signal-write-as-call, etc.).
|
|
6
|
+
* The detector matches the anti-patterns catalogue in
|
|
7
|
+
* `.claude/rules/anti-patterns.md` (entries tagged `[detector: ...]`)
|
|
8
|
+
* 1:1 — so the user reading the doctor output gets the same advice
|
|
9
|
+
* as someone running `validate` via MCP.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as fs from 'node:fs'
|
|
13
|
+
import * as path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
detectPyreonPatterns,
|
|
17
|
+
hasPyreonPatterns,
|
|
18
|
+
} from '@pyreon/compiler'
|
|
19
|
+
|
|
20
|
+
import type { Finding, GateResult } from '../types'
|
|
21
|
+
import { collectSourceFiles } from '../utils/walk'
|
|
22
|
+
|
|
23
|
+
export interface PyreonPatternsGateOptions {
|
|
24
|
+
cwd: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const runPyreonPatternsGate = async (
|
|
28
|
+
opts: PyreonPatternsGateOptions,
|
|
29
|
+
): Promise<GateResult> => {
|
|
30
|
+
const start = Date.now()
|
|
31
|
+
const findings: Finding[] = []
|
|
32
|
+
const files = collectSourceFiles(opts.cwd)
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
let code: string
|
|
36
|
+
try {
|
|
37
|
+
code = fs.readFileSync(file, 'utf-8')
|
|
38
|
+
} catch {
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (!hasPyreonPatterns(code)) continue
|
|
42
|
+
|
|
43
|
+
const relPath = path.relative(opts.cwd, file)
|
|
44
|
+
const diagnostics = detectPyreonPatterns(code, relPath)
|
|
45
|
+
|
|
46
|
+
for (const diag of diagnostics) {
|
|
47
|
+
findings.push({
|
|
48
|
+
category: 'correctness',
|
|
49
|
+
severity: 'warning',
|
|
50
|
+
code: `pyreon-patterns/${diag.code}`,
|
|
51
|
+
gate: 'pyreon-patterns',
|
|
52
|
+
message: diag.message,
|
|
53
|
+
location: {
|
|
54
|
+
path: file,
|
|
55
|
+
relPath,
|
|
56
|
+
line: diag.line,
|
|
57
|
+
column: diag.column,
|
|
58
|
+
},
|
|
59
|
+
fix: diag.suggested,
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
gate: 'pyreon-patterns',
|
|
66
|
+
category: 'correctness',
|
|
67
|
+
findings,
|
|
68
|
+
meta: { scanned: files.length, elapsedMs: Date.now() - start },
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* react-patterns gate — wraps `@pyreon/compiler:detectReactPatterns`.
|
|
3
|
+
*
|
|
4
|
+
* Catches "coming from React" mistakes: `useState` / `useEffect`,
|
|
5
|
+
* `className` / `htmlFor`, `onChange` on inputs, React-package
|
|
6
|
+
* imports, etc. The detector is already used standalone by the
|
|
7
|
+
* pre-PR-2 `pyreon doctor` legacy path; this adapter just emits its
|
|
8
|
+
* findings in the unified `Finding[]` shape so the v2 aggregator can
|
|
9
|
+
* fold them into the score.
|
|
10
|
+
*
|
|
11
|
+
* `--fix` mode delegates to `migrateReactCode` and reports BOTH the
|
|
12
|
+
* applied changes (as `info` findings) AND any residual diagnostics
|
|
13
|
+
* the migration didn't auto-resolve.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as fs from 'node:fs'
|
|
17
|
+
import * as path from 'node:path'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
detectReactPatterns,
|
|
21
|
+
hasReactPatterns,
|
|
22
|
+
migrateReactCode,
|
|
23
|
+
} from '@pyreon/compiler'
|
|
24
|
+
|
|
25
|
+
import type { Finding, GateResult } from '../types'
|
|
26
|
+
import { collectSourceFiles } from '../utils/walk'
|
|
27
|
+
|
|
28
|
+
export interface ReactPatternsGateOptions {
|
|
29
|
+
cwd: string
|
|
30
|
+
/** Apply `migrateReactCode` to each file with React patterns (writes to disk). */
|
|
31
|
+
fix?: boolean | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const runReactPatternsGate = async (
|
|
35
|
+
opts: ReactPatternsGateOptions,
|
|
36
|
+
): Promise<GateResult> => {
|
|
37
|
+
const start = Date.now()
|
|
38
|
+
const findings: Finding[] = []
|
|
39
|
+
const files = collectSourceFiles(opts.cwd)
|
|
40
|
+
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
let code: string
|
|
43
|
+
try {
|
|
44
|
+
code = fs.readFileSync(file, 'utf-8')
|
|
45
|
+
} catch {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
if (!hasReactPatterns(code)) continue
|
|
49
|
+
|
|
50
|
+
const relPath = path.relative(opts.cwd, file)
|
|
51
|
+
|
|
52
|
+
if (opts.fix) {
|
|
53
|
+
const migrated = migrateReactCode(code, relPath)
|
|
54
|
+
if (migrated.changes.length > 0) {
|
|
55
|
+
fs.writeFileSync(file, migrated.code, 'utf-8')
|
|
56
|
+
for (const ch of migrated.changes) {
|
|
57
|
+
findings.push({
|
|
58
|
+
category: 'correctness',
|
|
59
|
+
severity: 'info',
|
|
60
|
+
code: `react-patterns/auto-fixed-${ch.type}`,
|
|
61
|
+
gate: 'react-patterns',
|
|
62
|
+
message: `Auto-fixed: ${ch.description}`,
|
|
63
|
+
location: { path: file, relPath, line: ch.line },
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const remaining = detectReactPatterns(migrated.code, relPath)
|
|
68
|
+
for (const diag of remaining) {
|
|
69
|
+
findings.push({
|
|
70
|
+
category: 'correctness',
|
|
71
|
+
severity: diag.fixable ? 'warning' : 'error',
|
|
72
|
+
code: `react-patterns/${diag.code}`,
|
|
73
|
+
gate: 'react-patterns',
|
|
74
|
+
message: diag.message,
|
|
75
|
+
location: {
|
|
76
|
+
path: file,
|
|
77
|
+
relPath,
|
|
78
|
+
line: diag.line,
|
|
79
|
+
column: diag.column,
|
|
80
|
+
},
|
|
81
|
+
fix: diag.suggested,
|
|
82
|
+
fixable: diag.fixable,
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
const diagnostics = detectReactPatterns(code, relPath)
|
|
87
|
+
for (const diag of diagnostics) {
|
|
88
|
+
findings.push({
|
|
89
|
+
category: 'correctness',
|
|
90
|
+
severity: diag.fixable ? 'warning' : 'error',
|
|
91
|
+
code: `react-patterns/${diag.code}`,
|
|
92
|
+
gate: 'react-patterns',
|
|
93
|
+
message: diag.message,
|
|
94
|
+
location: {
|
|
95
|
+
path: file,
|
|
96
|
+
relPath,
|
|
97
|
+
line: diag.line,
|
|
98
|
+
column: diag.column,
|
|
99
|
+
},
|
|
100
|
+
fix: diag.suggested,
|
|
101
|
+
fixable: diag.fixable,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
gate: 'react-patterns',
|
|
109
|
+
category: 'correctness',
|
|
110
|
+
findings,
|
|
111
|
+
meta: { scanned: files.length, elapsedMs: Date.now() - start },
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ssg-audit gate — wraps `@pyreon/compiler:auditSsg`.
|
|
3
|
+
*
|
|
4
|
+
* SSG / ISR convention checker (M3.4): `_404.tsx` placement, dynamic
|
|
5
|
+
* routes missing `getStaticPaths`, non-literal revalidate exports.
|
|
6
|
+
* Severities mirror the gate's intent: missing-getStaticPaths is a
|
|
7
|
+
* warn (legit under `mode: 'ssr' | 'isr'`), the other two are errors
|
|
8
|
+
* (silently broken under `mode: 'ssg'`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { auditSsg, type SsgFindingCode } from '@pyreon/compiler'
|
|
12
|
+
|
|
13
|
+
import type { Finding, GateResult, Severity } from '../types'
|
|
14
|
+
|
|
15
|
+
const SEVERITY_BY_CODE: Record<SsgFindingCode, Severity> = {
|
|
16
|
+
'404-outside-layout-dir': 'error',
|
|
17
|
+
'dynamic-route-missing-get-static-paths': 'warning',
|
|
18
|
+
'non-literal-revalidate-export': 'error',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SsgAuditGateOptions {
|
|
22
|
+
cwd: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const runSsgAuditGate = async (
|
|
26
|
+
opts: SsgAuditGateOptions,
|
|
27
|
+
): Promise<GateResult> => {
|
|
28
|
+
const start = Date.now()
|
|
29
|
+
const findings: Finding[] = []
|
|
30
|
+
const result = auditSsg(opts.cwd)
|
|
31
|
+
|
|
32
|
+
for (const f of result.findings) {
|
|
33
|
+
findings.push({
|
|
34
|
+
category: 'architecture',
|
|
35
|
+
severity: SEVERITY_BY_CODE[f.code] ?? 'error',
|
|
36
|
+
code: `ssg-audit/${f.code}`,
|
|
37
|
+
gate: 'ssg-audit',
|
|
38
|
+
message: f.message,
|
|
39
|
+
location: {
|
|
40
|
+
path: f.location.path,
|
|
41
|
+
relPath: f.location.relPath,
|
|
42
|
+
line: f.location.line,
|
|
43
|
+
column: f.location.column,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
gate: 'ssg-audit',
|
|
50
|
+
category: 'architecture',
|
|
51
|
+
findings,
|
|
52
|
+
meta: {
|
|
53
|
+
scanned: result.findings.length,
|
|
54
|
+
elapsedMs: Date.now() - start,
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor orchestrator — picks the gate list per mode, runs them in
|
|
3
|
+
* parallel where safe, collects results, hands off to the aggregator.
|
|
4
|
+
*
|
|
5
|
+
* **Gate categorization by speed**:
|
|
6
|
+
* - FAST gates (default): react-patterns, pyreon-patterns, lint,
|
|
7
|
+
* distribution, doc-claims, islands-audit, ssg-audit, audit-tests.
|
|
8
|
+
* Total runtime ~2-5s on the real Pyreon repo.
|
|
9
|
+
* - SLOW gates (`--full` opt-in): audit-types (TS compiler-API walk
|
|
10
|
+
* across 6 packages, ~1-30s), bundle-budgets (Bun.build of every
|
|
11
|
+
* published package, ~15-30s).
|
|
12
|
+
*
|
|
13
|
+
* **Why parallel**: the gates are fully independent — no shared
|
|
14
|
+
* state, no file-write contention (only `--fix` writes, and lint /
|
|
15
|
+
* react-patterns target disjoint file patterns). Running them via
|
|
16
|
+
* `Promise.all` cuts wall-clock from ~5s sequential to ~1-2s for the
|
|
17
|
+
* fast set on a warm cache.
|
|
18
|
+
*
|
|
19
|
+
* **Skip filtering**: `--only` and `--skip` operate on gate names.
|
|
20
|
+
* Skipped gates appear in the report with `meta.skipped: true` so
|
|
21
|
+
* the renderer shows them in the footer ("Skipped: bundle-budgets").
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
runAuditTestsGate,
|
|
26
|
+
runAuditTypesGate,
|
|
27
|
+
runBundleBudgetsGate,
|
|
28
|
+
runDistributionGate,
|
|
29
|
+
runDocClaimsGate,
|
|
30
|
+
runIslandsAuditGate,
|
|
31
|
+
runLintGate,
|
|
32
|
+
runPyreonPatternsGate,
|
|
33
|
+
runReactPatternsGate,
|
|
34
|
+
runSsgAuditGate,
|
|
35
|
+
} from './gates'
|
|
36
|
+
import { buildReport } from './report'
|
|
37
|
+
import type { DoctorReport, GateResult } from './types'
|
|
38
|
+
|
|
39
|
+
export type GateName =
|
|
40
|
+
| 'react-patterns'
|
|
41
|
+
| 'pyreon-patterns'
|
|
42
|
+
| 'lint'
|
|
43
|
+
| 'distribution'
|
|
44
|
+
| 'doc-claims'
|
|
45
|
+
| 'audit-tests'
|
|
46
|
+
| 'islands-audit'
|
|
47
|
+
| 'ssg-audit'
|
|
48
|
+
| 'audit-types'
|
|
49
|
+
| 'bundle-budgets'
|
|
50
|
+
|
|
51
|
+
/** Gates that run by default (fast). */
|
|
52
|
+
const FAST_GATES: GateName[] = [
|
|
53
|
+
'react-patterns',
|
|
54
|
+
'pyreon-patterns',
|
|
55
|
+
'lint',
|
|
56
|
+
'distribution',
|
|
57
|
+
'doc-claims',
|
|
58
|
+
'islands-audit',
|
|
59
|
+
'ssg-audit',
|
|
60
|
+
'audit-tests',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
/** Gates that require `--full` to enable. */
|
|
64
|
+
const SLOW_GATES: GateName[] = ['audit-types', 'bundle-budgets']
|
|
65
|
+
|
|
66
|
+
export interface OrchestratorOptions {
|
|
67
|
+
cwd: string
|
|
68
|
+
/** Enable slow gates (audit-types, bundle-budgets). */
|
|
69
|
+
full?: boolean | undefined
|
|
70
|
+
/** Run ONLY these gates (overrides `--full` / default selection). */
|
|
71
|
+
only?: GateName[] | undefined
|
|
72
|
+
/** Exclude these gates from whatever set would otherwise run. */
|
|
73
|
+
skip?: GateName[] | undefined
|
|
74
|
+
/** Apply auto-fixes where supported (lint + react-patterns). */
|
|
75
|
+
fix?: boolean | undefined
|
|
76
|
+
/** Min risk for the test-environment audit. Defaults to `'medium'`. */
|
|
77
|
+
auditMinRisk?: 'high' | 'medium' | 'low' | undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const skippedGate = (
|
|
81
|
+
gate: GateName,
|
|
82
|
+
category: GateResult['category'],
|
|
83
|
+
reason: string,
|
|
84
|
+
): GateResult => ({
|
|
85
|
+
gate,
|
|
86
|
+
category,
|
|
87
|
+
findings: [],
|
|
88
|
+
meta: { elapsedMs: 0, skipped: true, skipReason: reason },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const ALL_GATE_CATEGORIES: Record<GateName, GateResult['category']> = {
|
|
92
|
+
'react-patterns': 'correctness',
|
|
93
|
+
'pyreon-patterns': 'correctness',
|
|
94
|
+
lint: 'correctness',
|
|
95
|
+
distribution: 'architecture',
|
|
96
|
+
'doc-claims': 'documentation',
|
|
97
|
+
'audit-tests': 'testing',
|
|
98
|
+
'islands-audit': 'architecture',
|
|
99
|
+
'ssg-audit': 'architecture',
|
|
100
|
+
'audit-types': 'architecture',
|
|
101
|
+
'bundle-budgets': 'performance',
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve which gates to run.
|
|
106
|
+
*
|
|
107
|
+
* Precedence: `--only` > `--skip` > (`--full` toggles slow gates) > default fast set.
|
|
108
|
+
*/
|
|
109
|
+
export const resolveGates = (opts: OrchestratorOptions): GateName[] => {
|
|
110
|
+
if (opts.only && opts.only.length > 0) {
|
|
111
|
+
const skip = new Set(opts.skip ?? [])
|
|
112
|
+
return opts.only.filter((g) => !skip.has(g))
|
|
113
|
+
}
|
|
114
|
+
const base: GateName[] = opts.full ? [...FAST_GATES, ...SLOW_GATES] : [...FAST_GATES]
|
|
115
|
+
const skip = new Set(opts.skip ?? [])
|
|
116
|
+
return base.filter((g) => !skip.has(g))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Run the gates and build the report. Wall-clock is measured here
|
|
121
|
+
* (vs the aggregator's sum-of-elapsedMs which is total CPU time).
|
|
122
|
+
*/
|
|
123
|
+
export const runDoctor = async (
|
|
124
|
+
opts: OrchestratorOptions,
|
|
125
|
+
): Promise<DoctorReport> => {
|
|
126
|
+
const start = Date.now()
|
|
127
|
+
const selected = new Set(resolveGates(opts))
|
|
128
|
+
const all: GateName[] = [...FAST_GATES, ...SLOW_GATES]
|
|
129
|
+
|
|
130
|
+
const promises = all.map(async (gate): Promise<GateResult> => {
|
|
131
|
+
if (!selected.has(gate)) {
|
|
132
|
+
// Distinguish "user explicitly skipped" from "needs --full".
|
|
133
|
+
const reason = SLOW_GATES.includes(gate) && !opts.full
|
|
134
|
+
? 'enable with --full'
|
|
135
|
+
: 'skipped'
|
|
136
|
+
return skippedGate(gate, ALL_GATE_CATEGORIES[gate], reason)
|
|
137
|
+
}
|
|
138
|
+
return runGate(gate, opts)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const gates = await Promise.all(promises)
|
|
142
|
+
const report = buildReport(gates)
|
|
143
|
+
// Overwrite the sum-of-elapsedMs proxy with the real wall-clock.
|
|
144
|
+
return { ...report, elapsedMs: Date.now() - start }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const runGate = async (
|
|
148
|
+
gate: GateName,
|
|
149
|
+
opts: OrchestratorOptions,
|
|
150
|
+
): Promise<GateResult> => {
|
|
151
|
+
switch (gate) {
|
|
152
|
+
case 'react-patterns':
|
|
153
|
+
return runReactPatternsGate({ cwd: opts.cwd, fix: opts.fix })
|
|
154
|
+
case 'pyreon-patterns':
|
|
155
|
+
return runPyreonPatternsGate({ cwd: opts.cwd })
|
|
156
|
+
case 'lint':
|
|
157
|
+
return runLintGate({ cwd: opts.cwd, fix: opts.fix })
|
|
158
|
+
case 'distribution':
|
|
159
|
+
return runDistributionGate({ cwd: opts.cwd, skipPackProbe: true })
|
|
160
|
+
case 'doc-claims':
|
|
161
|
+
return runDocClaimsGate({ cwd: opts.cwd })
|
|
162
|
+
case 'audit-tests':
|
|
163
|
+
return runAuditTestsGate({
|
|
164
|
+
cwd: opts.cwd,
|
|
165
|
+
minRisk: opts.auditMinRisk ?? 'medium',
|
|
166
|
+
})
|
|
167
|
+
case 'islands-audit':
|
|
168
|
+
return runIslandsAuditGate({ cwd: opts.cwd })
|
|
169
|
+
case 'ssg-audit':
|
|
170
|
+
return runSsgAuditGate({ cwd: opts.cwd })
|
|
171
|
+
case 'audit-types':
|
|
172
|
+
return runAuditTypesGate({ cwd: opts.cwd })
|
|
173
|
+
case 'bundle-budgets':
|
|
174
|
+
return runBundleBudgetsGate({ cwd: opts.cwd })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ANSI helpers for `pyreon doctor` human output.
|
|
3
|
+
*
|
|
4
|
+
* We deliberately don't pull in `chalk` / `kleur` / etc. — the doctor
|
|
5
|
+
* already lives in `@pyreon/cli` (the entrypoint package) and adding
|
|
6
|
+
* a runtime ANSI library bumps the install footprint. The escapes
|
|
7
|
+
* are tiny and stable.
|
|
8
|
+
*
|
|
9
|
+
* The `enabled` flag respects:
|
|
10
|
+
* - `NO_COLOR=1` → disable (de-facto standard)
|
|
11
|
+
* - `FORCE_COLOR=1` / `0` → force on / off
|
|
12
|
+
* - `process.stdout.isTTY` → default if no override
|
|
13
|
+
* - CI tools like GitHub Actions set `CI=1` but their terminal DOES
|
|
14
|
+
* render ANSI — so CI alone doesn't disable color.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// ANSI control codes. ESC (char 27 / 0x1B) + open-bracket (CSI) for
|
|
18
|
+
// SGR codes; ESC + close-bracket-8 for OSC-8 hyperlinks. We build the
|
|
19
|
+
// strings via `String.fromCharCode(27)` rather than `` literals
|
|
20
|
+
// so the source code stays free of non-printable bytes (some editors
|
|
21
|
+
// + lint rules complain about raw ESC).
|
|
22
|
+
const ESC = String.fromCharCode(27)
|
|
23
|
+
const CSI = `${ESC}[`
|
|
24
|
+
const OSC = `${ESC}]`
|
|
25
|
+
// String terminator — ESC + backslash. BEL also works in most
|
|
26
|
+
// terminals but ESC-\ is the spec-compliant form.
|
|
27
|
+
const ST = `${ESC}\\`
|
|
28
|
+
|
|
29
|
+
const isColorEnabled = (): boolean => {
|
|
30
|
+
if (process.env.NO_COLOR) return false
|
|
31
|
+
if (process.env.FORCE_COLOR === '0') return false
|
|
32
|
+
if (process.env.FORCE_COLOR) return true
|
|
33
|
+
return Boolean(process.stdout.isTTY)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const colorEnabled = isColorEnabled()
|
|
37
|
+
|
|
38
|
+
const wrap =
|
|
39
|
+
(open: string, close: string) =>
|
|
40
|
+
(s: string): string =>
|
|
41
|
+
colorEnabled ? `${CSI}${open}m${s}${CSI}${close}m` : s
|
|
42
|
+
|
|
43
|
+
export const bold = wrap('1', '22')
|
|
44
|
+
export const dim = wrap('2', '22')
|
|
45
|
+
export const red = wrap('31', '39')
|
|
46
|
+
export const green = wrap('32', '39')
|
|
47
|
+
export const yellow = wrap('33', '39')
|
|
48
|
+
export const blue = wrap('34', '39')
|
|
49
|
+
export const magenta = wrap('35', '39')
|
|
50
|
+
export const cyan = wrap('36', '39')
|
|
51
|
+
export const gray = wrap('90', '39')
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* OSC-8 hyperlink. iTerm2, WezTerm, kitty, modern VSCode terminals
|
|
55
|
+
* render this as a clickable link; other terminals show the visible
|
|
56
|
+
* text and ignore the escape. We use this for file paths so the user
|
|
57
|
+
* can cmd-click a finding's location and jump to it.
|
|
58
|
+
*
|
|
59
|
+
* `url` should be a `file://` URL with optional line/column:
|
|
60
|
+
* `file:///path/to/file.ts#L42`
|
|
61
|
+
*/
|
|
62
|
+
export const hyperlink = (text: string, url: string): string => {
|
|
63
|
+
if (!colorEnabled) return text
|
|
64
|
+
return `${OSC}8;;${url}${ST}${text}${OSC}8;;${ST}`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build a `file://` URL with optional line / column suffix. */
|
|
68
|
+
export const fileUrl = (
|
|
69
|
+
absPath: string,
|
|
70
|
+
line?: number,
|
|
71
|
+
_column?: number,
|
|
72
|
+
): string => {
|
|
73
|
+
// file:// URLs don't have a standard line/column shape across
|
|
74
|
+
// terminals; vscode uses `#L<line>`, iTerm2 + others use `:line:col`
|
|
75
|
+
// in the visible text but not the URL. We embed `#L<line>` since
|
|
76
|
+
// it's the form VSCode parses natively (the most common consumer).
|
|
77
|
+
let url = `file://${absPath}`
|
|
78
|
+
if (line !== undefined) url += `#L${line}`
|
|
79
|
+
return url
|
|
80
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Actions annotation renderer.
|
|
3
|
+
*
|
|
4
|
+
* Emits per-finding `::error file=X,line=Y,col=Z::message` lines that
|
|
5
|
+
* GitHub Actions parses into inline PR annotations (clickable in the
|
|
6
|
+
* "Files changed" tab). One line per finding plus a summary header.
|
|
7
|
+
*
|
|
8
|
+
* Severity map: doctor `error` → GHA `error`, doctor `warning` →
|
|
9
|
+
* GHA `warning`, doctor `info` → GHA `notice`.
|
|
10
|
+
*
|
|
11
|
+
* Reference: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DoctorReport, Severity } from '../types'
|
|
15
|
+
|
|
16
|
+
const GHA_LEVEL: Record<Severity, string> = {
|
|
17
|
+
error: 'error',
|
|
18
|
+
warning: 'warning',
|
|
19
|
+
info: 'notice',
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const escape = (s: string): string =>
|
|
23
|
+
// GHA annotations require these chars URL-encoded
|
|
24
|
+
s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A')
|
|
25
|
+
|
|
26
|
+
export const renderGha = (report: DoctorReport): string => {
|
|
27
|
+
const lines: string[] = []
|
|
28
|
+
|
|
29
|
+
// Summary header — visible at the top of the workflow log + on the
|
|
30
|
+
// job summary if `$GITHUB_STEP_SUMMARY` is also written.
|
|
31
|
+
lines.push(
|
|
32
|
+
`::notice::pyreon doctor score: ${report.score}/100 (${report.grade}) — ${report.totals.errors} errors, ${report.totals.warnings} warnings, ${report.totals.infos} info`,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
for (const f of report.findings) {
|
|
36
|
+
const level = GHA_LEVEL[f.severity]
|
|
37
|
+
const props: string[] = []
|
|
38
|
+
props.push(`title=${escape(f.code)}`)
|
|
39
|
+
if (f.location?.relPath) props.push(`file=${escape(f.location.relPath)}`)
|
|
40
|
+
if (f.location?.line) props.push(`line=${f.location.line}`)
|
|
41
|
+
if (f.location?.column) props.push(`col=${f.location.column}`)
|
|
42
|
+
const msg = f.fix ? `${f.message} — ${f.fix}` : f.message
|
|
43
|
+
lines.push(`::${level} ${props.join(',')}::${escape(msg)}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines.join('\n')
|
|
47
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable JSON renderer. The full `DoctorReport` is
|
|
3
|
+
* structured for stable consumption — AI agents reading the output,
|
|
4
|
+
* CI dashboards, third-party tools all see the same shape.
|
|
5
|
+
*
|
|
6
|
+
* Backward-compatibility note: the legacy `doctor.ts` pre-PR-2
|
|
7
|
+
* emitted a different JSON shape (`{ passed, files, summary }`). The
|
|
8
|
+
* legacy `--json` is preserved via the compat path in the CLI
|
|
9
|
+
* orchestrator; this renderer is the new shape gated on `--format=json`
|
|
10
|
+
* (or default JSON when the v2 flag set is in use).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { DoctorReport } from '../types'
|
|
14
|
+
|
|
15
|
+
export const renderJson = (report: DoctorReport): string =>
|
|
16
|
+
JSON.stringify(report, null, 2)
|