@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
+ * 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
+ }