@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,674 @@
|
|
|
1
|
+
import * as fs from 'node:fs'
|
|
2
|
+
import * as os from 'node:os'
|
|
3
|
+
import * as path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
runAuditTypesGate,
|
|
9
|
+
runBundleBudgetsGate,
|
|
10
|
+
runDistributionGate,
|
|
11
|
+
runDocClaimsGate,
|
|
12
|
+
} from '../doctor/gates'
|
|
13
|
+
import { _parseAuditTypesOutput } from '../doctor/gates/audit-types'
|
|
14
|
+
import { _parseBundleBudgetsOutput } from '../doctor/gates/bundle-budgets'
|
|
15
|
+
import { _detectMapsInPackOutput } from '../doctor/gates/distribution'
|
|
16
|
+
import type { Finding, GateResult, Severity } from '../doctor/types'
|
|
17
|
+
import { finding } from '../doctor/types'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shape contract for every doctor gate. These tests assert the
|
|
21
|
+
* `GateResult` invariants so the PR 2 aggregator can rely on them
|
|
22
|
+
* without per-gate special-casing.
|
|
23
|
+
*/
|
|
24
|
+
function assertGateResultShape(result: GateResult, gate: string): void {
|
|
25
|
+
expect(result.gate).toBe(gate)
|
|
26
|
+
expect(typeof result.category).toBe('string')
|
|
27
|
+
expect(Array.isArray(result.findings)).toBe(true)
|
|
28
|
+
expect(typeof result.meta.elapsedMs).toBe('number')
|
|
29
|
+
expect(result.meta.elapsedMs).toBeGreaterThanOrEqual(0)
|
|
30
|
+
for (const f of result.findings) {
|
|
31
|
+
assertFindingShape(f, gate)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function assertFindingShape(f: Finding, gate: string): void {
|
|
36
|
+
expect(f.gate).toBe(gate)
|
|
37
|
+
expect(typeof f.code).toBe('string')
|
|
38
|
+
expect(f.code).toMatch(new RegExp(`^${gate}/`))
|
|
39
|
+
expect(typeof f.message).toBe('string')
|
|
40
|
+
expect(f.message.length).toBeGreaterThan(0)
|
|
41
|
+
const sev: Severity[] = ['error', 'warning', 'info']
|
|
42
|
+
expect(sev).toContain(f.severity)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Vitest runs the file without `import.meta.dir`; resolve from the
|
|
46
|
+
// known package location relative to the workspace root via a stable
|
|
47
|
+
// anchor (`packages/tools/cli` -> 3 levels up to repo root).
|
|
48
|
+
const REPO_ROOT = path.resolve(
|
|
49
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
50
|
+
'..',
|
|
51
|
+
'..',
|
|
52
|
+
'..',
|
|
53
|
+
'..',
|
|
54
|
+
'..',
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
describe('runDistributionGate', () => {
|
|
58
|
+
it('returns clean GateResult shape against real repo', async () => {
|
|
59
|
+
// skipPackProbe: true — the live `npm pack --dry-run` is slow
|
|
60
|
+
// under CI parallel load (100s+ vs ~5s standalone). The probe's
|
|
61
|
+
// .map-detection logic is covered separately by the
|
|
62
|
+
// _detectMapsInPackOutput unit tests below; coverage for the
|
|
63
|
+
// execFileSync invocation itself isn't worth the timeout risk.
|
|
64
|
+
const result = await runDistributionGate({
|
|
65
|
+
cwd: REPO_ROOT,
|
|
66
|
+
skipPackProbe: true,
|
|
67
|
+
})
|
|
68
|
+
assertGateResultShape(result, 'distribution')
|
|
69
|
+
expect(result.category).toBe('architecture')
|
|
70
|
+
expect(result.meta.scanned).toBeGreaterThan(0)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('runs the probe block but no-ops when probePackage is missing', async () => {
|
|
74
|
+
// skipPackProbe: false (default-on path) + probePackage that
|
|
75
|
+
// doesn't exist in the synthetic repo → `packages.find(...)`
|
|
76
|
+
// returns undefined → the inner try block is skipped without
|
|
77
|
+
// spawning npm. Covers the `if (!opts.skipPackProbe)` +
|
|
78
|
+
// `const probe = packages.find(...)` + `if (probe)` branches
|
|
79
|
+
// without depending on the npm subprocess (which times out on
|
|
80
|
+
// CI parallel load).
|
|
81
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-probe-'))
|
|
82
|
+
fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'ok'), {
|
|
83
|
+
recursive: true,
|
|
84
|
+
})
|
|
85
|
+
fs.writeFileSync(
|
|
86
|
+
path.join(tmp, 'packages', 'fundamentals', 'ok', 'package.json'),
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
name: '@pyreon/ok',
|
|
89
|
+
sideEffects: false,
|
|
90
|
+
files: ['lib', '!lib/**/*.map'],
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
const result = await runDistributionGate({
|
|
95
|
+
cwd: tmp,
|
|
96
|
+
probePackage: '@pyreon/does-not-exist',
|
|
97
|
+
// skipPackProbe omitted → defaults to false
|
|
98
|
+
})
|
|
99
|
+
assertGateResultShape(result, 'distribution')
|
|
100
|
+
expect(result.findings).toEqual([])
|
|
101
|
+
|
|
102
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('emits findings with the expected code prefixes when invariants fail', async () => {
|
|
106
|
+
// Create a synthetic broken package: published (no `private`),
|
|
107
|
+
// missing both sideEffects AND files-array map exclusion.
|
|
108
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-gate-'))
|
|
109
|
+
fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'broken'), {
|
|
110
|
+
recursive: true,
|
|
111
|
+
})
|
|
112
|
+
fs.writeFileSync(
|
|
113
|
+
path.join(tmp, 'packages', 'fundamentals', 'broken', 'package.json'),
|
|
114
|
+
JSON.stringify({ name: '@pyreon/broken', files: ['lib'] }),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
|
|
118
|
+
assertGateResultShape(result, 'distribution')
|
|
119
|
+
|
|
120
|
+
const codes = result.findings.map((f) => f.code).sort()
|
|
121
|
+
expect(codes).toContain('distribution/missing-sideEffects')
|
|
122
|
+
expect(codes).toContain('distribution/missing-map-exclusion')
|
|
123
|
+
for (const f of result.findings) {
|
|
124
|
+
expect(f.severity).toBe('error')
|
|
125
|
+
expect(f.message).toContain('@pyreon/broken')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('runDocClaimsGate', () => {
|
|
133
|
+
it('returns GateResult against real repo with expected shape', async () => {
|
|
134
|
+
const result = await runDocClaimsGate({ cwd: REPO_ROOT })
|
|
135
|
+
assertGateResultShape(result, 'doc-claims')
|
|
136
|
+
expect(result.category).toBe('documentation')
|
|
137
|
+
// The real repo has 7 claim sites configured today; verify the
|
|
138
|
+
// scanner found them all (any missing files would surface as
|
|
139
|
+
// file-missing findings, scanned count stays stable).
|
|
140
|
+
expect(result.meta.scanned).toBe(7)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Helpers to build a tmp repo with the hooks claim sources the
|
|
144
|
+
// doc-claims gate walks. Used by the drift / hedged / pattern-miss
|
|
145
|
+
// path-covering tests below.
|
|
146
|
+
function buildHooksRepo(
|
|
147
|
+
tmp: string,
|
|
148
|
+
opts: {
|
|
149
|
+
hookCount: number
|
|
150
|
+
readmeClaim?: string | null
|
|
151
|
+
manifestTagline?: string | null
|
|
152
|
+
claudeMdRow?: string | null
|
|
153
|
+
claudeMdArch?: string | null
|
|
154
|
+
docsIndex?: string | null
|
|
155
|
+
},
|
|
156
|
+
): void {
|
|
157
|
+
const hooksDir = path.join(tmp, 'packages', 'fundamentals', 'hooks')
|
|
158
|
+
fs.mkdirSync(path.join(hooksDir, 'src'), { recursive: true })
|
|
159
|
+
|
|
160
|
+
// Generate an index.ts with N `export { useX }` lines so the
|
|
161
|
+
// gate's countHookExports() returns hookCount. The regex requires
|
|
162
|
+
// `use[A-Z][a-zA-Z]+` so use letter-only suffixes (no digits).
|
|
163
|
+
const letters = 'abcdefghijklmnopqrstuvwxyz'
|
|
164
|
+
const exports = Array.from(
|
|
165
|
+
{ length: opts.hookCount },
|
|
166
|
+
(_, i) => {
|
|
167
|
+
const name = (letters[i] ?? `Z${i}`).toUpperCase() + letters[i] + 'ook'
|
|
168
|
+
return `export { useH${name} }`
|
|
169
|
+
},
|
|
170
|
+
).join('\n')
|
|
171
|
+
fs.writeFileSync(
|
|
172
|
+
path.join(hooksDir, 'src', 'index.ts'),
|
|
173
|
+
exports + '\n',
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if (opts.readmeClaim !== null) {
|
|
177
|
+
fs.writeFileSync(
|
|
178
|
+
path.join(hooksDir, 'README.md'),
|
|
179
|
+
opts.readmeClaim ?? `${opts.hookCount} signal-based reactive utilities\n`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (opts.manifestTagline !== null) {
|
|
184
|
+
fs.writeFileSync(
|
|
185
|
+
path.join(hooksDir, 'src', 'manifest.ts'),
|
|
186
|
+
opts.manifestTagline ??
|
|
187
|
+
`tagline: '${opts.hookCount} signal-based hooks: foo'\nSignal-based hooks for Pyreon — ${opts.hookCount} reactive primitives\n`,
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// CLAUDE.md carries TWO claim sites (table row + arch section)
|
|
192
|
+
fs.writeFileSync(
|
|
193
|
+
path.join(tmp, 'CLAUDE.md'),
|
|
194
|
+
`${opts.claudeMdRow ?? `| \`@pyreon/hooks\` | ${opts.hookCount} signal-based hooks for stuff |`}\n${
|
|
195
|
+
opts.claudeMdArch ?? `- ${opts.hookCount} signal-based hooks across 6 categories`
|
|
196
|
+
}\n3 doc pages covering all packages\n`,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
// docs/docs/index.md carries one
|
|
200
|
+
fs.mkdirSync(path.join(tmp, 'docs', 'docs'), { recursive: true })
|
|
201
|
+
fs.writeFileSync(
|
|
202
|
+
path.join(tmp, 'docs', 'docs', 'index.md'),
|
|
203
|
+
opts.docsIndex ?? `| ${opts.hookCount} signal-based hooks for common UI patterns\n`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
it('emits drift finding when claim count diverges from actual', async () => {
|
|
208
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-drift-'))
|
|
209
|
+
// Actual export count is 3, but the README hard-codes 99.
|
|
210
|
+
buildHooksRepo(tmp, {
|
|
211
|
+
hookCount: 3,
|
|
212
|
+
readmeClaim: '99 signal-based reactive utilities\n',
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const result = await runDocClaimsGate({ cwd: tmp })
|
|
216
|
+
assertGateResultShape(result, 'doc-claims')
|
|
217
|
+
const drift = result.findings.find((f) =>
|
|
218
|
+
f.code.endsWith('-drift'),
|
|
219
|
+
)!
|
|
220
|
+
expect(drift).toBeDefined()
|
|
221
|
+
expect(drift.severity).toBe('error')
|
|
222
|
+
expect(drift.message).toContain('claims 99')
|
|
223
|
+
expect(drift.message).toContain('actual 3')
|
|
224
|
+
expect(drift.fix).toContain('99 to 3')
|
|
225
|
+
|
|
226
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('emits hedged finding when CLAUDE.md uses "N+" form', async () => {
|
|
230
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-hedged-'))
|
|
231
|
+
// 3 actual exports; CLAUDE.md row uses the rejected hedged form.
|
|
232
|
+
buildHooksRepo(tmp, {
|
|
233
|
+
hookCount: 3,
|
|
234
|
+
claudeMdRow:
|
|
235
|
+
'| `@pyreon/hooks` | 3+ signal-based hooks for stuff |',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
const result = await runDocClaimsGate({ cwd: tmp })
|
|
239
|
+
assertGateResultShape(result, 'doc-claims')
|
|
240
|
+
const hedged = result.findings.find((f) =>
|
|
241
|
+
f.code.endsWith('-hedged'),
|
|
242
|
+
)!
|
|
243
|
+
expect(hedged).toBeDefined()
|
|
244
|
+
expect(hedged.severity).toBe('error')
|
|
245
|
+
expect(hedged.message).toContain('hedged claim')
|
|
246
|
+
expect(hedged.fix).toContain('"3+"')
|
|
247
|
+
|
|
248
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('emits pattern-miss warning when the claim file lost its pattern', async () => {
|
|
252
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-miss-'))
|
|
253
|
+
// Build a repo where the README exists but doesn't contain the
|
|
254
|
+
// pattern at all — claim was rephrased / removed.
|
|
255
|
+
buildHooksRepo(tmp, {
|
|
256
|
+
hookCount: 3,
|
|
257
|
+
readmeClaim: 'Hooks library for Pyreon. No numeric claim here.\n',
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const result = await runDocClaimsGate({ cwd: tmp })
|
|
261
|
+
assertGateResultShape(result, 'doc-claims')
|
|
262
|
+
const miss = result.findings.find((f) =>
|
|
263
|
+
f.code === 'doc-claims/hook-count-pattern-miss',
|
|
264
|
+
)!
|
|
265
|
+
expect(miss).toBeDefined()
|
|
266
|
+
expect(miss.severity).toBe('warning')
|
|
267
|
+
expect(miss.message).toContain('pattern not found')
|
|
268
|
+
|
|
269
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('emits file-missing finding when a claim file is absent (but at least one exists)', async () => {
|
|
273
|
+
// Build a minimal fake repo with the source-of-truth file AND at
|
|
274
|
+
// least one claim file present — that's the "this IS a Pyreon
|
|
275
|
+
// project, but some claim sites have been deleted/moved" shape.
|
|
276
|
+
// Gate walks claims[] and emits file-missing for each absent one.
|
|
277
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-claims-gate-'))
|
|
278
|
+
fs.mkdirSync(
|
|
279
|
+
path.join(tmp, 'packages', 'fundamentals', 'hooks', 'src'),
|
|
280
|
+
{ recursive: true },
|
|
281
|
+
)
|
|
282
|
+
fs.writeFileSync(
|
|
283
|
+
path.join(tmp, 'packages', 'fundamentals', 'hooks', 'src', 'index.ts'),
|
|
284
|
+
'',
|
|
285
|
+
)
|
|
286
|
+
// Plant one claim file (CLAUDE.md) so the gate doesn't skip.
|
|
287
|
+
// Content is empty so its claims trigger pattern-miss (warning),
|
|
288
|
+
// not file-missing. All OTHER claim files remain absent and
|
|
289
|
+
// produce file-missing (error) findings.
|
|
290
|
+
fs.writeFileSync(path.join(tmp, 'CLAUDE.md'), '')
|
|
291
|
+
|
|
292
|
+
const result = await runDocClaimsGate({ cwd: tmp })
|
|
293
|
+
assertGateResultShape(result, 'doc-claims')
|
|
294
|
+
expect(result.meta.skipped).not.toBe(true)
|
|
295
|
+
const fileMissing = result.findings.filter((f) =>
|
|
296
|
+
f.code.endsWith('-file-missing'),
|
|
297
|
+
)
|
|
298
|
+
expect(fileMissing.length).toBeGreaterThan(0)
|
|
299
|
+
for (const f of fileMissing) {
|
|
300
|
+
expect(f.severity).toBe('error')
|
|
301
|
+
expect(f.location?.relPath).toBeTruthy()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('skips the gate when no claim files exist (non-Pyreon project)', async () => {
|
|
308
|
+
// A downstream consumer app has none of the Pyreon-monorepo claim
|
|
309
|
+
// sites (CLAUDE.md, hooks README, docs/docs/index.md, …). The gate
|
|
310
|
+
// recognises this and returns skipped:true rather than flooding
|
|
311
|
+
// findings with spurious file-missing errors for paths that don't
|
|
312
|
+
// apply to the user's project.
|
|
313
|
+
const tmp = fs.mkdtempSync(
|
|
314
|
+
path.join(os.tmpdir(), 'pyreon-claims-gate-skip-'),
|
|
315
|
+
)
|
|
316
|
+
// Tmp dir is empty — no Pyreon-shaped files anywhere.
|
|
317
|
+
|
|
318
|
+
const result = await runDocClaimsGate({ cwd: tmp })
|
|
319
|
+
assertGateResultShape(result, 'doc-claims')
|
|
320
|
+
expect(result.meta.skipped).toBe(true)
|
|
321
|
+
expect(result.meta.skipReason).toContain('no claim sites')
|
|
322
|
+
expect(result.findings).toHaveLength(0)
|
|
323
|
+
|
|
324
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('runAuditTypesGate', () => {
|
|
329
|
+
// The live invocation against the real repo is intentionally NOT
|
|
330
|
+
// a unit test — the subprocess walks every public interface across
|
|
331
|
+
// 6 high-risk packages via the TS compiler API; even with warm
|
|
332
|
+
// caches it runs ~1s locally but ~30s+ under CI parallel load,
|
|
333
|
+
// tripping the test timeout. The standalone script
|
|
334
|
+
// `scripts/audit-types.ts` has its own CI gate (`Audit Types`)
|
|
335
|
+
// which exercises the live path; this test layer locks the
|
|
336
|
+
// GateResult shape contract via the failure-path spec below
|
|
337
|
+
// (the assertGateResultShape() helper fires either way).
|
|
338
|
+
|
|
339
|
+
it('surfaces gate-failed finding when script is unreachable', async () => {
|
|
340
|
+
// Point cwd at a directory with no scripts/audit-types.ts. The
|
|
341
|
+
// subprocess fails; the gate emits a single gate-failed finding
|
|
342
|
+
// rather than throwing.
|
|
343
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-audit-gate-'))
|
|
344
|
+
const result = await runAuditTypesGate({ cwd: tmp })
|
|
345
|
+
assertGateResultShape(result, 'audit-types')
|
|
346
|
+
const failed = result.findings.find(
|
|
347
|
+
(f) => f.code === 'audit-types/gate-failed',
|
|
348
|
+
)
|
|
349
|
+
expect(failed).toBeDefined()
|
|
350
|
+
expect(failed?.severity).toBe('error')
|
|
351
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
describe('finding() helper', () => {
|
|
356
|
+
it('returns the passed Finding unchanged (identity helper for readability)', () => {
|
|
357
|
+
const f = finding({
|
|
358
|
+
category: 'correctness',
|
|
359
|
+
severity: 'error',
|
|
360
|
+
code: 'test/example',
|
|
361
|
+
gate: 'test',
|
|
362
|
+
message: 'example',
|
|
363
|
+
})
|
|
364
|
+
expect(f.category).toBe('correctness')
|
|
365
|
+
expect(f.severity).toBe('error')
|
|
366
|
+
expect(f.code).toBe('test/example')
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
describe('runDistributionGate — findPackages error paths', () => {
|
|
371
|
+
it('tolerates non-directory entries under packages/* (unreadable catDir)', async () => {
|
|
372
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-dirread-'))
|
|
373
|
+
fs.mkdirSync(path.join(tmp, 'packages'), { recursive: true })
|
|
374
|
+
// Put a regular file where readdirSync(packages/file) would fail.
|
|
375
|
+
fs.writeFileSync(path.join(tmp, 'packages', 'notadir'), '')
|
|
376
|
+
|
|
377
|
+
// Should NOT throw — the catch wraps readdirSync(catDir) so the
|
|
378
|
+
// walker just skips the non-directory entry.
|
|
379
|
+
const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
|
|
380
|
+
assertGateResultShape(result, 'distribution')
|
|
381
|
+
expect(result.meta.scanned).toBe(0)
|
|
382
|
+
|
|
383
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('skips packages with invalid JSON package.json', async () => {
|
|
387
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-badpj-'))
|
|
388
|
+
fs.mkdirSync(path.join(tmp, 'packages', 'fundamentals', 'bad'), {
|
|
389
|
+
recursive: true,
|
|
390
|
+
})
|
|
391
|
+
fs.writeFileSync(
|
|
392
|
+
path.join(tmp, 'packages', 'fundamentals', 'bad', 'package.json'),
|
|
393
|
+
'NOT JSON{{{',
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
|
|
397
|
+
assertGateResultShape(result, 'distribution')
|
|
398
|
+
expect(result.meta.scanned).toBe(0)
|
|
399
|
+
|
|
400
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
it('skips private packages', async () => {
|
|
404
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-dist-private-'))
|
|
405
|
+
fs.mkdirSync(path.join(tmp, 'packages', 'internals', 'priv'), {
|
|
406
|
+
recursive: true,
|
|
407
|
+
})
|
|
408
|
+
fs.writeFileSync(
|
|
409
|
+
path.join(tmp, 'packages', 'internals', 'priv', 'package.json'),
|
|
410
|
+
JSON.stringify({ name: '@pyreon/priv', private: true }),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const result = await runDistributionGate({ cwd: tmp, skipPackProbe: true })
|
|
414
|
+
assertGateResultShape(result, 'distribution')
|
|
415
|
+
// Private package is excluded from the scanned count + emits no
|
|
416
|
+
// findings (the gate only walks published packages).
|
|
417
|
+
expect(result.meta.scanned).toBe(0)
|
|
418
|
+
expect(result.findings).toEqual([])
|
|
419
|
+
|
|
420
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
describe('_detectMapsInPackOutput', () => {
|
|
425
|
+
const probe = { dir: '/repo/packages/core/reactivity' }
|
|
426
|
+
const cwd = '/repo'
|
|
427
|
+
|
|
428
|
+
it('returns null when the tarball has no .map files', () => {
|
|
429
|
+
const raw = JSON.stringify([
|
|
430
|
+
{
|
|
431
|
+
files: [
|
|
432
|
+
{ path: 'package.json' },
|
|
433
|
+
{ path: 'lib/index.js' },
|
|
434
|
+
{ path: 'lib/index.d.ts' },
|
|
435
|
+
],
|
|
436
|
+
},
|
|
437
|
+
])
|
|
438
|
+
const result = _detectMapsInPackOutput(
|
|
439
|
+
raw,
|
|
440
|
+
cwd,
|
|
441
|
+
probe,
|
|
442
|
+
'@pyreon/reactivity',
|
|
443
|
+
)
|
|
444
|
+
expect(result).toBeNull()
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('emits tarball-contains-map finding when .map files leak in', () => {
|
|
448
|
+
const raw = JSON.stringify([
|
|
449
|
+
{
|
|
450
|
+
files: [
|
|
451
|
+
{ path: 'lib/index.js' },
|
|
452
|
+
{ path: 'lib/index.js.map' },
|
|
453
|
+
{ path: 'lib/types/index.d.ts.map' },
|
|
454
|
+
],
|
|
455
|
+
},
|
|
456
|
+
])
|
|
457
|
+
const result = _detectMapsInPackOutput(
|
|
458
|
+
raw,
|
|
459
|
+
cwd,
|
|
460
|
+
probe,
|
|
461
|
+
'@pyreon/reactivity',
|
|
462
|
+
)
|
|
463
|
+
expect(result).not.toBeNull()
|
|
464
|
+
expect(result!.code).toBe('distribution/tarball-contains-map')
|
|
465
|
+
expect(result!.severity).toBe('error')
|
|
466
|
+
expect(result!.gate).toBe('distribution')
|
|
467
|
+
expect(result!.message).toContain('@pyreon/reactivity')
|
|
468
|
+
expect(result!.message).toContain('2 .map file(s)')
|
|
469
|
+
expect(result!.message).toContain('lib/index.js.map')
|
|
470
|
+
expect(result!.location?.relPath).toBe(
|
|
471
|
+
'packages/core/reactivity/package.json',
|
|
472
|
+
)
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
it('truncates the listed maps to the first 3 with an ellipsis', () => {
|
|
476
|
+
const raw = JSON.stringify([
|
|
477
|
+
{
|
|
478
|
+
files: [
|
|
479
|
+
{ path: 'a.js.map' },
|
|
480
|
+
{ path: 'b.js.map' },
|
|
481
|
+
{ path: 'c.js.map' },
|
|
482
|
+
{ path: 'd.js.map' },
|
|
483
|
+
],
|
|
484
|
+
},
|
|
485
|
+
])
|
|
486
|
+
const result = _detectMapsInPackOutput(
|
|
487
|
+
raw,
|
|
488
|
+
cwd,
|
|
489
|
+
probe,
|
|
490
|
+
'@pyreon/reactivity',
|
|
491
|
+
)!
|
|
492
|
+
expect(result.message).toContain('a.js.map, b.js.map, c.js.map')
|
|
493
|
+
expect(result.message).toContain('…')
|
|
494
|
+
expect(result.message).not.toContain('d.js.map')
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
it('handles npm pack output with no files entry gracefully', () => {
|
|
498
|
+
// Some npm versions / edge cases emit an empty array; the gate
|
|
499
|
+
// shouldn't crash.
|
|
500
|
+
const result = _detectMapsInPackOutput('[]', cwd, probe, '@pyreon/x')
|
|
501
|
+
expect(result).toBeNull()
|
|
502
|
+
})
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
describe('_parseAuditTypesOutput', () => {
|
|
506
|
+
it('maps HIGH/MEDIUM/LOW to error/warning/info and suppresses OK', () => {
|
|
507
|
+
const raw = JSON.stringify([
|
|
508
|
+
{
|
|
509
|
+
package: '@pyreon/zero',
|
|
510
|
+
packageDir: '/repo/packages/zero/zero',
|
|
511
|
+
findings: [
|
|
512
|
+
{
|
|
513
|
+
package: '@pyreon/zero',
|
|
514
|
+
interface: 'ZeroConfig',
|
|
515
|
+
field: 'mode',
|
|
516
|
+
declaredIn: 'packages/zero/zero/src/types.ts',
|
|
517
|
+
declaredLine: 42,
|
|
518
|
+
refCount: 0,
|
|
519
|
+
severity: 'HIGH',
|
|
520
|
+
},
|
|
521
|
+
{
|
|
522
|
+
package: '@pyreon/zero',
|
|
523
|
+
interface: 'ZeroConfig',
|
|
524
|
+
field: 'maybeUsed',
|
|
525
|
+
declaredIn: 'packages/zero/zero/src/types.ts',
|
|
526
|
+
declaredLine: 50,
|
|
527
|
+
refCount: 1,
|
|
528
|
+
severity: 'MEDIUM',
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
package: '@pyreon/zero',
|
|
532
|
+
interface: 'ZeroConfig',
|
|
533
|
+
field: 'rarelyUsed',
|
|
534
|
+
declaredIn: 'packages/zero/zero/src/types.ts',
|
|
535
|
+
declaredLine: 55,
|
|
536
|
+
refCount: 2,
|
|
537
|
+
severity: 'LOW',
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
package: '@pyreon/zero',
|
|
541
|
+
interface: 'ZeroConfig',
|
|
542
|
+
field: 'reallyUsed',
|
|
543
|
+
declaredIn: 'packages/zero/zero/src/types.ts',
|
|
544
|
+
declaredLine: 60,
|
|
545
|
+
refCount: 20,
|
|
546
|
+
severity: 'OK',
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
},
|
|
550
|
+
])
|
|
551
|
+
const { findings, scanned } = _parseAuditTypesOutput(raw, '/repo')
|
|
552
|
+
expect(scanned).toBe(1)
|
|
553
|
+
// OK is suppressed → 3 findings, not 4
|
|
554
|
+
expect(findings).toHaveLength(3)
|
|
555
|
+
expect(findings.map((f) => f.severity).sort()).toEqual([
|
|
556
|
+
'error',
|
|
557
|
+
'info',
|
|
558
|
+
'warning',
|
|
559
|
+
])
|
|
560
|
+
const high = findings.find((f) => f.severity === 'error')!
|
|
561
|
+
expect(high.code).toBe('audit-types/typed-but-unimplemented-high')
|
|
562
|
+
expect(high.gate).toBe('audit-types')
|
|
563
|
+
expect(high.category).toBe('architecture')
|
|
564
|
+
expect(high.message).toContain('@pyreon/zero')
|
|
565
|
+
expect(high.message).toContain('ZeroConfig.mode')
|
|
566
|
+
expect(high.location?.line).toBe(42)
|
|
567
|
+
expect(high.location?.relPath).toBe(
|
|
568
|
+
'packages/zero/zero/src/types.ts',
|
|
569
|
+
)
|
|
570
|
+
expect(high.location?.path).toBe(
|
|
571
|
+
'/repo/packages/zero/zero/src/types.ts',
|
|
572
|
+
)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('handles empty results array', () => {
|
|
576
|
+
const { findings, scanned } = _parseAuditTypesOutput('[]', '/repo')
|
|
577
|
+
expect(findings).toEqual([])
|
|
578
|
+
expect(scanned).toBe(0)
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('handles a package with no findings', () => {
|
|
582
|
+
const raw = JSON.stringify([
|
|
583
|
+
{ package: '@pyreon/router', packageDir: '/repo/p', findings: [] },
|
|
584
|
+
])
|
|
585
|
+
const { findings, scanned } = _parseAuditTypesOutput(raw, '/repo')
|
|
586
|
+
expect(findings).toEqual([])
|
|
587
|
+
expect(scanned).toBe(1)
|
|
588
|
+
})
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
describe('_parseBundleBudgetsOutput', () => {
|
|
592
|
+
it('emits over-budget, missing-budget, bundle-failed findings', () => {
|
|
593
|
+
const raw = JSON.stringify({
|
|
594
|
+
violations: [
|
|
595
|
+
{
|
|
596
|
+
name: '@pyreon/big',
|
|
597
|
+
current: 5120,
|
|
598
|
+
budget: 4096,
|
|
599
|
+
overBy: 1024,
|
|
600
|
+
overByPct: 25,
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
missing: [{ name: '@pyreon/new', current: 2048 }],
|
|
604
|
+
failures: [{ name: '@pyreon/broken', error: 'cannot resolve foo\nstack' }],
|
|
605
|
+
measured: [
|
|
606
|
+
{ name: '@pyreon/big', raw: 10240, gzip: 5120 },
|
|
607
|
+
{ name: '@pyreon/new', raw: 4096, gzip: 2048 },
|
|
608
|
+
{ name: '@pyreon/fine', raw: 1024, gzip: 512 },
|
|
609
|
+
],
|
|
610
|
+
})
|
|
611
|
+
const { findings, scanned } = _parseBundleBudgetsOutput(raw, '/repo')
|
|
612
|
+
|
|
613
|
+
// 3 measured + 1 failure → 4 scanned
|
|
614
|
+
expect(scanned).toBe(4)
|
|
615
|
+
expect(findings).toHaveLength(3)
|
|
616
|
+
|
|
617
|
+
const over = findings.find((f) => f.code === 'bundle-budgets/over-budget')!
|
|
618
|
+
expect(over.severity).toBe('error')
|
|
619
|
+
expect(over.message).toContain('@pyreon/big')
|
|
620
|
+
expect(over.message).toContain('+25.0%')
|
|
621
|
+
expect(over.location?.relPath).toBe('scripts/bundle-budgets.json')
|
|
622
|
+
expect(over.fix).toContain('--update')
|
|
623
|
+
|
|
624
|
+
const missing = findings.find(
|
|
625
|
+
(f) => f.code === 'bundle-budgets/missing-budget',
|
|
626
|
+
)!
|
|
627
|
+
expect(missing.severity).toBe('warning')
|
|
628
|
+
expect(missing.message).toContain('@pyreon/new')
|
|
629
|
+
|
|
630
|
+
const failed = findings.find(
|
|
631
|
+
(f) => f.code === 'bundle-budgets/bundle-failed',
|
|
632
|
+
)!
|
|
633
|
+
expect(failed.severity).toBe('error')
|
|
634
|
+
expect(failed.message).toContain('@pyreon/broken')
|
|
635
|
+
// Only the FIRST line of the error message is surfaced — the
|
|
636
|
+
// stack trace below the first \n is dropped.
|
|
637
|
+
expect(failed.message).toContain('cannot resolve foo')
|
|
638
|
+
expect(failed.message).not.toContain('stack')
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('returns empty findings on clean output', () => {
|
|
642
|
+
const raw = JSON.stringify({
|
|
643
|
+
violations: [],
|
|
644
|
+
missing: [],
|
|
645
|
+
failures: [],
|
|
646
|
+
measured: [{ name: '@pyreon/fine', raw: 1024, gzip: 512 }],
|
|
647
|
+
})
|
|
648
|
+
const { findings, scanned } = _parseBundleBudgetsOutput(raw, '/repo')
|
|
649
|
+
expect(findings).toEqual([])
|
|
650
|
+
expect(scanned).toBe(1)
|
|
651
|
+
})
|
|
652
|
+
})
|
|
653
|
+
|
|
654
|
+
describe('runBundleBudgetsGate', () => {
|
|
655
|
+
// The full real-repo bundle measurement takes 15-30s and is the
|
|
656
|
+
// slowest gate by a wide margin — it lives behind doctor's --full
|
|
657
|
+
// flag for the same reason. Skip the live measurement in unit
|
|
658
|
+
// tests; lock the shape via the gate-failed path (cheap subprocess
|
|
659
|
+
// failure) and let the standalone script's existing tests cover
|
|
660
|
+
// the live bundler behaviour.
|
|
661
|
+
|
|
662
|
+
it('surfaces gate-failed finding when script is unreachable', async () => {
|
|
663
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pyreon-bb-gate-'))
|
|
664
|
+
const result = await runBundleBudgetsGate({ cwd: tmp })
|
|
665
|
+
assertGateResultShape(result, 'bundle-budgets')
|
|
666
|
+
expect(result.category).toBe('performance')
|
|
667
|
+
const failed = result.findings.find(
|
|
668
|
+
(f) => f.code === 'bundle-budgets/gate-failed',
|
|
669
|
+
)
|
|
670
|
+
expect(failed).toBeDefined()
|
|
671
|
+
expect(failed?.severity).toBe('error')
|
|
672
|
+
fs.rmSync(tmp, { recursive: true, force: true })
|
|
673
|
+
})
|
|
674
|
+
})
|