@pyreon/cli 0.15.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.
@@ -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
+ }