@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,72 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { resolveGates } from '../doctor/orchestrator'
4
+
5
+ describe('resolveGates', () => {
6
+ it('default = 8 fast gates (no audit-types / bundle-budgets)', () => {
7
+ const gates = resolveGates({ cwd: '/' })
8
+ expect(gates).toEqual([
9
+ 'react-patterns',
10
+ 'pyreon-patterns',
11
+ 'lint',
12
+ 'distribution',
13
+ 'doc-claims',
14
+ 'islands-audit',
15
+ 'ssg-audit',
16
+ 'audit-tests',
17
+ ])
18
+ })
19
+
20
+ it('--full enables 10 gates total (adds the 2 slow ones)', () => {
21
+ const gates = resolveGates({ cwd: '/', full: true })
22
+ expect(gates).toContain('audit-types')
23
+ expect(gates).toContain('bundle-budgets')
24
+ expect(gates).toHaveLength(10)
25
+ })
26
+
27
+ it('--only restricts to the listed gates', () => {
28
+ const gates = resolveGates({
29
+ cwd: '/',
30
+ only: ['lint', 'doc-claims'],
31
+ })
32
+ expect(gates).toEqual(['lint', 'doc-claims'])
33
+ })
34
+
35
+ it('--only overrides --full (only wins precedence)', () => {
36
+ const gates = resolveGates({
37
+ cwd: '/',
38
+ full: true,
39
+ only: ['lint'],
40
+ })
41
+ expect(gates).toEqual(['lint'])
42
+ })
43
+
44
+ it('--skip removes from the default set', () => {
45
+ const gates = resolveGates({
46
+ cwd: '/',
47
+ skip: ['lint', 'pyreon-patterns'],
48
+ })
49
+ expect(gates).not.toContain('lint')
50
+ expect(gates).not.toContain('pyreon-patterns')
51
+ expect(gates).toContain('react-patterns')
52
+ })
53
+
54
+ it('--skip applies after --only (intersection)', () => {
55
+ const gates = resolveGates({
56
+ cwd: '/',
57
+ only: ['lint', 'doc-claims', 'distribution'],
58
+ skip: ['doc-claims'],
59
+ })
60
+ expect(gates).toEqual(['lint', 'distribution'])
61
+ })
62
+
63
+ it('empty --only falls through to default + --skip', () => {
64
+ const gates = resolveGates({
65
+ cwd: '/',
66
+ only: [],
67
+ skip: ['lint'],
68
+ })
69
+ expect(gates).not.toContain('lint')
70
+ expect(gates).toContain('react-patterns')
71
+ })
72
+ })
@@ -0,0 +1,213 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildReport } from '../doctor/report'
4
+ import { renderGha, renderJson, renderText } from '../doctor/render'
5
+ import type { Finding, GateResult } from '../doctor/types'
6
+
7
+ // Strip ANSI for assertions. We test in a controlled NO_COLOR env via
8
+ // `FORCE_COLOR=0` at runtime (set in vitest.config or per-run), but
9
+ // the test environment may still have a TTY — so strip defensively.
10
+ const stripAnsi = (s: string): string =>
11
+ // eslint-disable-next-line no-control-regex
12
+ s.replace(/\[[0-9;]*m/g, '').replace(/\][^]*\\/g, '')
13
+
14
+ const f = (
15
+ severity: Finding['severity'],
16
+ category: Finding['category'],
17
+ code: string,
18
+ extra: Partial<Finding> = {},
19
+ ): Finding => ({
20
+ severity,
21
+ category,
22
+ code,
23
+ gate: 'test',
24
+ message: `Message for ${code}`,
25
+ ...extra,
26
+ })
27
+
28
+ const g = (
29
+ gate: string,
30
+ category: GateResult['category'],
31
+ findings: Finding[] = [],
32
+ meta: Partial<GateResult['meta']> = {},
33
+ ): GateResult => ({
34
+ gate,
35
+ category,
36
+ findings,
37
+ meta: { elapsedMs: 1, ...meta },
38
+ })
39
+
40
+ describe('renderText', () => {
41
+ it('renders the banner with score + grade', () => {
42
+ const report = buildReport([g('lint', 'correctness')])
43
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
44
+ expect(out).toContain('pyreon doctor')
45
+ expect(out).toContain('Score:')
46
+ expect(out).toContain('100')
47
+ expect(out).toContain('Grade:')
48
+ expect(out).toContain('A')
49
+ })
50
+
51
+ it('renders per-category bar chart', () => {
52
+ const report = buildReport([
53
+ g('lint', 'correctness', [f('error', 'correctness', 'a/x')]),
54
+ g('distribution', 'architecture'),
55
+ ])
56
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
57
+ expect(out).toContain('correctness')
58
+ expect(out).toContain('architecture')
59
+ expect(out).toContain('90') // 100 - 10 for one error
60
+ expect(out).toContain('1E') // 1 error breakdown
61
+ })
62
+
63
+ it('shows "skipped" for uncovered categories', () => {
64
+ const report = buildReport([g('lint', 'correctness')])
65
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
66
+ expect(out).toContain('performance')
67
+ expect(out).toContain('skipped')
68
+ })
69
+
70
+ it('lists top-N findings with severity icon + code + message', () => {
71
+ const report = buildReport([
72
+ g('lint', 'correctness', [
73
+ f('error', 'correctness', 'lint/no-x'),
74
+ f('warning', 'correctness', 'lint/no-y'),
75
+ ]),
76
+ ])
77
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
78
+ expect(out).toContain('lint/no-x')
79
+ expect(out).toContain('Message for lint/no-x')
80
+ expect(out).toContain('lint/no-y')
81
+ })
82
+
83
+ it('shows clean green-light when no findings', () => {
84
+ const report = buildReport([g('lint', 'correctness')])
85
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
86
+ expect(out).toContain('No findings')
87
+ })
88
+
89
+ it('lists skipped gates in the footer', () => {
90
+ const report = buildReport([
91
+ g('lint', 'correctness'),
92
+ g('bundle-budgets', 'performance', [], {
93
+ skipped: true,
94
+ skipReason: 'enable with --full',
95
+ }),
96
+ ])
97
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
98
+ expect(out).toContain('Skipped:')
99
+ expect(out).toContain('bundle-budgets')
100
+ expect(out).toContain('enable with --full')
101
+ })
102
+
103
+ it('shows fix hint when finding has one', () => {
104
+ const report = buildReport([
105
+ g('lint', 'correctness', [
106
+ f('warning', 'correctness', 'lint/x', { fix: 'Use `class` instead.' }),
107
+ ]),
108
+ ])
109
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
110
+ expect(out).toContain('fix:')
111
+ expect(out).toContain('Use `class` instead.')
112
+ })
113
+
114
+ it('shows location with line:col when present', () => {
115
+ const report = buildReport([
116
+ g('lint', 'correctness', [
117
+ f('warning', 'correctness', 'lint/x', {
118
+ location: {
119
+ path: '/abs/file.ts',
120
+ relPath: 'file.ts',
121
+ line: 42,
122
+ column: 7,
123
+ },
124
+ }),
125
+ ]),
126
+ ])
127
+ const out = stripAnsi(renderText(report, { cwd: '/' }))
128
+ expect(out).toContain('file.ts:42:7')
129
+ })
130
+
131
+ it('truncates to topN with "and N more" hint', () => {
132
+ const findings = Array(15).fill(null).map((_, i) =>
133
+ f('info', 'correctness', `lint/x${i}`),
134
+ )
135
+ const report = buildReport([g('lint', 'correctness', findings)])
136
+ const out = stripAnsi(renderText(report, { cwd: '/', topN: 3 }))
137
+ expect(out).toContain('and 12 more')
138
+ })
139
+ })
140
+
141
+ describe('renderJson', () => {
142
+ it('emits a parseable JSON object', () => {
143
+ const report = buildReport([g('lint', 'correctness')])
144
+ const json = renderJson(report)
145
+ const parsed = JSON.parse(json)
146
+ expect(parsed.score).toBe(100)
147
+ expect(parsed.grade).toBe('A')
148
+ expect(Array.isArray(parsed.findings)).toBe(true)
149
+ expect(Array.isArray(parsed.categories)).toBe(true)
150
+ expect(Array.isArray(parsed.gates)).toBe(true)
151
+ })
152
+ })
153
+
154
+ describe('renderGha', () => {
155
+ it('emits a notice header with score + totals', () => {
156
+ const report = buildReport([
157
+ g('lint', 'correctness', [
158
+ f('error', 'correctness', 'lint/err'),
159
+ f('warning', 'correctness', 'lint/warn'),
160
+ ]),
161
+ ])
162
+ const out = renderGha(report)
163
+ expect(out).toMatch(/^::notice::pyreon doctor score:/)
164
+ expect(out).toContain('1 errors, 1 warnings')
165
+ })
166
+
167
+ it('emits per-finding annotation with file + line + col', () => {
168
+ const report = buildReport([
169
+ g('lint', 'correctness', [
170
+ f('error', 'correctness', 'lint/err', {
171
+ location: {
172
+ path: '/abs/file.ts',
173
+ relPath: 'src/file.ts',
174
+ line: 10,
175
+ column: 5,
176
+ },
177
+ }),
178
+ ]),
179
+ ])
180
+ const out = renderGha(report)
181
+ expect(out).toContain('::error ')
182
+ expect(out).toContain('file=src/file.ts')
183
+ expect(out).toContain('line=10')
184
+ expect(out).toContain('col=5')
185
+ })
186
+
187
+ it('maps severity correctly (error → error, warning → warning, info → notice)', () => {
188
+ const report = buildReport([
189
+ g('lint', 'correctness', [
190
+ f('error', 'correctness', 'a'),
191
+ f('warning', 'correctness', 'b'),
192
+ f('info', 'correctness', 'c'),
193
+ ]),
194
+ ])
195
+ const out = renderGha(report)
196
+ expect(out).toContain('::error ')
197
+ expect(out).toContain('::warning ')
198
+ // notice appears for both the header AND the info finding
199
+ expect((out.match(/::notice/g) ?? []).length).toBeGreaterThanOrEqual(2)
200
+ })
201
+
202
+ it('URL-encodes special chars in messages', () => {
203
+ const report = buildReport([
204
+ g('lint', 'correctness', [
205
+ f('error', 'correctness', 'a', {
206
+ message: 'line1\nline2',
207
+ }),
208
+ ]),
209
+ ])
210
+ const out = renderGha(report)
211
+ expect(out).toContain('%0A') // newline encoded
212
+ })
213
+ })
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { buildReport } from '../doctor/report'
4
+ import type { Finding, GateResult } from '../doctor/types'
5
+
6
+ const f = (
7
+ severity: Finding['severity'],
8
+ category: Finding['category'],
9
+ code: string,
10
+ ): Finding => ({
11
+ severity,
12
+ category,
13
+ code,
14
+ gate: 'test',
15
+ message: 'm',
16
+ })
17
+
18
+ const g = (
19
+ gate: string,
20
+ category: GateResult['category'],
21
+ findings: Finding[] = [],
22
+ elapsedMs = 10,
23
+ ): GateResult => ({
24
+ gate,
25
+ category,
26
+ findings,
27
+ meta: { elapsedMs },
28
+ })
29
+
30
+ describe('buildReport', () => {
31
+ it('produces a clean report when no gates run', () => {
32
+ const report = buildReport([])
33
+ expect(report.score).toBe(100)
34
+ expect(report.grade).toBe('A')
35
+ expect(report.findings).toEqual([])
36
+ expect(report.totals).toEqual({ errors: 0, warnings: 0, infos: 0 })
37
+ expect(report.elapsedMs).toBe(0)
38
+ expect(report.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/)
39
+ })
40
+
41
+ it('sorts findings: errors → warnings → infos, then by category, then by code', () => {
42
+ const gates = [
43
+ g('a', 'correctness', [
44
+ f('info', 'documentation', 'a/info'),
45
+ f('warning', 'performance', 'a/warn'),
46
+ ]),
47
+ g('b', 'architecture', [
48
+ f('error', 'correctness', 'b/err1'),
49
+ f('error', 'architecture', 'b/err2'),
50
+ ]),
51
+ ]
52
+ const report = buildReport(gates)
53
+ // Errors first, sorted by category (architecture < correctness alphabetically)
54
+ expect(report.findings.map((x) => x.code)).toEqual([
55
+ 'b/err2',
56
+ 'b/err1',
57
+ 'a/warn',
58
+ 'a/info',
59
+ ])
60
+ })
61
+
62
+ it('counts totals across gates', () => {
63
+ const gates = [
64
+ g('a', 'correctness', [
65
+ f('error', 'correctness', 'a'),
66
+ f('error', 'correctness', 'b'),
67
+ f('warning', 'correctness', 'c'),
68
+ f('info', 'correctness', 'd'),
69
+ ]),
70
+ ]
71
+ const report = buildReport(gates)
72
+ expect(report.totals.errors).toBe(2)
73
+ expect(report.totals.warnings).toBe(1)
74
+ expect(report.totals.infos).toBe(1)
75
+ })
76
+
77
+ it('sums elapsedMs across gates (proxy for total CPU work)', () => {
78
+ const gates = [
79
+ g('a', 'correctness', [], 100),
80
+ g('b', 'architecture', [], 200),
81
+ g('c', 'testing', [], 50),
82
+ ]
83
+ expect(buildReport(gates).elapsedMs).toBe(350)
84
+ })
85
+
86
+ it('preserves the gates array in the report', () => {
87
+ const gates = [g('a', 'correctness'), g('b', 'architecture')]
88
+ expect(buildReport(gates).gates).toEqual(gates)
89
+ })
90
+
91
+ it('computes score from findings (1 error = -10)', () => {
92
+ const gates = [
93
+ g('a', 'correctness', [f('error', 'correctness', 'a/err')]),
94
+ ]
95
+ const report = buildReport(gates)
96
+ expect(report.score).toBe(90)
97
+ expect(report.grade).toBe('A')
98
+ })
99
+ })
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ computeScore,
5
+ gradeFor,
6
+ scoreCategory,
7
+ } from '../doctor/score'
8
+ import type { Finding, GateResult } from '../doctor/types'
9
+
10
+ const makeFinding = (
11
+ category: Finding['category'],
12
+ severity: Finding['severity'],
13
+ code = 'test/x',
14
+ ): Finding => ({
15
+ category,
16
+ severity,
17
+ code,
18
+ gate: 'test',
19
+ message: 'test',
20
+ })
21
+
22
+ const makeGate = (
23
+ gate: string,
24
+ category: GateResult['category'],
25
+ findings: Finding[] = [],
26
+ skipped = false,
27
+ ): GateResult => ({
28
+ gate,
29
+ category,
30
+ findings,
31
+ meta: {
32
+ elapsedMs: 1,
33
+ ...(skipped && { skipped: true, skipReason: 'test' }),
34
+ },
35
+ })
36
+
37
+ describe('gradeFor', () => {
38
+ it('returns A for 90+', () => {
39
+ expect(gradeFor(100)).toBe('A')
40
+ expect(gradeFor(95)).toBe('A')
41
+ expect(gradeFor(90)).toBe('A')
42
+ })
43
+ it('returns B for 80-89', () => {
44
+ expect(gradeFor(89)).toBe('B')
45
+ expect(gradeFor(80)).toBe('B')
46
+ })
47
+ it('returns C for 70-79', () => {
48
+ expect(gradeFor(79)).toBe('C')
49
+ expect(gradeFor(70)).toBe('C')
50
+ })
51
+ it('returns D for 60-69', () => {
52
+ expect(gradeFor(69)).toBe('D')
53
+ expect(gradeFor(60)).toBe('D')
54
+ })
55
+ it('returns F for <60', () => {
56
+ expect(gradeFor(59)).toBe('F')
57
+ expect(gradeFor(0)).toBe('F')
58
+ })
59
+ })
60
+
61
+ describe('scoreCategory', () => {
62
+ it('returns 100 with grade A for empty findings', () => {
63
+ const result = scoreCategory('correctness', [], true)
64
+ expect(result.score).toBe(100)
65
+ expect(result.grade).toBe('A')
66
+ expect(result.errors).toBe(0)
67
+ expect(result.warnings).toBe(0)
68
+ expect(result.infos).toBe(0)
69
+ })
70
+
71
+ it('weights: 1 error = -10, 1 warning = -3, 1 info = -1', () => {
72
+ const findings = [
73
+ makeFinding('correctness', 'error'),
74
+ makeFinding('correctness', 'warning'),
75
+ makeFinding('correctness', 'info'),
76
+ ]
77
+ const result = scoreCategory('correctness', findings, true)
78
+ expect(result.score).toBe(86) // 100 - 10 - 3 - 1
79
+ expect(result.errors).toBe(1)
80
+ expect(result.warnings).toBe(1)
81
+ expect(result.infos).toBe(1)
82
+ })
83
+
84
+ it('saturates at 0', () => {
85
+ const findings = Array(20).fill(null).map(() =>
86
+ makeFinding('correctness', 'error'),
87
+ )
88
+ const result = scoreCategory('correctness', findings, true)
89
+ expect(result.score).toBe(0)
90
+ expect(result.grade).toBe('F')
91
+ })
92
+
93
+ it('only counts findings in the named category', () => {
94
+ const findings = [
95
+ makeFinding('correctness', 'error'),
96
+ makeFinding('performance', 'error'),
97
+ ]
98
+ const result = scoreCategory('correctness', findings, true)
99
+ expect(result.score).toBe(90) // -10 for the one correctness error
100
+ })
101
+
102
+ it('passes through `included` flag', () => {
103
+ expect(scoreCategory('testing', [], true).included).toBe(true)
104
+ expect(scoreCategory('testing', [], false).included).toBe(false)
105
+ })
106
+ })
107
+
108
+ describe('computeScore', () => {
109
+ it('returns 100/A when no gates run', () => {
110
+ const { score, grade } = computeScore([], [])
111
+ expect(score).toBe(100)
112
+ expect(grade).toBe('A')
113
+ })
114
+
115
+ it('excludes uncovered categories from the mean', () => {
116
+ // Only ONE gate (correctness) covers anything — should NOT
117
+ // average in 4× perfect 100s from uncovered categories.
118
+ const gates = [makeGate('lint', 'correctness')]
119
+ const findings = [makeFinding('correctness', 'error')]
120
+ const { score, categories } = computeScore(findings, gates)
121
+ expect(score).toBe(90) // only correctness counted, 100-10
122
+ expect(categories.find((c) => c.category === 'correctness')!.included).toBe(true)
123
+ expect(categories.find((c) => c.category === 'performance')!.included).toBe(false)
124
+ })
125
+
126
+ it('averages included categories', () => {
127
+ // 2 gates: lint (correctness, 1 error → 90) + distribution
128
+ // (architecture, 0 findings → 100). Mean = 95.
129
+ const gates = [
130
+ makeGate('lint', 'correctness'),
131
+ makeGate('distribution', 'architecture'),
132
+ ]
133
+ const findings = [makeFinding('correctness', 'error')]
134
+ const { score } = computeScore(findings, gates)
135
+ expect(score).toBe(95)
136
+ })
137
+
138
+ it('skipped gates do NOT include their category', () => {
139
+ const gates = [
140
+ makeGate('lint', 'correctness', [], true), // skipped
141
+ makeGate('distribution', 'architecture'),
142
+ ]
143
+ const { categories } = computeScore([], gates)
144
+ expect(categories.find((c) => c.category === 'correctness')!.included).toBe(false)
145
+ expect(categories.find((c) => c.category === 'architecture')!.included).toBe(true)
146
+ })
147
+
148
+ it('finding category alone is enough to include the category', () => {
149
+ // A finding emitted under 'performance' (perhaps from a lint
150
+ // rule whose default gate-category is 'correctness') should
151
+ // pull performance into the included set.
152
+ const gates = [makeGate('lint', 'correctness')]
153
+ const findings = [makeFinding('performance', 'warning')]
154
+ const { categories } = computeScore(findings, gates)
155
+ expect(categories.find((c) => c.category === 'performance')!.included).toBe(true)
156
+ expect(categories.find((c) => c.category === 'performance')!.score).toBe(97)
157
+ })
158
+ })