@pyreon/lint 0.13.1 → 0.15.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 +9 -7
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +540 -60
- package/lib/index.js +540 -60
- package/package.json +6 -2
- package/src/manifest.ts +152 -0
- package/src/rules/architecture/dev-guard-warnings.ts +56 -6
- package/src/rules/architecture/no-process-dev-gate.ts +141 -62
- package/src/rules/index.ts +11 -2
- package/src/rules/jsx/no-props-destructure.ts +57 -7
- package/src/rules/lifecycle/no-imperative-effect-on-create.ts +278 -0
- package/src/rules/reactivity/no-async-effect.ts +84 -0
- package/src/rules/reactivity/no-signal-call-write.ts +60 -0
- package/src/tests/ast-utils.test.ts +239 -0
- package/src/tests/imports.test.ts +182 -0
- package/src/tests/manifest-snapshot.test.ts +30 -0
- package/src/tests/reporter.test.ts +155 -0
- package/src/tests/runner.test.ts +543 -8
- package/lib/cli.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getJSXAttribute,
|
|
4
|
+
getJSXTagName,
|
|
5
|
+
getSpan,
|
|
6
|
+
hasJSXAttribute,
|
|
7
|
+
hasJSXChild,
|
|
8
|
+
isArrayMapCall,
|
|
9
|
+
isBrowserGlobal,
|
|
10
|
+
isCallTo,
|
|
11
|
+
isCallToAny,
|
|
12
|
+
isDestructuring,
|
|
13
|
+
isFunction,
|
|
14
|
+
isInsideDevGuard,
|
|
15
|
+
isInsideFunction,
|
|
16
|
+
isInsideJSX,
|
|
17
|
+
isInsideOnMount,
|
|
18
|
+
isInsideTypeofGuard,
|
|
19
|
+
isJSXElement,
|
|
20
|
+
isLogicalAndWithJSX,
|
|
21
|
+
isMemberCallTo,
|
|
22
|
+
isPeekCall,
|
|
23
|
+
isSetCall,
|
|
24
|
+
isTernaryWithJSX,
|
|
25
|
+
} from '../utils/ast'
|
|
26
|
+
|
|
27
|
+
// Coverage gap closed in PR #323. The ast utils are pure node-shape
|
|
28
|
+
// predicates used by ~40 lint rules. Pinning their behavior here so
|
|
29
|
+
// a future refactor that touches the AST builder doesn't silently
|
|
30
|
+
// invalidate the rule layer.
|
|
31
|
+
|
|
32
|
+
const ident = (name: string) => ({ type: 'Identifier', name })
|
|
33
|
+
const callExpr = (callee: any, args: any[] = []) => ({
|
|
34
|
+
type: 'CallExpression',
|
|
35
|
+
callee,
|
|
36
|
+
arguments: args,
|
|
37
|
+
})
|
|
38
|
+
const member = (object: any, property: any) => ({ type: 'MemberExpression', object, property })
|
|
39
|
+
const jsxIdent = (name: string) => ({ type: 'JSXIdentifier', name })
|
|
40
|
+
const jsxElement = (tag: string, attrs: any[] = [], children: any[] = []) => ({
|
|
41
|
+
type: 'JSXElement',
|
|
42
|
+
openingElement: { attributes: attrs, name: jsxIdent(tag) },
|
|
43
|
+
children,
|
|
44
|
+
})
|
|
45
|
+
const jsxAttr = (name: string, value?: any) => ({
|
|
46
|
+
type: 'JSXAttribute',
|
|
47
|
+
name: jsxIdent(name),
|
|
48
|
+
value,
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('ast utils — call expression predicates', () => {
|
|
52
|
+
it('isCallTo matches direct identifier calls', () => {
|
|
53
|
+
expect(isCallTo(callExpr(ident('signal'), []), 'signal')).toBe(true)
|
|
54
|
+
expect(isCallTo(callExpr(ident('signal'), []), 'computed')).toBe(false)
|
|
55
|
+
expect(isCallTo(callExpr(member(ident('a'), ident('b'))), 'a')).toBe(false)
|
|
56
|
+
expect(isCallTo({ type: 'BinaryExpression' }, 'signal')).toBe(false)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('isCallToAny matches any of a name set', () => {
|
|
60
|
+
const set = new Set(['signal', 'computed', 'effect'])
|
|
61
|
+
expect(isCallToAny(callExpr(ident('signal')), set)).toBe(true)
|
|
62
|
+
expect(isCallToAny(callExpr(ident('effect')), set)).toBe(true)
|
|
63
|
+
expect(isCallToAny(callExpr(ident('useState')), set)).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('isMemberCallTo matches `obj.method()` form', () => {
|
|
67
|
+
const node = callExpr(member(ident('console'), ident('log')))
|
|
68
|
+
expect(isMemberCallTo(node, 'console', 'log')).toBe(true)
|
|
69
|
+
expect(isMemberCallTo(node, 'console', 'warn')).toBe(false)
|
|
70
|
+
expect(isMemberCallTo(node, 'window', 'log')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('isArrayMapCall matches any `.map(...)` call', () => {
|
|
74
|
+
expect(isArrayMapCall(callExpr(member(ident('items'), ident('map'))))).toBe(true)
|
|
75
|
+
expect(isArrayMapCall(callExpr(member(ident('items'), ident('filter'))))).toBe(false)
|
|
76
|
+
expect(isArrayMapCall(callExpr(ident('map')))).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('isPeekCall and isSetCall match `obj.peek()` and `obj.set()`', () => {
|
|
80
|
+
expect(isPeekCall(callExpr(member(ident('count'), ident('peek'))))).toBe(true)
|
|
81
|
+
expect(isPeekCall(callExpr(member(ident('count'), ident('value'))))).toBe(false)
|
|
82
|
+
expect(isSetCall(callExpr(member(ident('count'), ident('set'))))).toBe(true)
|
|
83
|
+
expect(isSetCall(callExpr(member(ident('count'), ident('peek'))))).toBe(false)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('ast utils — JSX predicates', () => {
|
|
88
|
+
it('isJSXElement recognises JSXElement and JSXFragment', () => {
|
|
89
|
+
expect(isJSXElement(jsxElement('div'))).toBe(true)
|
|
90
|
+
expect(isJSXElement({ type: 'JSXFragment' })).toBe(true)
|
|
91
|
+
expect(isJSXElement({ type: 'CallExpression' })).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('getJSXTagName returns plain identifier name', () => {
|
|
95
|
+
expect(getJSXTagName(jsxElement('div'))).toBe('div')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('getJSXTagName returns dotted form for JSXMemberExpression', () => {
|
|
99
|
+
const node = {
|
|
100
|
+
type: 'JSXElement',
|
|
101
|
+
openingElement: {
|
|
102
|
+
name: { type: 'JSXMemberExpression', object: { name: 'My' }, property: { name: 'Comp' } },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
expect(getJSXTagName(node)).toBe('My.Comp')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('getJSXTagName returns null for fragments and missing opening', () => {
|
|
109
|
+
expect(getJSXTagName({ type: 'JSXFragment' })).toBeNull()
|
|
110
|
+
expect(getJSXTagName({ type: 'JSXElement' })).toBeNull()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('getJSXAttribute / hasJSXAttribute look up by name', () => {
|
|
114
|
+
const opening = { attributes: [jsxAttr('class', 'a'), jsxAttr('id', 'x')] }
|
|
115
|
+
expect(getJSXAttribute(opening, 'class')).toBeTruthy()
|
|
116
|
+
expect(getJSXAttribute(opening, 'missing')).toBeNull()
|
|
117
|
+
expect(hasJSXAttribute(opening, 'id')).toBe(true)
|
|
118
|
+
expect(hasJSXAttribute(opening, 'no')).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('hasJSXAttribute handles missing attributes array', () => {
|
|
122
|
+
expect(hasJSXAttribute({}, 'x')).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('hasJSXChild detects nested JSX elements', () => {
|
|
126
|
+
const inner = jsxElement('span')
|
|
127
|
+
const outer = jsxElement('div', [], [inner])
|
|
128
|
+
expect(hasJSXChild(outer)).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('hasJSXChild returns false for fragments', () => {
|
|
132
|
+
expect(hasJSXChild({ type: 'JSXFragment' })).toBe(false)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('hasJSXChild returns false when only text children', () => {
|
|
136
|
+
expect(hasJSXChild(jsxElement('div', [], [{ type: 'JSXText' }]))).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('isTernaryWithJSX detects JSX in either branch', () => {
|
|
140
|
+
const ternary = (c: any, a: any) => ({ type: 'ConditionalExpression', consequent: c, alternate: a })
|
|
141
|
+
expect(isTernaryWithJSX(ternary(jsxElement('a'), { type: 'NullLiteral' }))).toBe(true)
|
|
142
|
+
expect(isTernaryWithJSX(ternary({ type: 'NullLiteral' }, jsxElement('b')))).toBe(true)
|
|
143
|
+
expect(isTernaryWithJSX(ternary({ type: 'NullLiteral' }, { type: 'NullLiteral' }))).toBe(false)
|
|
144
|
+
expect(isTernaryWithJSX({ type: 'BinaryExpression' })).toBe(false)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('isTernaryWithJSX unwraps ParenthesizedExpression', () => {
|
|
148
|
+
const ternary = {
|
|
149
|
+
type: 'ConditionalExpression',
|
|
150
|
+
consequent: { type: 'ParenthesizedExpression', expression: jsxElement('a') },
|
|
151
|
+
alternate: { type: 'NullLiteral' },
|
|
152
|
+
}
|
|
153
|
+
expect(isTernaryWithJSX(ternary)).toBe(true)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('isLogicalAndWithJSX detects `cond && <JSX />`', () => {
|
|
157
|
+
expect(
|
|
158
|
+
isLogicalAndWithJSX({ type: 'LogicalExpression', operator: '&&', right: jsxElement('div') }),
|
|
159
|
+
).toBe(true)
|
|
160
|
+
expect(
|
|
161
|
+
isLogicalAndWithJSX({ type: 'LogicalExpression', operator: '||', right: jsxElement('div') }),
|
|
162
|
+
).toBe(false)
|
|
163
|
+
expect(
|
|
164
|
+
isLogicalAndWithJSX({ type: 'LogicalExpression', operator: '&&', right: { type: 'NullLiteral' } }),
|
|
165
|
+
).toBe(false)
|
|
166
|
+
expect(isLogicalAndWithJSX({ type: 'BinaryExpression' })).toBe(false)
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('ast utils — ancestor predicates', () => {
|
|
171
|
+
it('isInsideFunction detects function ancestors', () => {
|
|
172
|
+
expect(isInsideFunction([{ type: 'IfStatement' }, { type: 'FunctionDeclaration' }])).toBe(true)
|
|
173
|
+
expect(isInsideFunction([{ type: 'ArrowFunctionExpression' }])).toBe(true)
|
|
174
|
+
expect(isInsideFunction([{ type: 'FunctionExpression' }])).toBe(true)
|
|
175
|
+
expect(isInsideFunction([{ type: 'IfStatement' }])).toBe(false)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('isInsideJSX detects JSX ancestors', () => {
|
|
179
|
+
expect(isInsideJSX([{ type: 'JSXElement' }])).toBe(true)
|
|
180
|
+
expect(isInsideJSX([{ type: 'JSXFragment' }])).toBe(true)
|
|
181
|
+
expect(isInsideJSX([{ type: 'IfStatement' }])).toBe(false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('isInsideDevGuard detects `if (__DEV__)` wrapping', () => {
|
|
185
|
+
expect(
|
|
186
|
+
isInsideDevGuard([{ type: 'IfStatement', test: { type: 'Identifier', name: '__DEV__' } }]),
|
|
187
|
+
).toBe(true)
|
|
188
|
+
expect(
|
|
189
|
+
isInsideDevGuard([{ type: 'IfStatement', test: { type: 'Identifier', name: 'something' } }]),
|
|
190
|
+
).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('isInsideOnMount detects `onMount(() => …)` wrapping', () => {
|
|
194
|
+
expect(
|
|
195
|
+
isInsideOnMount([{ type: 'CallExpression', callee: { type: 'Identifier', name: 'onMount' } }]),
|
|
196
|
+
).toBe(true)
|
|
197
|
+
expect(
|
|
198
|
+
isInsideOnMount([{ type: 'CallExpression', callee: { type: 'Identifier', name: 'effect' } }]),
|
|
199
|
+
).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('isInsideTypeofGuard detects `typeof X !== "undefined"` wrapping', () => {
|
|
203
|
+
const guard = {
|
|
204
|
+
type: 'IfStatement',
|
|
205
|
+
test: {
|
|
206
|
+
type: 'BinaryExpression',
|
|
207
|
+
left: { type: 'UnaryExpression', operator: 'typeof' },
|
|
208
|
+
},
|
|
209
|
+
}
|
|
210
|
+
expect(isInsideTypeofGuard([guard])).toBe(true)
|
|
211
|
+
expect(isInsideTypeofGuard([{ type: 'IfStatement', test: { type: 'Identifier' } }])).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
describe('ast utils — misc', () => {
|
|
216
|
+
it('isFunction recognises all function node types', () => {
|
|
217
|
+
expect(isFunction({ type: 'FunctionDeclaration' })).toBe(true)
|
|
218
|
+
expect(isFunction({ type: 'FunctionExpression' })).toBe(true)
|
|
219
|
+
expect(isFunction({ type: 'ArrowFunctionExpression' })).toBe(true)
|
|
220
|
+
expect(isFunction({ type: 'CallExpression' })).toBe(false)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('isDestructuring recognises object and array patterns', () => {
|
|
224
|
+
expect(isDestructuring({ type: 'ObjectPattern' })).toBe(true)
|
|
225
|
+
expect(isDestructuring({ type: 'ArrayPattern' })).toBe(true)
|
|
226
|
+
expect(isDestructuring({ type: 'Identifier' })).toBe(false)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('isBrowserGlobal recognises window/document/etc.', () => {
|
|
230
|
+
expect(isBrowserGlobal(ident('window'))).toBe(true)
|
|
231
|
+
expect(isBrowserGlobal(ident('document'))).toBe(true)
|
|
232
|
+
expect(isBrowserGlobal(ident('fooBar'))).toBe(false)
|
|
233
|
+
expect(isBrowserGlobal({ type: 'CallExpression' })).toBe(false)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('getSpan returns { start, end } from byte offsets', () => {
|
|
237
|
+
expect(getSpan({ start: 12, end: 30 })).toEqual({ start: 12, end: 30 })
|
|
238
|
+
})
|
|
239
|
+
})
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
extractImportInfo,
|
|
4
|
+
getLocalName,
|
|
5
|
+
importsName,
|
|
6
|
+
isPyreonImport,
|
|
7
|
+
isPyreonPackage,
|
|
8
|
+
} from '../utils/imports'
|
|
9
|
+
|
|
10
|
+
// Coverage gap closed in PR #323. The imports utils are pure AST
|
|
11
|
+
// readers — `extractImportInfo` walks an oxc-style ImportDeclaration
|
|
12
|
+
// node, the rest are convenience predicates over the resulting
|
|
13
|
+
// ImportInfo shape. Used by ~30 lint rules; unit-tested here so a
|
|
14
|
+
// future refactor doesn't silently break import-aware rules.
|
|
15
|
+
|
|
16
|
+
const importDecl = (
|
|
17
|
+
source: string,
|
|
18
|
+
specifiers: Array<{ kind: 'default' | 'ns' | 'named'; imported?: string; local: string }>,
|
|
19
|
+
) => ({
|
|
20
|
+
type: 'ImportDeclaration',
|
|
21
|
+
source: { value: source },
|
|
22
|
+
specifiers: specifiers.map((s) => {
|
|
23
|
+
if (s.kind === 'default') return { type: 'ImportDefaultSpecifier', local: { name: s.local } }
|
|
24
|
+
if (s.kind === 'ns') return { type: 'ImportNamespaceSpecifier', local: { name: s.local } }
|
|
25
|
+
return {
|
|
26
|
+
type: 'ImportSpecifier',
|
|
27
|
+
imported: { type: 'Identifier', name: s.imported },
|
|
28
|
+
local: { name: s.local },
|
|
29
|
+
}
|
|
30
|
+
}),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('imports utils — Pyreon import classifiers', () => {
|
|
34
|
+
it('isPyreonImport recognises @pyreon/* sources', () => {
|
|
35
|
+
expect(isPyreonImport('@pyreon/core')).toBe(true)
|
|
36
|
+
expect(isPyreonImport('@pyreon/router')).toBe(true)
|
|
37
|
+
expect(isPyreonImport('react')).toBe(false)
|
|
38
|
+
expect(isPyreonImport('@vue/runtime-core')).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('isPyreonPackage matches the same prefix', () => {
|
|
42
|
+
expect(isPyreonPackage('@pyreon/flow')).toBe(true)
|
|
43
|
+
expect(isPyreonPackage('react')).toBe(false)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('imports utils — extractImportInfo', () => {
|
|
48
|
+
it('returns null for non-ImportDeclaration nodes', () => {
|
|
49
|
+
expect(extractImportInfo({ type: 'ExpressionStatement' })).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns null when source value is missing', () => {
|
|
53
|
+
expect(extractImportInfo({ type: 'ImportDeclaration', source: {}, specifiers: [] })).toBeNull()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('extracts default import', () => {
|
|
57
|
+
const info = extractImportInfo(importDecl('react', [{ kind: 'default', local: 'React' }]))
|
|
58
|
+
expect(info).toEqual({
|
|
59
|
+
source: 'react',
|
|
60
|
+
specifiers: [{ imported: 'default', local: 'React' }],
|
|
61
|
+
isDefault: true,
|
|
62
|
+
isNamespace: false,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('extracts namespace import', () => {
|
|
67
|
+
const info = extractImportInfo(importDecl('@pyreon/core', [{ kind: 'ns', local: 'P' }]))
|
|
68
|
+
expect(info).toEqual({
|
|
69
|
+
source: '@pyreon/core',
|
|
70
|
+
specifiers: [{ imported: '*', local: 'P' }],
|
|
71
|
+
isDefault: false,
|
|
72
|
+
isNamespace: true,
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('extracts named imports (Identifier form)', () => {
|
|
77
|
+
const info = extractImportInfo(
|
|
78
|
+
importDecl('@pyreon/reactivity', [
|
|
79
|
+
{ kind: 'named', imported: 'signal', local: 'signal' },
|
|
80
|
+
{ kind: 'named', imported: 'computed', local: 'computed' },
|
|
81
|
+
]),
|
|
82
|
+
)
|
|
83
|
+
expect(info?.source).toBe('@pyreon/reactivity')
|
|
84
|
+
expect(info?.specifiers).toEqual([
|
|
85
|
+
{ imported: 'signal', local: 'signal' },
|
|
86
|
+
{ imported: 'computed', local: 'computed' },
|
|
87
|
+
])
|
|
88
|
+
expect(info?.isDefault).toBe(false)
|
|
89
|
+
expect(info?.isNamespace).toBe(false)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('extracts named imports with renaming (local name differs)', () => {
|
|
93
|
+
const info = extractImportInfo(
|
|
94
|
+
importDecl('@pyreon/core', [
|
|
95
|
+
{ kind: 'named', imported: 'h', local: 'createElement' },
|
|
96
|
+
]),
|
|
97
|
+
)
|
|
98
|
+
expect(info?.specifiers).toEqual([{ imported: 'h', local: 'createElement' }])
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('handles imported as Literal (string-keyed export — type Literal node)', () => {
|
|
102
|
+
const info = extractImportInfo({
|
|
103
|
+
type: 'ImportDeclaration',
|
|
104
|
+
source: { value: '@pyreon/core' },
|
|
105
|
+
specifiers: [
|
|
106
|
+
{
|
|
107
|
+
type: 'ImportSpecifier',
|
|
108
|
+
imported: { type: 'Literal', value: 'use client' },
|
|
109
|
+
local: { name: 'useClient' },
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
})
|
|
113
|
+
expect(info?.specifiers).toEqual([{ imported: 'use client', local: 'useClient' }])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('handles missing specifiers array', () => {
|
|
117
|
+
const info = extractImportInfo({
|
|
118
|
+
type: 'ImportDeclaration',
|
|
119
|
+
source: { value: 'noop' },
|
|
120
|
+
})
|
|
121
|
+
expect(info?.specifiers).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('mixed default + named not standardly emitted but the builder is permissive', () => {
|
|
125
|
+
const info = extractImportInfo(
|
|
126
|
+
importDecl('react', [
|
|
127
|
+
{ kind: 'default', local: 'React' },
|
|
128
|
+
{ kind: 'named', imported: 'useState', local: 'useState' },
|
|
129
|
+
]),
|
|
130
|
+
)
|
|
131
|
+
expect(info?.isDefault).toBe(true)
|
|
132
|
+
expect(info?.specifiers).toHaveLength(2)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('imports utils — importsName', () => {
|
|
137
|
+
const imports = [
|
|
138
|
+
extractImportInfo(
|
|
139
|
+
importDecl('@pyreon/reactivity', [{ kind: 'named', imported: 'signal', local: 'signal' }]),
|
|
140
|
+
)!,
|
|
141
|
+
extractImportInfo(
|
|
142
|
+
importDecl('@pyreon/core', [{ kind: 'named', imported: 'h', local: 'h' }]),
|
|
143
|
+
)!,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
it('returns true when an import imports the named export', () => {
|
|
147
|
+
expect(importsName(imports, 'signal')).toBe(true)
|
|
148
|
+
expect(importsName(imports, 'h')).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('returns false when the name is not imported', () => {
|
|
152
|
+
expect(importsName(imports, 'computed')).toBe(false)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('respects fromPackage filter — same name from different package returns false', () => {
|
|
156
|
+
expect(importsName(imports, 'signal', '@pyreon/reactivity')).toBe(true)
|
|
157
|
+
expect(importsName(imports, 'signal', '@pyreon/core')).toBe(false)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('imports utils — getLocalName', () => {
|
|
162
|
+
const imports = [
|
|
163
|
+
extractImportInfo(
|
|
164
|
+
importDecl('@pyreon/core', [
|
|
165
|
+
{ kind: 'named', imported: 'h', local: 'createElement' },
|
|
166
|
+
]),
|
|
167
|
+
)!,
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
it('returns the local alias when the export is imported', () => {
|
|
171
|
+
expect(getLocalName(imports, 'h')).toBe('createElement')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('returns null when not imported', () => {
|
|
175
|
+
expect(getLocalName(imports, 'unused')).toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('respects fromPackage filter', () => {
|
|
179
|
+
expect(getLocalName(imports, 'h', '@pyreon/core')).toBe('createElement')
|
|
180
|
+
expect(getLocalName(imports, 'h', 'react')).toBeNull()
|
|
181
|
+
})
|
|
182
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — lint snapshot', () => {
|
|
9
|
+
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
10
|
+
const line = renderLlmsTxtLine(manifest)
|
|
11
|
+
expect(line.startsWith('- @pyreon/lint —')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders a llms-full.txt section with the right header', () => {
|
|
15
|
+
const section = renderLlmsFullSection(manifest)
|
|
16
|
+
expect(section.startsWith('## @pyreon/lint —')).toBe(true)
|
|
17
|
+
expect(section).toContain('```typescript')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders MCP api-reference entries for every api[] item', () => {
|
|
21
|
+
const record = renderApiReferenceEntries(manifest)
|
|
22
|
+
expect(Object.keys(record).sort()).toEqual([
|
|
23
|
+
'lint/cli',
|
|
24
|
+
'lint/lint',
|
|
25
|
+
'lint/lintFile',
|
|
26
|
+
'lint/no-process-dev-gate',
|
|
27
|
+
'lint/require-browser-smoke-test',
|
|
28
|
+
])
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatCompact, formatJSON, formatText } from '../reporter'
|
|
3
|
+
import type { LintResult } from '../types'
|
|
4
|
+
|
|
5
|
+
// Coverage gap closed in PR #323. The reporter module renders LintResult
|
|
6
|
+
// shapes into one of three formats (text / JSON / compact). Pure pretty-
|
|
7
|
+
// printing — no I/O, no async — but uncovered until now.
|
|
8
|
+
|
|
9
|
+
const span = (start: number, end: number) => ({ start, end })
|
|
10
|
+
|
|
11
|
+
const fileWithErr = {
|
|
12
|
+
filePath: '/abs/foo.ts',
|
|
13
|
+
diagnostics: [
|
|
14
|
+
{
|
|
15
|
+
ruleId: 'pyreon/no-window-in-ssr',
|
|
16
|
+
severity: 'error' as const,
|
|
17
|
+
message: 'window is undefined in SSR',
|
|
18
|
+
loc: { line: 12, column: 4 },
|
|
19
|
+
span: span(0, 6),
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
const fileWithMixed = {
|
|
24
|
+
filePath: '/abs/bar.ts',
|
|
25
|
+
diagnostics: [
|
|
26
|
+
{
|
|
27
|
+
ruleId: 'pyreon/no-bare-signal-in-jsx',
|
|
28
|
+
severity: 'warn' as const,
|
|
29
|
+
message: 'bare signal in JSX text',
|
|
30
|
+
loc: { line: 5, column: 10 },
|
|
31
|
+
span: span(20, 26),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
ruleId: 'pyreon/use-pyreon-hooks',
|
|
35
|
+
severity: 'info' as const,
|
|
36
|
+
message: 'consider useEventListener',
|
|
37
|
+
loc: { line: 7, column: 2 },
|
|
38
|
+
span: span(40, 60),
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
const cleanFile = {
|
|
43
|
+
filePath: '/abs/clean.ts',
|
|
44
|
+
diagnostics: [],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result: LintResult = {
|
|
48
|
+
files: [fileWithErr, fileWithMixed, cleanFile],
|
|
49
|
+
totalErrors: 1,
|
|
50
|
+
totalWarnings: 1,
|
|
51
|
+
totalInfos: 1,
|
|
52
|
+
configDiagnostics: [],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const empty: LintResult = {
|
|
56
|
+
files: [],
|
|
57
|
+
totalErrors: 0,
|
|
58
|
+
totalWarnings: 0,
|
|
59
|
+
totalInfos: 0,
|
|
60
|
+
configDiagnostics: [],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('reporter — formatText', () => {
|
|
64
|
+
it('renders file paths, locations, severities, messages, and rule ids', () => {
|
|
65
|
+
const text = formatText(result)
|
|
66
|
+
expect(text).toContain('/abs/foo.ts')
|
|
67
|
+
expect(text).toContain('/abs/bar.ts')
|
|
68
|
+
expect(text).toContain('12:4')
|
|
69
|
+
expect(text).toContain('5:10')
|
|
70
|
+
expect(text).toContain('window is undefined in SSR')
|
|
71
|
+
expect(text).toContain('pyreon/no-window-in-ssr')
|
|
72
|
+
expect(text).toContain('pyreon/no-bare-signal-in-jsx')
|
|
73
|
+
// 'error' / 'warning' / 'info' strings appear (ANSI-coloured)
|
|
74
|
+
expect(text).toContain('error')
|
|
75
|
+
expect(text).toContain('warning')
|
|
76
|
+
expect(text).toContain('info')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('omits files with no diagnostics from the body', () => {
|
|
80
|
+
const text = formatText(result)
|
|
81
|
+
expect(text).not.toContain('/abs/clean.ts')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('renders the trailing summary with pluralisation', () => {
|
|
85
|
+
const text = formatText(result)
|
|
86
|
+
expect(text).toMatch(/1 error/)
|
|
87
|
+
expect(text).toMatch(/1 warning/)
|
|
88
|
+
expect(text).toMatch(/1 info/)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('pluralises errors and warnings when count > 1', () => {
|
|
92
|
+
const multi: LintResult = {
|
|
93
|
+
files: [
|
|
94
|
+
{
|
|
95
|
+
filePath: '/x.ts',
|
|
96
|
+
diagnostics: [
|
|
97
|
+
{ ruleId: 'r', severity: 'error', message: 'm', loc: { line: 1, column: 1 }, span: span(0, 1) },
|
|
98
|
+
{ ruleId: 'r', severity: 'error', message: 'm', loc: { line: 2, column: 1 }, span: span(2, 3) },
|
|
99
|
+
{ ruleId: 'r', severity: 'warn', message: 'm', loc: { line: 3, column: 1 }, span: span(4, 5) },
|
|
100
|
+
{ ruleId: 'r', severity: 'warn', message: 'm', loc: { line: 4, column: 1 }, span: span(6, 7) },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
totalErrors: 2,
|
|
105
|
+
totalWarnings: 2,
|
|
106
|
+
totalInfos: 0,
|
|
107
|
+
configDiagnostics: [],
|
|
108
|
+
}
|
|
109
|
+
const text = formatText(multi)
|
|
110
|
+
expect(text).toMatch(/2 errors/)
|
|
111
|
+
expect(text).toMatch(/2 warnings/)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('returns empty-ish output for a clean result (no summary)', () => {
|
|
115
|
+
const text = formatText(empty)
|
|
116
|
+
expect(text.trim()).toBe('')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('reporter — formatJSON', () => {
|
|
121
|
+
it('round-trips through JSON.parse', () => {
|
|
122
|
+
const json = formatJSON(result)
|
|
123
|
+
expect(JSON.parse(json)).toEqual(result)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('produces indented output (multi-line)', () => {
|
|
127
|
+
const json = formatJSON(result)
|
|
128
|
+
expect(json.split('\n').length).toBeGreaterThan(1)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('handles empty results', () => {
|
|
132
|
+
expect(JSON.parse(formatJSON(empty))).toEqual(empty)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('reporter — formatCompact', () => {
|
|
137
|
+
it('emits one line per diagnostic in `path:line:col: severity [ruleId] message` form', () => {
|
|
138
|
+
const text = formatCompact(result)
|
|
139
|
+
const lines = text.split('\n')
|
|
140
|
+
expect(lines).toHaveLength(3)
|
|
141
|
+
expect(lines[0]).toBe(
|
|
142
|
+
'/abs/foo.ts:12:4: error [pyreon/no-window-in-ssr] window is undefined in SSR',
|
|
143
|
+
)
|
|
144
|
+
expect(lines[1]).toBe(
|
|
145
|
+
'/abs/bar.ts:5:10: warn [pyreon/no-bare-signal-in-jsx] bare signal in JSX text',
|
|
146
|
+
)
|
|
147
|
+
expect(lines[2]).toBe(
|
|
148
|
+
'/abs/bar.ts:7:2: info [pyreon/use-pyreon-hooks] consider useEventListener',
|
|
149
|
+
)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('emits empty string when there are no diagnostics', () => {
|
|
153
|
+
expect(formatCompact(empty)).toBe('')
|
|
154
|
+
})
|
|
155
|
+
})
|