uiaudit.js 1.0.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.
Files changed (52) hide show
  1. package/dist/auditor.d.ts +11 -0
  2. package/dist/auditor.d.ts.map +1 -0
  3. package/dist/auditor.js +70 -0
  4. package/dist/auditor.js.map +1 -0
  5. package/dist/auditors/accessibility.d.ts +8 -0
  6. package/dist/auditors/accessibility.d.ts.map +1 -0
  7. package/dist/auditors/accessibility.js +1769 -0
  8. package/dist/auditors/accessibility.js.map +1 -0
  9. package/dist/auditors/performance.d.ts +8 -0
  10. package/dist/auditors/performance.d.ts.map +1 -0
  11. package/dist/auditors/performance.js +168 -0
  12. package/dist/auditors/performance.js.map +1 -0
  13. package/dist/auditors/seo.d.ts +8 -0
  14. package/dist/auditors/seo.d.ts.map +1 -0
  15. package/dist/auditors/seo.js +171 -0
  16. package/dist/auditors/seo.js.map +1 -0
  17. package/dist/cli.d.ts +10 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +115 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/parser/index.d.ts +18 -0
  26. package/dist/parser/index.d.ts.map +1 -0
  27. package/dist/parser/index.js +87 -0
  28. package/dist/parser/index.js.map +1 -0
  29. package/dist/parser/traverse.d.ts +10 -0
  30. package/dist/parser/traverse.d.ts.map +1 -0
  31. package/dist/parser/traverse.js +13 -0
  32. package/dist/parser/traverse.js.map +1 -0
  33. package/dist/reporter/terminal.d.ts +3 -0
  34. package/dist/reporter/terminal.d.ts.map +1 -0
  35. package/dist/reporter/terminal.js +200 -0
  36. package/dist/reporter/terminal.js.map +1 -0
  37. package/dist/types.d.ts +38 -0
  38. package/dist/types.d.ts.map +1 -0
  39. package/dist/types.js +3 -0
  40. package/dist/types.js.map +1 -0
  41. package/package.json +39 -0
  42. package/src/auditor.ts +100 -0
  43. package/src/auditors/accessibility.ts +2125 -0
  44. package/src/auditors/performance.ts +212 -0
  45. package/src/auditors/seo.ts +212 -0
  46. package/src/cli.ts +162 -0
  47. package/src/index.ts +22 -0
  48. package/src/parser/index.ts +106 -0
  49. package/src/parser/traverse.ts +14 -0
  50. package/src/reporter/terminal.ts +247 -0
  51. package/src/types.ts +51 -0
  52. package/tsconfig.json +47 -0
@@ -0,0 +1,212 @@
1
+ import { traverse } from '../parser/traverse.js';
2
+ import type { ParsedFile } from '../parser/index.js';
3
+ import type { Issue } from '../types.js';
4
+
5
+ /**
6
+ * Audits parsed files for React performance anti-patterns.
7
+ * All checks are purely static — no browser, no runtime needed.
8
+ */
9
+ export function auditPerformance(parsedFiles: ParsedFile[]): Issue[] {
10
+ const issues: Issue[] = [];
11
+
12
+ for (const { ast, filePath } of parsedFiles) {
13
+ traverse(ast, {
14
+
15
+ /**
16
+ * All CallExpression checks are merged into ONE visitor.
17
+ * Babel traverse does NOT support duplicate visitor keys —
18
+ * the last one silently wins. Always merge.
19
+ */
20
+ CallExpression(path: any) {
21
+ const node = path.node;
22
+
23
+ // ── Check 1: .map() returning JSX without a key prop ─────────────
24
+ //
25
+ // Why this matters: React uses keys to identify which items in a
26
+ // list changed. Without keys, React re-renders the entire list on
27
+ // every state change — O(n) DOM mutations instead of O(1).
28
+ if (
29
+ node.callee.type === 'MemberExpression' &&
30
+ node.callee.property.type === 'Identifier' &&
31
+ node.callee.property.name === 'map' &&
32
+ node.arguments.length > 0
33
+ ) {
34
+ const callback = node.arguments[0];
35
+ if (
36
+ callback.type === 'ArrowFunctionExpression' ||
37
+ callback.type === 'FunctionExpression'
38
+ ) {
39
+ const body = callback.body;
40
+ let jsxEl: any = null;
41
+
42
+ // Arrow fn with implicit return: items.map(item => <div />)
43
+ if (body.type === 'JSXElement' || body.type === 'JSXFragment') {
44
+ jsxEl = body;
45
+ }
46
+
47
+ // Arrow fn with block body: items.map(item => { return <div /> })
48
+ if (body.type === 'BlockStatement') {
49
+ const ret = body.body.find(
50
+ (s: any) =>
51
+ s.type === 'ReturnStatement' &&
52
+ (s as any).argument?.type === 'JSXElement'
53
+ ) as any;
54
+ jsxEl = ret?.argument ?? null;
55
+ }
56
+
57
+ if (jsxEl?.type === 'JSXElement') {
58
+ const attrs = jsxEl.openingElement.attributes as any[];
59
+ const hasKey = attrs.some(
60
+ (a) =>
61
+ a.type === 'JSXAttribute' &&
62
+ a.name?.type === 'JSXIdentifier' &&
63
+ a.name.name === 'key'
64
+ );
65
+
66
+ if (!hasKey) {
67
+ issues.push({
68
+ id: 'missing-key-prop',
69
+ category: 'performance',
70
+ title: 'Missing key prop in list render',
71
+ description:
72
+ 'React uses keys to identify which items changed, were added, or removed. Without a key, React re-renders the whole list on every state update — even when nothing changed.',
73
+ impact: 'major',
74
+ status: 'fail',
75
+ suggestion:
76
+ 'Add a unique, stable key to the root element inside .map(). Use a real ID from your data — never the array index (index shifts when items are added/removed).',
77
+ codeSnippet: 'items.map(item => <Card>{item.name}</Card>)',
78
+ fixSnippet: 'items.map(item => <Card key={item.id}>{item.name}</Card>)',
79
+ file: filePath,
80
+ line: node.loc?.start.line,
81
+ });
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ // ── Check 2: useEffect with no dependency array ───────────────────
88
+ //
89
+ // Why this matters: No second argument = runs after EVERY render.
90
+ // This is almost always a bug — it creates fetch loops, causes
91
+ // infinite re-renders, and kills performance.
92
+ if (
93
+ node.callee.type === 'Identifier' &&
94
+ node.callee.name === 'useEffect' &&
95
+ node.arguments.length === 1 // 2nd arg (dep array) is absent
96
+ ) {
97
+ issues.push({
98
+ id: 'useeffect-no-deps',
99
+ category: 'performance',
100
+ title: 'useEffect is missing a dependency array',
101
+ description:
102
+ 'A useEffect with no second argument runs after every single render, including renders triggered by unrelated state changes. This almost always causes performance problems or infinite loops.',
103
+ impact: 'major',
104
+ status: 'fail',
105
+ suggestion:
106
+ 'Add a dependency array as the second argument.\n [] — run once on mount (equivalent to componentDidMount)\n [value] — run whenever `value` changes\n [id, token] — run when either changes',
107
+ codeSnippet: 'useEffect(() => { fetchUser(id); })',
108
+ fixSnippet: 'useEffect(() => { fetchUser(id); }, [id])',
109
+ file: filePath,
110
+ line: node.loc?.start.line,
111
+ });
112
+ }
113
+
114
+ // ── Check 3: console.log/debug/info in component files ────────────
115
+ //
116
+ // Why this matters: Console calls shipped to production leak
117
+ // internal data to any user who opens DevTools, and they
118
+ // accumulate into thousands of log entries in long sessions.
119
+ if (
120
+ node.callee.type === 'MemberExpression' &&
121
+ node.callee.object.type === 'Identifier' &&
122
+ node.callee.object.name === 'console' &&
123
+ node.callee.property.type === 'Identifier' &&
124
+ ['log', 'debug', 'info'].includes(node.callee.property.name)
125
+ ) {
126
+ const method = (node.callee.property as any).name as string;
127
+ issues.push({
128
+ id: 'console-in-component',
129
+ category: 'performance',
130
+ title: `console.${method}() left in component`,
131
+ description:
132
+ `console.${method} calls shipped to production expose internal data to end users and clutter the browser DevTools console.`,
133
+ impact: 'minor',
134
+ status: 'warning',
135
+ suggestion:
136
+ 'Remove before shipping. For intentional debug logging, gate it:\nif (process.env.NODE_ENV !== "production") console.log(...)',
137
+ file: filePath,
138
+ line: node.loc?.start.line,
139
+ });
140
+ }
141
+ }, // end CallExpression
142
+
143
+ // ── Check 4: Complex inline function in event handler props ──────────
144
+ //
145
+ // Why this matters: () => { ... } inside JSX creates a NEW function
146
+ // object on every render. Any child component that receives this prop
147
+ // will always fail React.memo's shallow equality check and re-render.
148
+ JSXAttribute(path: any) {
149
+ const node = path.node;
150
+ const EVENT_HANDLERS = [
151
+ 'onClick', 'onChange', 'onSubmit', 'onBlur', 'onFocus', 'onKeyDown',
152
+ ];
153
+
154
+ if (
155
+ node.name.type !== 'JSXIdentifier' ||
156
+ !EVENT_HANDLERS.includes(node.name.name)
157
+ ) return;
158
+
159
+ if (node.value?.type !== 'JSXExpressionContainer') return;
160
+
161
+ const expr = node.value.expression;
162
+ if (
163
+ expr.type !== 'ArrowFunctionExpression' &&
164
+ expr.type !== 'FunctionExpression'
165
+ ) return;
166
+
167
+ // Only flag truly inline logic — 2+ statements in the function body.
168
+ // A simple pass-through like onClick={() => onClick(item)} is fine.
169
+ const body = (expr as any).body;
170
+ const isComplex =
171
+ body?.type === 'BlockStatement' && body.body.length >= 2;
172
+
173
+ if (isComplex) {
174
+ const propName = node.name.name;
175
+ const handlerName = `handle${propName.slice(2)}`; // onClick → handleClick
176
+
177
+ issues.push({
178
+ id: 'inline-handler-in-jsx',
179
+ category: 'performance',
180
+ title: `Complex inline function in ${propName} prop`,
181
+ description:
182
+ `This creates a new function reference on every render. Any child receiving this prop via React.memo or PureComponent will always re-render because the prop value is never referentially equal.`,
183
+ impact: 'minor',
184
+ status: 'warning',
185
+ suggestion:
186
+ `Extract into a useCallback at the top of your component:\n\nconst ${handlerName} = useCallback(() => {\n // your logic here\n}, [/* list dependencies */]);\n\n// Then use:\n<Component ${propName}={${handlerName}} />`,
187
+ codeSnippet: `<Button ${propName}={() => { doA(); doB(); }}>`,
188
+ fixSnippet: `const ${handlerName} = useCallback(() => { doA(); doB(); }, []);\n<Button ${propName}={${handlerName}}>`,
189
+ file: filePath,
190
+ line: node.loc?.start.line,
191
+ });
192
+ }
193
+ }, // end JSXAttribute
194
+
195
+ }); // end traverse
196
+ }
197
+
198
+ return deduplicate(issues);
199
+ }
200
+
201
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
202
+
203
+ /** Drop exact duplicate issues (same rule, file, and line). */
204
+ function deduplicate(issues: Issue[]): Issue[] {
205
+ const seen = new Set<string>();
206
+ return issues.filter((issue) => {
207
+ const key = `${issue.id}::${issue.file}::${issue.line}`;
208
+ if (seen.has(key)) return false;
209
+ seen.add(key);
210
+ return true;
211
+ });
212
+ }
@@ -0,0 +1,212 @@
1
+ import { traverse } from '../parser/traverse.js';
2
+ import type { ParsedFile } from '../parser/index.js';
3
+ import type { Issue } from '../types.js';
4
+
5
+ /**
6
+ * Audits parsed files for SEO issues detectable via static AST analysis.
7
+ * Focus: things that directly cost you search ranking or crawlability.
8
+ */
9
+ export function auditSEO(parsedFiles: ParsedFile[]): Issue[] {
10
+ const issues: Issue[] = [];
11
+
12
+ for (const { ast, filePath } of parsedFiles) {
13
+ // Per-file state — we collect import info first, then use it in checks
14
+ const fileState = {
15
+ importsNextImage: false,
16
+ imgTagLines: [] as number[],
17
+ };
18
+
19
+ traverse(ast, {
20
+
21
+ // ── Track which packages this file imports ────────────────────────────
22
+ ImportDeclaration(path: any) {
23
+ if (path.node.source.value === 'next/image') {
24
+ fileState.importsNextImage = true;
25
+ }
26
+ },
27
+
28
+ // ── All JSXOpeningElement checks ──────────────────────────────────────
29
+ JSXOpeningElement(path: any) {
30
+ const node = path.node;
31
+ if (node.name.type !== 'JSXIdentifier') return;
32
+
33
+ const tagName = node.name.name;
34
+
35
+ // Helper: check if an attribute exists on this element
36
+ const hasAttr = (name: string): boolean =>
37
+ node.attributes.some(
38
+ (a: any) =>
39
+ a.type === 'JSXAttribute' &&
40
+ a.name.type === 'JSXIdentifier' &&
41
+ a.name.name === name
42
+ );
43
+
44
+ // Helper: get an attribute's string value (returns null if dynamic/absent)
45
+ const getStringAttr = (name: string): string | null => {
46
+ const attr = node.attributes.find(
47
+ (a: any) =>
48
+ a.type === 'JSXAttribute' &&
49
+ a.name.type === 'JSXIdentifier' &&
50
+ a.name.name === name
51
+ ) as any;
52
+ return attr?.value?.type === 'StringLiteral'
53
+ ? attr.value.value
54
+ : null;
55
+ };
56
+
57
+ // ── Check 1: <img> without alt attribute ─────────────────────────
58
+ //
59
+ // Why this matters: Google uses alt text to understand image content
60
+ // for image search ranking. Missing alt = invisible to search engines.
61
+ if (tagName === 'img') {
62
+ fileState.imgTagLines.push(node.loc?.start.line ?? 0);
63
+
64
+ if (!hasAttr('alt')) {
65
+ issues.push({
66
+ id: 'img-missing-alt',
67
+ category: 'seo',
68
+ title: '<img> is missing an alt attribute',
69
+ description:
70
+ 'Search engines use alt text to index image content. Missing alt attributes also break Google Image Search rankings and violate WCAG 1.1.1.',
71
+ impact: 'major',
72
+ status: 'fail',
73
+ suggestion:
74
+ 'Add an alt attribute describing what the image shows.\nFor decorative images (pure visual flair, no meaning): alt=""\nFor meaningful images: alt="Two developers reviewing code on a laptop"',
75
+ codeSnippet: '<img src={hero} />',
76
+ fixSnippet: '<img src={hero} alt="Hero banner showing the product dashboard" />',
77
+ file: filePath,
78
+ line: node.loc?.start.line,
79
+ });
80
+ }
81
+ }
82
+
83
+ // ── Check 2: Self-closing <a> with no content or aria-label ──────
84
+ //
85
+ // Why this matters: Google uses anchor text to understand what the
86
+ // linked page is about. An empty link gives it nothing to rank on.
87
+ if (tagName === 'a' && node.selfClosing) {
88
+ const hasAriaLabel = hasAttr('aria-label');
89
+ const hasTitle = hasAttr('title');
90
+
91
+ if (!hasAriaLabel && !hasTitle) {
92
+ issues.push({
93
+ id: 'anchor-no-content',
94
+ category: 'seo',
95
+ title: '<a> tag has no content or accessible label',
96
+ description:
97
+ 'Empty anchor tags give search engines no anchor text to rank with, and give screen readers nothing to announce. Both hurt you — one in rankings, one in accessibility.',
98
+ impact: 'major',
99
+ status: 'fail',
100
+ suggestion:
101
+ 'Add descriptive text between <a> and </a>, or add aria-label if the link contains only an icon:\n<a href="/about">About us</a>\n// Icon-only link:\n<a href="/about" aria-label="About us"><Icon /></a>',
102
+ codeSnippet: '<a href="/about" />',
103
+ fixSnippet: '<a href="/about">About us</a>',
104
+ file: filePath,
105
+ line: node.loc?.start.line,
106
+ });
107
+ }
108
+ }
109
+
110
+ // ── Check 3: <div> used where a semantic HTML tag belongs ────────
111
+ //
112
+ // Why this matters: Semantic HTML is a direct ranking signal.
113
+ // Google's crawlers assign structural meaning to <nav>, <main>,
114
+ // <article>, etc. A <div> with className="nav" tells them nothing.
115
+ if (tagName === 'div') {
116
+ // Look at className and id for hints about intended semantics
117
+ const className = getStringAttr('className') ?? '';
118
+ const idVal = getStringAttr('id') ?? '';
119
+ const combined = `${className} ${idVal}`.toLowerCase();
120
+
121
+ const SEMANTIC_MAP: Record<string, string> = {
122
+ nav: 'nav',
123
+ navigation: 'nav',
124
+ header: 'header',
125
+ footer: 'footer',
126
+ main: 'main',
127
+ sidebar: 'aside',
128
+ aside: 'aside',
129
+ article: 'article',
130
+ section: 'section',
131
+ };
132
+
133
+ // Check each word in className/id against our semantic map
134
+ const words = combined.split(/[\s\-_/]+/);
135
+ const match = words.find((w) => SEMANTIC_MAP[w]);
136
+
137
+ if (match) {
138
+ const replacement = SEMANTIC_MAP[match];
139
+ issues.push({
140
+ id: 'non-semantic-html',
141
+ category: 'seo',
142
+ title: `Use <${replacement}> instead of <div className="${className || idVal}">`,
143
+ description:
144
+ `Search engines assign structural roles to semantic HTML elements. <${replacement}> signals its purpose to Google's crawler and improves your document outline. A <div> is invisible to that system.`,
145
+ impact: 'minor',
146
+ status: 'warning',
147
+ suggestion:
148
+ `Replace <div> with <${replacement}>. You can keep the existing className — the element name is what changes.\n<${replacement} className="${className || idVal}">...</${replacement}>`,
149
+ codeSnippet: `<div className="${className || idVal}">...</div>`,
150
+ fixSnippet: `<${replacement} className="${className || idVal}">...</${replacement}>`,
151
+ file: filePath,
152
+ line: node.loc?.start.line,
153
+ });
154
+ }
155
+ }
156
+
157
+ }, // end JSXOpeningElement
158
+
159
+ }); // end traverse
160
+
161
+ // ── Post-traverse: Check for raw <img> in Next.js component files ────────
162
+ //
163
+ // This runs AFTER traverse because we need to know both:
164
+ // (a) whether 'next/image' was imported (set in ImportDeclaration)
165
+ // (b) whether any <img> tags were used (set in JSXOpeningElement)
166
+ //
167
+ // Why this matters: next/image gives you automatic WebP, lazy-loading,
168
+ // responsive sizes, and CLS prevention — all Core Web Vitals wins.
169
+ // A raw <img> tag bypasses ALL of that.
170
+
171
+ const isComponentFile =
172
+ filePath.endsWith('.tsx') || filePath.endsWith('.jsx');
173
+
174
+ if (
175
+ isComponentFile &&
176
+ fileState.imgTagLines.length > 0 &&
177
+ !fileState.importsNextImage
178
+ ) {
179
+ for (const line of fileState.imgTagLines) {
180
+ issues.push({
181
+ id: 'use-next-image',
182
+ category: 'seo',
183
+ title: 'Use Next.js <Image> instead of <img>',
184
+ description:
185
+ 'Next.js <Image> automatically converts to WebP, lazy-loads below-the-fold images, prevents layout shift (CLS), and serves correctly-sized images per viewport. Raw <img> gets none of this.',
186
+ impact: 'major',
187
+ status: 'warning',
188
+ suggestion:
189
+ "Import and use the Next.js Image component:\nimport Image from 'next/image';\n\n// Replace:\n<img src={src} alt={alt} width={500} height={300} />",
190
+ codeSnippet: `import Image from 'next/image';`,
191
+ fixSnippet: `<Image src={src} alt="description" width={500} height={300} />`,
192
+ file: filePath,
193
+ line,
194
+ });
195
+ }
196
+ }
197
+ }
198
+
199
+ return deduplicate(issues);
200
+ }
201
+
202
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
203
+
204
+ function deduplicate(issues: Issue[]): Issue[] {
205
+ const seen = new Set<string>();
206
+ return issues.filter((i) => {
207
+ const key = `${i.id}::${i.file}::${i.line}`;
208
+ if (seen.has(key)) return false;
209
+ seen.add(key);
210
+ return true;
211
+ });
212
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UIAudit CLI entry point.
4
+ * This file is what runs when someone types `uiaudit` in their terminal.
5
+ *
6
+ * The bin field in package.json points to `dist/cli.js` (compiled version).
7
+ * During development: `npx ts-node src/cli.ts audit ./src`
8
+ */
9
+
10
+ import { Command } from 'commander';
11
+ import ora from 'ora';
12
+ import chalk from 'chalk';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+
16
+ import { runAudit } from './auditor.js';
17
+ import { renderTerminal } from './reporter/terminal.js';
18
+ import type { AuditCategory } from './types.js';
19
+
20
+ // ─── Constants ────────────────────────────────────────────────────────────────
21
+
22
+ const ALL_CATEGORIES: AuditCategory[] = ['performance', 'seo', 'accessibility'];
23
+ const VERSION = '1.0.0';
24
+
25
+ // ─── CLI setup ────────────────────────────────────────────────────────────────
26
+
27
+ const program = new Command();
28
+
29
+ program
30
+ .name('uiaudit')
31
+ .description('Audit React/Next.js components for performance, SEO, and accessibility issues')
32
+ .version(VERSION);
33
+
34
+ // ─── Main command: uiaudit audit <target> ────────────────────────────────────
35
+
36
+ program
37
+ .command('audit <target>')
38
+ .description('Audit a file or directory of React/Next.js components')
39
+ .option(
40
+ '-t, --type <types>',
41
+ 'Comma-separated audit categories to run: performance, seo, accessibility',
42
+ 'performance,seo,accessibility'
43
+ )
44
+ .option(
45
+ '-o, --output <format>',
46
+ 'Output format: terminal (default) or json',
47
+ 'terminal'
48
+ )
49
+ .option(
50
+ '-f, --file <path>',
51
+ 'Save JSON report to a file (also prints to terminal by default)'
52
+ )
53
+ .action((target: string, opts: { type: string; output: string; file?: string }) => {
54
+ const types = parseTypes(opts.type);
55
+ if (!types) process.exit(1);
56
+
57
+ runAuditCommand(target, types, opts.output, opts.file);
58
+ });
59
+
60
+ // ─── Shorthand commands ───────────────────────────────────────────────────────
61
+ // These exist so developers can type `uiaudit perf ./src` instead of the
62
+ // full `uiaudit audit ./src --type performance`. Less typing = more usage.
63
+
64
+ program
65
+ .command('perf <target>')
66
+ .description('Shorthand: run performance audit only')
67
+ .option('-o, --output <format>', 'Output format: terminal or json', 'terminal')
68
+ .option('-f, --file <path>', 'Save JSON report to a file')
69
+ .action((target: string, opts: { output: string; file?: string }) => {
70
+ runAuditCommand(target, ['performance'], opts.output, opts.file);
71
+ });
72
+
73
+ program
74
+ .command('seo <target>')
75
+ .description('Shorthand: run SEO audit only')
76
+ .option('-o, --output <format>', 'Output format: terminal or json', 'terminal')
77
+ .option('-f, --file <path>', 'Save JSON report to a file')
78
+ .action((target: string, opts: { output: string; file?: string }) => {
79
+ runAuditCommand(target, ['seo'], opts.output, opts.file);
80
+ });
81
+
82
+ program
83
+ .command('a11y <target>')
84
+ .description('Shorthand: run accessibility audit only')
85
+ .option('-o, --output <format>', 'Output format: terminal or json', 'terminal')
86
+ .option('-f, --file <path>', 'Save JSON report to a file')
87
+ .action((target: string, opts: { output: string; file?: string }) => {
88
+ runAuditCommand(target, ['accessibility'], opts.output, opts.file);
89
+ });
90
+
91
+ program.parse(process.argv);
92
+
93
+ // ─── Shared action handler ────────────────────────────────────────────────────
94
+
95
+ function runAuditCommand(
96
+ target: string,
97
+ types: AuditCategory[],
98
+ output: string,
99
+ outputFile?: string
100
+ ): void {
101
+ const spinner = ora({
102
+ text: `Scanning ${chalk.cyan(target)}...`,
103
+ color: 'cyan',
104
+ }).start();
105
+
106
+ try {
107
+ const report = runAudit(target, { types });
108
+
109
+ spinner.succeed(
110
+ chalk.green(
111
+ `Scanned ${report.totalFiles} file${report.totalFiles !== 1 ? 's' : ''}`
112
+ )
113
+ );
114
+
115
+ // Save JSON report to file if requested
116
+ if (outputFile) {
117
+ const resolvedPath = path.resolve(outputFile);
118
+ fs.writeFileSync(resolvedPath, JSON.stringify(report, null, 2), 'utf-8');
119
+ console.log(chalk.green(`\n ✓ Report saved → ${resolvedPath}\n`));
120
+ }
121
+
122
+ // Render output
123
+ if (output === 'json') {
124
+ console.log(JSON.stringify(report, null, 2));
125
+ } else {
126
+ renderTerminal(report);
127
+ }
128
+
129
+ // Exit code 1 if any critical issues — makes this useful in CI pipelines.
130
+ // Example: `uiaudit audit ./src && git push` will block the push if crits exist.
131
+ const hasCritical = Object.values(report.results).some(
132
+ (r) => r && r.counts.critical > 0
133
+ );
134
+ process.exit(hasCritical ? 1 : 0);
135
+
136
+ } catch (err: unknown) {
137
+ spinner.fail(chalk.red(`Audit failed: ${(err as Error).message}`));
138
+ if (process.env.DEBUG) {
139
+ console.error('\n', err);
140
+ } else {
141
+ console.error(chalk.dim(' Run with DEBUG=1 for stack trace.'));
142
+ }
143
+ process.exit(1);
144
+ }
145
+ }
146
+
147
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
148
+
149
+ function parseTypes(raw: string): AuditCategory[] | null {
150
+ const parts = raw.split(',').map((t) => t.trim().toLowerCase()) as AuditCategory[];
151
+ const invalid = parts.filter((p) => !ALL_CATEGORIES.includes(p));
152
+
153
+ if (invalid.length > 0) {
154
+ console.error(
155
+ chalk.red(`\n Unknown category: "${invalid.join('", "')}"\n`) +
156
+ chalk.dim(` Valid options: ${ALL_CATEGORIES.join(', ')}\n`)
157
+ );
158
+ return null;
159
+ }
160
+
161
+ return parts;
162
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * UIAudit — Public programmatic API
3
+ *
4
+ * This is what the VS Code extension (and any other programmatic consumer)
5
+ * will import. The CLI (src/cli.ts) uses this same API under the hood.
6
+ *
7
+ * Usage:
8
+ * import { runAudit } from 'uiaudit';
9
+ * const report = runAudit('./src/components', { types: ['accessibility', 'performance'] });
10
+ */
11
+
12
+ export { runAudit } from './auditor.js';
13
+
14
+ export type {
15
+ AuditReport,
16
+ AuditResult,
17
+ AuditOptions,
18
+ AuditCategory,
19
+ Issue,
20
+ IssueImpact,
21
+ IssueStatus,
22
+ } from './types.js';