@pyreon/lint 0.13.0 → 0.14.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/package.json +4 -1
- package/src/manifest.ts +152 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/lint",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pyreon-specific linter — 56 rules for signals, JSX, SSR, performance, router, and architecture",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/lint#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -53,5 +53,8 @@
|
|
|
53
53
|
"dependencies": {
|
|
54
54
|
"@oxc-project/types": "^0.123.0",
|
|
55
55
|
"oxc-parser": "^0.123.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@pyreon/manifest": "0.13.1"
|
|
56
59
|
}
|
|
57
60
|
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
+
|
|
3
|
+
export default defineManifest({
|
|
4
|
+
name: '@pyreon/lint',
|
|
5
|
+
title: 'Pyreon-specific Linter',
|
|
6
|
+
tagline:
|
|
7
|
+
'Pyreon-specific linter — 59 rules across 12 categories, config files, watch mode, AST cache, CLI + LSP',
|
|
8
|
+
description:
|
|
9
|
+
'Pyreon-specific lint rules powered by `oxc-parser`. Covers reactivity (10), JSX (11), lifecycle (4), performance (4), SSR (3), architecture (7), store (3), form (3), styling (4), hooks (3), accessibility (3), router (4) — 59 rules total. Programmatic API (`lint`, `lintFile`), CLI (`pyreon-lint`), watch mode (fs.watch + 100ms debounce + AstCache), LSP server, and `.pyreonlintrc.json` config with per-rule options via ESLint-style tuple form. Notable rules: `pyreon/no-process-dev-gate` (auto-fixable; replaces dead-in-browser `typeof process` gates with `import.meta.env?.DEV`), `pyreon/require-browser-smoke-test` (locks in T1.1 browser-test durability).',
|
|
10
|
+
category: 'server',
|
|
11
|
+
features: [
|
|
12
|
+
'59 rules across 12 categories',
|
|
13
|
+
'lint(options) programmatic API + lintFile() low-level entry',
|
|
14
|
+
'CLI: pyreon-lint with --preset / --fix / --watch / --format / --rule-options',
|
|
15
|
+
'4 presets: recommended, strict, app, lib',
|
|
16
|
+
'Per-rule options via tuple form in config or `--rule-options id=\'{json}\'`',
|
|
17
|
+
'AstCache (FNV-1a hash) for repeat runs',
|
|
18
|
+
'LSP server for IDE integration (startLspServer)',
|
|
19
|
+
'Inline suppression: // pyreon-lint-ignore <rule> OR // pyreon-lint-disable-next-line <rule>',
|
|
20
|
+
],
|
|
21
|
+
longExample: `import { lint, lintFile, allRules, getPreset, AstCache } from '@pyreon/lint'
|
|
22
|
+
|
|
23
|
+
// Programmatic — typical CI usage
|
|
24
|
+
const result = lint({ paths: ['src/'], preset: 'recommended' })
|
|
25
|
+
console.log(result.totalErrors, result.totalWarnings)
|
|
26
|
+
for (const d of result.configDiagnostics) console.log(d.ruleId, d.message)
|
|
27
|
+
|
|
28
|
+
// Per-rule overrides on a single run
|
|
29
|
+
lint({
|
|
30
|
+
paths: ['.'],
|
|
31
|
+
ruleOverrides: { 'pyreon/no-classname': 'off' },
|
|
32
|
+
ruleOptionsOverrides: {
|
|
33
|
+
'pyreon/no-window-in-ssr': { exemptPaths: ['src/foundation/'] },
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// Low-level single-file API with AST cache (watch mode)
|
|
38
|
+
const cache = new AstCache()
|
|
39
|
+
const config = getPreset('recommended')
|
|
40
|
+
const fileResult = lintFile('app.tsx', source, allRules, config, cache)
|
|
41
|
+
|
|
42
|
+
// CLI
|
|
43
|
+
// pyreon-lint --preset strict --quiet # CI mode
|
|
44
|
+
// pyreon-lint --fix # auto-fix
|
|
45
|
+
// pyreon-lint --watch src/ # watch mode
|
|
46
|
+
// pyreon-lint --list # list all 59 rules
|
|
47
|
+
// pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/`,
|
|
48
|
+
api: [
|
|
49
|
+
{
|
|
50
|
+
name: 'lint',
|
|
51
|
+
kind: 'function',
|
|
52
|
+
signature: 'lint(options?: LintOptions): LintResult',
|
|
53
|
+
summary:
|
|
54
|
+
'59 rules across 12 categories. Auto-loads `.pyreonlintrc.json`. Presets: `recommended`, `strict`, `app`, `lib`. Per-rule options via tuple form in config (`["error", { exemptPaths: [...] }]`) or `ruleOptionsOverrides`. Wrong-typed options surface on `result.configDiagnostics`. Uses `oxc-parser` with AST caching.',
|
|
55
|
+
example: `import { lint } from "@pyreon/lint"
|
|
56
|
+
|
|
57
|
+
const result = lint({ paths: ["src/"], preset: "recommended" })
|
|
58
|
+
console.log(result.totalErrors, result.totalWarnings)
|
|
59
|
+
// Config-level diagnostics (malformed rule options, etc.)
|
|
60
|
+
for (const d of result.configDiagnostics) console.log(d.ruleId, d.message)
|
|
61
|
+
|
|
62
|
+
// Severity overrides + per-rule options overrides
|
|
63
|
+
lint({
|
|
64
|
+
paths: ["."],
|
|
65
|
+
ruleOverrides: { "pyreon/no-classname": "off" },
|
|
66
|
+
ruleOptionsOverrides: {
|
|
67
|
+
"pyreon/no-window-in-ssr": { exemptPaths: ["src/foundation/"] },
|
|
68
|
+
},
|
|
69
|
+
})`,
|
|
70
|
+
seeAlso: ['lintFile', 'getPreset', 'AstCache'],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
name: 'lintFile',
|
|
74
|
+
kind: 'function',
|
|
75
|
+
signature:
|
|
76
|
+
'lintFile(filePath: string, sourceText: string, rules: Rule[], config: LintConfig, cache?: AstCache, configDiagnosticsSink?: ConfigDiagnostic[]): LintFileResult',
|
|
77
|
+
summary:
|
|
78
|
+
'Low-level single-file API. Optional `AstCache` for repeat runs (FNV-1a hash keyed). Optional `configDiagnosticsSink` collects malformed-option diagnostics; without it they print to stderr.',
|
|
79
|
+
example: `import { lintFile, allRules, getPreset, AstCache } from "@pyreon/lint"
|
|
80
|
+
|
|
81
|
+
const cache = new AstCache()
|
|
82
|
+
const config = getPreset("recommended")
|
|
83
|
+
const configSink: ConfigDiagnostic[] = []
|
|
84
|
+
const result = lintFile("app.tsx", source, allRules, config, cache, configSink)`,
|
|
85
|
+
seeAlso: ['lint', 'AstCache'],
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: 'cli',
|
|
89
|
+
kind: 'function',
|
|
90
|
+
signature:
|
|
91
|
+
"pyreon-lint [--preset name] [--fix] [--format text|json|compact] [--quiet] [--watch] [--list] [--config path] [--ignore path] [--rule id=severity] [--rule-options id='{json}'] [path...]",
|
|
92
|
+
summary:
|
|
93
|
+
"CLI entry. Config: `.pyreonlintrc.json` (reference `schema/pyreonlintrc.schema.json` for IDE autocomplete) or `package.json`'s `'pyreonlint'` field. Ignore: `.pyreonlintignore` + `.gitignore`. Watch: `fs.watch` recursive with 100ms debounce. `--rule-options id='{json}'` passes per-rule options on a single run.",
|
|
94
|
+
example: `pyreon-lint --preset strict --quiet # CI mode
|
|
95
|
+
pyreon-lint --fix # auto-fix
|
|
96
|
+
pyreon-lint --watch src/ # watch mode
|
|
97
|
+
pyreon-lint --list # list all 59 rules
|
|
98
|
+
pyreon-lint --format json # machine-readable
|
|
99
|
+
pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/`,
|
|
100
|
+
seeAlso: ['lint'],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'no-process-dev-gate',
|
|
104
|
+
kind: 'constant',
|
|
105
|
+
signature: 'rule: pyreon/no-process-dev-gate (architecture, error, auto-fixable)',
|
|
106
|
+
summary:
|
|
107
|
+
"The `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'` pattern works in vitest (Node, `process` is defined) but is silently dead code in real Vite browser bundles because Vite does NOT polyfill `process` for the client. Every `console.warn` gated on the broken constant never fires for real users in dev mode — unit tests pass while users get nothing. Use `import.meta.env.DEV` instead — Vite/Rolldown literal-replace it at build time, prod tree-shakes the warning to zero bytes, and vitest sets it to `true` automatically. Server-only packages (`zero`, `core/server`, `core/runtime-server`, `vite-plugin`, `cli`, `lint`, `mcp`, `storybook`, `typescript`) and test files are exempt. Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`. The rule has an auto-fix that replaces the broken expression with `import.meta.env?.DEV === true`.",
|
|
108
|
+
example: `// ❌ Wrong — dead code in real Vite browser bundles
|
|
109
|
+
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
110
|
+
if (__DEV__) console.warn('hello')
|
|
111
|
+
|
|
112
|
+
// ✅ Correct — Vite literal-replaces import.meta.env.DEV at build time
|
|
113
|
+
// @ts-ignore — provided by Vite/Rolldown at build time
|
|
114
|
+
const __DEV__ = import.meta.env?.DEV === true
|
|
115
|
+
if (__DEV__) console.warn('hello')`,
|
|
116
|
+
mistakes: [
|
|
117
|
+
"Copying the `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'` pattern from existing codebases — it works in Node but is dead in browser bundles",
|
|
118
|
+
"Trying to test with `delete globalThis.process` — vitest's own `import.meta.env` depends on `process`, so deleting it breaks the FIXED gate too (not because the gate is wrong, but because vitest can't resolve it)",
|
|
119
|
+
'Adding `process: { env: { ... } }` polyfills to vite.config.ts as a workaround — fix the source instead',
|
|
120
|
+
"Using the rule for server-only packages — they're correctly exempt because Node always has `process`",
|
|
121
|
+
],
|
|
122
|
+
seeAlso: ['require-browser-smoke-test'],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'require-browser-smoke-test',
|
|
126
|
+
kind: 'constant',
|
|
127
|
+
signature:
|
|
128
|
+
'rule: pyreon/require-browser-smoke-test (architecture, error in recommended/strict/lib, off in app)',
|
|
129
|
+
summary:
|
|
130
|
+
"Locks in the durability of the T1.1 browser smoke harness (PRs #224, #227, #229, #231). Every browser-categorized package MUST ship at least one `*.browser.test.{ts,tsx}` file under `src/`. Without this rule, new browser packages can quietly ship without smoke coverage and we drift back to the world before T1.1 — happy-dom silently masks environment-divergence bugs (PR #197 mock-vnode metadata drop, PR #200 `typeof process` dead code, multi-word event delegation bug). Default browser-package list mirrors `.claude/rules/test-environment-parity.md`. The rule fires once per package on its `src/index.ts`, walks the package directory looking for `*.browser.test.*`, and reports if none are found. Off in `app` preset because apps don't ship as packages with smoke obligations.",
|
|
131
|
+
example: `// Per-package config (optional — defaults cover all known browser packages)
|
|
132
|
+
{
|
|
133
|
+
"rules": {
|
|
134
|
+
"pyreon/require-browser-smoke-test": [
|
|
135
|
+
"error",
|
|
136
|
+
{
|
|
137
|
+
"additionalPackages": ["@my-org/my-browser-pkg"],
|
|
138
|
+
"exemptPaths": ["packages/experimental/"]
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
}`,
|
|
143
|
+
mistakes: [
|
|
144
|
+
'Adding a new browser-running package without a browser test — the rule will fail your PR',
|
|
145
|
+
'Hardcoding the browser-package list in the rule — the list lives in `.claude/rules/browser-packages.json` (single source of truth), not in the rule source',
|
|
146
|
+
'Disabling the rule globally — use `exemptPaths` to exempt specific packages still under construction',
|
|
147
|
+
'Shipping a `sanity.browser.test.ts` with `expect(1).toBe(1)` just to satisfy the rule — it passes but provides zero signal. The rule is a GATE, not a quality check; review actual contents on PR',
|
|
148
|
+
],
|
|
149
|
+
seeAlso: ['no-process-dev-gate'],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
})
|
|
@@ -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
|
+
})
|