@chemx/starter-kit 26.9.11-631 → 26.9.12-1140

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/cli/audit/ai-slop-detector.js +246 -0
  2. package/cli/audit/ast-visitors.js +10 -13
  3. package/cli/audit/extended-visitors.js +359 -0
  4. package/cli/audit/history.js +63 -12
  5. package/cli/audit/metrics.js +69 -0
  6. package/cli/audit/pattern-detector.js +182 -0
  7. package/cli/audit/prompts.js +172 -58
  8. package/cli/audit/reporter-banner.d.ts +3 -0
  9. package/cli/audit/reporter-banner.js +62 -0
  10. package/cli/audit/reporter-grades.js +39 -42
  11. package/cli/audit/reporter-grouping-markdown.js +83 -0
  12. package/cli/audit/reporter-grouping.d.ts +48 -0
  13. package/cli/audit/reporter-grouping.js +286 -0
  14. package/cli/audit/reporter-markdown.js +56 -29
  15. package/cli/audit/reporter-sections.d.ts +10 -0
  16. package/cli/audit/reporter-sections.js +269 -0
  17. package/cli/audit/reporter-summary.d.ts +4 -0
  18. package/cli/audit/reporter-summary.js +142 -0
  19. package/cli/audit/reporter-utils.js +101 -2
  20. package/cli/audit/reporter.d.ts +25 -0
  21. package/cli/audit/reporter.js +77 -423
  22. package/cli/audit/roadmap.js +236 -0
  23. package/cli/audit/rules-helpers.js +16 -0
  24. package/cli/audit/rules-predicates.d.ts +17 -0
  25. package/cli/audit/rules-predicates.js +107 -0
  26. package/cli/audit/rules-registry.js +88 -1
  27. package/cli/audit/rules.js +37 -2
  28. package/cli/audit/social-gh.js +2 -6
  29. package/cli/audit/social-git.js +133 -131
  30. package/cli/audit/social-publisher.js +1 -1
  31. package/cli/audit/social.js +39 -10
  32. package/cli/audit/types.d.ts +71 -28
  33. package/cli/audit.js +101 -8
  34. package/cli/badge.js +269 -0
  35. package/cli/help.js +7 -0
  36. package/cli/index.js +54 -20
  37. package/cli/installer-templates.js +43 -4
  38. package/cli/installer.d.ts +2 -0
  39. package/cli/installer.js +52 -38
  40. package/cli/license.js +23 -8
  41. package/cli/navigator-actions.js +97 -7
  42. package/cli/navigator-banner.js +20 -7
  43. package/cli/navigator-grades.js +9 -0
  44. package/cli/navigator-menu.js +54 -0
  45. package/cli/navigator-share.js +8 -5
  46. package/cli/navigator.d.ts +13 -2
  47. package/cli/navigator.js +49 -12
  48. package/cli/terminal.js +10 -6
  49. package/cli/theme.d.ts +41 -0
  50. package/cli/theme.js +84 -0
  51. package/docs/CHANGELOG.md +82 -0
  52. package/package.json +1 -1
@@ -0,0 +1,246 @@
1
+ import * as t from '@babel/types';
2
+ import { RULE_REGISTRY } from './rules-registry.js';
3
+ import {
4
+ isComponentPath,
5
+ isCodeLine,
6
+ isShallowCatchBody,
7
+ hasAnyTypeAnnotation,
8
+ isRedundantPassthroughReturn
9
+ } from './rules-predicates.js';
10
+
11
+ const RESIDUE_PATTERNS = [
12
+ ['hope this', 'helps'].join(' '),
13
+ ['feel free', 'to tweak'].join(' '),
14
+ ['let me know', 'if you need'].join(' '),
15
+ ['as', 'requested'].join(' '),
16
+ ['as an ai', 'language model'].join(' ')
17
+ ];
18
+
19
+ const PLACEHOLDER_PATTERNS = [
20
+ ['replace with', 'your own'].join(' '),
21
+ ['replace this with', 'actual'].join(' '),
22
+ ['insert your', 'logic here'].join(' ')
23
+ ];
24
+
25
+ const CONVERSATIONAL_PATTERNS = [
26
+ {
27
+ regex: /\b(?:here(?:'s| is) the (?:complete|updated|refactored|full) (?:code|implementation|file|component|version))\b/i,
28
+ rule: 'AI_SLOP_CONVERSATIONAL_ARTIFACT',
29
+ hazard: 'LLM conversational preamble detected in source'
30
+ },
31
+ {
32
+ regex: new RegExp(`\\b(?:${RESIDUE_PATTERNS.join('|')})\\b`, 'i'),
33
+ rule: 'AI_SLOP_CONVERSATIONAL_ARTIFACT',
34
+ hazard: 'LLM assistant conversational residue detected in source'
35
+ },
36
+ {
37
+ regex: new RegExp(`\\b(?:${PLACEHOLDER_PATTERNS.join('|')})\\b`, 'i'),
38
+ rule: 'AI_SLOP_CONVERSATIONAL_ARTIFACT',
39
+ hazard: 'Generic AI boilerplate instruction placeholder detected'
40
+ },
41
+ {
42
+ regex: /\/\/\s*\.\.\.\s*(?:existing|rest of|remaining)\s+(?:code|implementation|logic|imports)/i,
43
+ rule: 'AI_SLOP_LAZY_PLACEHOLDER',
44
+ hazard: 'AI truncation placeholder detected (lazy incomplete implementation)'
45
+ },
46
+ {
47
+ regex: /```(?:typescript|javascript|tsx|jsx|vue|js|ts|html|css)?\s*$/m,
48
+ rule: 'AI_SLOP_CONVERSATIONAL_ARTIFACT',
49
+ hazard: 'Leaked markdown code block fence detected in source'
50
+ }
51
+ ];
52
+
53
+ const REINVENTED_UTILS = new Set([
54
+ 'clamp',
55
+ 'slugify',
56
+ 'debounce',
57
+ 'throttle',
58
+ 'deepclone',
59
+ 'deepmerge',
60
+ 'capitalize'
61
+ ]);
62
+
63
+ const STOP_WORDS = new Set([
64
+ 'the', 'a', 'an', 'to', 'if', 'and', 'of', 'for', 'in', 'is', 'it',
65
+ 'this', 'that', 'with', 'from', 'as', 'on', 'function', 'const', 'let', 'var'
66
+ ]);
67
+
68
+ const normalizeWords = (text) => {
69
+ return text
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9]/g, ' ')
72
+ .split(/\s+/)
73
+ .filter((w) => w.length > 1 && !STOP_WORDS.has(w));
74
+ };
75
+
76
+ export const checkSlopTextPatterns = (content, lines, relativePath, violations) => {
77
+ lines.forEach((lineText, idx) => {
78
+ for (const pat of CONVERSATIONAL_PATTERNS) {
79
+ if (pat.regex.test(lineText)) {
80
+ const meta = RULE_REGISTRY[pat.rule];
81
+ violations.push({
82
+ filePath: relativePath,
83
+ line: idx + 1,
84
+ column: 1,
85
+ hazard: pat.hazard,
86
+ rule: pat.rule,
87
+ severity: meta.severity,
88
+ pillar: meta.pillar,
89
+ directive: meta.directive,
90
+ isAiSlop: true
91
+ });
92
+ break;
93
+ }
94
+ }
95
+
96
+ const trimmed = lineText.trim();
97
+ if (trimmed.startsWith('//') && !trimmed.startsWith('///')) {
98
+ const commentBody = trimmed.replace(/^\/\/\s*/, '');
99
+ const commentWords = normalizeWords(commentBody);
100
+
101
+ if (commentWords.length >= 2 && commentWords.length <= 6) {
102
+ let nextIdx = idx + 1;
103
+ while (nextIdx < lines.length) {
104
+ const nextTrim = lines[nextIdx].trim();
105
+ if (isCodeLine(nextTrim)) {
106
+ const nextWords = new Set(normalizeWords(nextTrim));
107
+ let matchCount = 0;
108
+ for (const cw of commentWords) {
109
+ if (nextWords.has(cw)) matchCount += 1;
110
+ }
111
+ const matchRatio = matchCount / commentWords.length;
112
+ if (matchRatio >= 0.75) {
113
+ const meta = RULE_REGISTRY.AI_SLOP_ECHO_COMMENT;
114
+ violations.push({
115
+ filePath: relativePath,
116
+ line: idx + 1,
117
+ column: 1,
118
+ hazard: `Trivial echo comment detected: "${commentBody.slice(0, 40)}" repeats adjacent code`,
119
+ rule: 'AI_SLOP_ECHO_COMMENT',
120
+ severity: meta.severity,
121
+ pillar: meta.pillar,
122
+ directive: meta.directive,
123
+ isAiSlop: true
124
+ });
125
+ }
126
+ break;
127
+ }
128
+ nextIdx += 1;
129
+ }
130
+ }
131
+ }
132
+ });
133
+ };
134
+
135
+ export const createAiSlopVisitors = ({ relativePath, violations }) => {
136
+ const isComponentFile = isComponentPath(relativePath);
137
+
138
+ return {
139
+ CatchClause(astPath) {
140
+ const body = astPath.node.body?.body || [];
141
+ const isShallowCatch = isShallowCatchBody(body, t);
142
+
143
+ if (isShallowCatch) {
144
+ const line = astPath.node.loc?.start.line || 1;
145
+ const meta = RULE_REGISTRY.AI_SLOP_SHALLOW_CATCH;
146
+ violations.push({
147
+ filePath: relativePath,
148
+ line,
149
+ column: astPath.node.loc?.start.column || 1,
150
+ hazard: 'Shallow catch paranoia wrapper (silent suppression without handling)',
151
+ rule: 'AI_SLOP_SHALLOW_CATCH',
152
+ severity: meta.severity,
153
+ pillar: meta.pillar,
154
+ directive: meta.directive,
155
+ isAiSlop: true
156
+ });
157
+ }
158
+
159
+ if (hasAnyTypeAnnotation(astPath.node.param, t)) {
160
+ const line = astPath.node.loc?.start.line || 1;
161
+ const meta = RULE_REGISTRY.AI_SLOP_LAZY_ANY;
162
+ violations.push({
163
+ filePath: relativePath,
164
+ line,
165
+ column: astPath.node.loc?.start.column || 1,
166
+ hazard: 'Lazy any catch parameter widening detected',
167
+ rule: 'AI_SLOP_LAZY_ANY',
168
+ severity: meta.severity,
169
+ pillar: meta.pillar,
170
+ directive: meta.directive,
171
+ isAiSlop: true
172
+ });
173
+ }
174
+ },
175
+
176
+ BlockStatement(astPath) {
177
+ const stmts = astPath.node.body;
178
+ if (stmts.length >= 2) {
179
+ for (let i = 0; i < stmts.length - 1; i++) {
180
+ const curr = stmts[i];
181
+ const next = stmts[i + 1];
182
+ if (isRedundantPassthroughReturn(curr, next, t)) {
183
+ const varName = curr.declarations[0].id.name;
184
+ const line = curr.loc?.start.line || 1;
185
+ const meta = RULE_REGISTRY.AI_SLOP_REDUNDANT_PASSTHROUGH;
186
+ violations.push({
187
+ filePath: relativePath,
188
+ line,
189
+ column: curr.loc?.start.column || 1,
190
+ hazard: `Redundant single-use passthrough assignment "${varName}" before return`,
191
+ rule: 'AI_SLOP_REDUNDANT_PASSTHROUGH',
192
+ severity: meta.severity,
193
+ pillar: meta.pillar,
194
+ directive: meta.directive,
195
+ isAiSlop: true
196
+ });
197
+ }
198
+ }
199
+ }
200
+ },
201
+
202
+ FunctionDeclaration(astPath) {
203
+ if (!isComponentFile) return;
204
+ const fnName = astPath.node.id?.name?.toLowerCase();
205
+ if (fnName && REINVENTED_UTILS.has(fnName)) {
206
+ const line = astPath.node.loc?.start.line || 1;
207
+ const meta = RULE_REGISTRY.AI_SLOP_UTILITY_REINVENTION;
208
+ violations.push({
209
+ filePath: relativePath,
210
+ line,
211
+ column: astPath.node.loc?.start.column || 1,
212
+ hazard: `Inline utility reinvention "${astPath.node.id.name}" in component capsule`,
213
+ rule: 'AI_SLOP_UTILITY_REINVENTION',
214
+ severity: meta.severity,
215
+ pillar: meta.pillar,
216
+ directive: meta.directive,
217
+ isAiSlop: true
218
+ });
219
+ }
220
+ },
221
+
222
+ VariableDeclarator(astPath) {
223
+ if (!isComponentFile) return;
224
+ const idName = astPath.node.id?.name?.toLowerCase();
225
+ if (
226
+ idName &&
227
+ REINVENTED_UTILS.has(idName) &&
228
+ (t.isArrowFunctionExpression(astPath.node.init) || t.isFunctionExpression(astPath.node.init))
229
+ ) {
230
+ const line = astPath.node.loc?.start.line || 1;
231
+ const meta = RULE_REGISTRY.AI_SLOP_UTILITY_REINVENTION;
232
+ violations.push({
233
+ filePath: relativePath,
234
+ line,
235
+ column: astPath.node.loc?.start.column || 1,
236
+ hazard: `Inline utility reinvention "${astPath.node.id.name}" in component capsule`,
237
+ rule: 'AI_SLOP_UTILITY_REINVENTION',
238
+ severity: meta.severity,
239
+ pillar: meta.pillar,
240
+ directive: meta.directive,
241
+ isAiSlop: true
242
+ });
243
+ }
244
+ }
245
+ };
246
+ };
@@ -1,14 +1,17 @@
1
1
  import * as t from '@babel/types';
2
2
  import { RULE_REGISTRY } from './rules-registry.js';
3
3
  import { countLogicalOperators } from './rules-helpers.js';
4
+ import {
5
+ isCustomHookFunction,
6
+ resolveStartLine,
7
+ isZeroDelayTimeout,
8
+ isUnguardedConsoleCall
9
+ } from './rules-predicates.js';
4
10
 
5
11
  export const createAstVisitors = ({ relativePath, violations }) => {
6
12
  return {
7
13
  Function(astPath) {
8
- const isCustomHook = (
9
- (astPath.node.id && /^use[A-Z0-9]/.test(astPath.node.id.name)) ||
10
- (astPath.parentPath?.node?.id && /^use[A-Z0-9]/.test(astPath.parentPath.node.id.name))
11
- );
14
+ const isCustomHook = isCustomHookFunction(astPath);
12
15
 
13
16
  // Pillar 3: Hook Saturation
14
17
  let hookCount = 0;
@@ -70,7 +73,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
70
73
  if (t.isLogicalExpression(expr) || t.isUnaryExpression(expr)) {
71
74
  const opCount = countLogicalOperators(expr);
72
75
  if (opCount > 2) {
73
- const line = expr.loc?.start.line || astPath.node.loc?.start.line || 1;
76
+ const line = resolveStartLine(expr, astPath.node, 1);
74
77
  const meta = RULE_REGISTRY.CONTROL_FLOW_INLINE_BOOLEAN;
75
78
  violations.push({
76
79
  filePath: relativePath,
@@ -154,7 +157,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
154
157
  const delayArg = args[1];
155
158
 
156
159
  // Render-hack check: setTimeout(fn, 0)
157
- if (callee.name === 'setTimeout' && delayArg && t.isNumericLiteral(delayArg) && delayArg.value === 0) {
160
+ if (isZeroDelayTimeout(callee, delayArg, t)) {
158
161
  const line = astPath.node.loc?.start.line || 1;
159
162
  const meta = RULE_REGISTRY.RENDER_HACK_TIMEOUT;
160
163
  violations.push({
@@ -198,13 +201,7 @@ export const createAstVisitors = ({ relativePath, violations }) => {
198
201
  }
199
202
 
200
203
  // Unguarded Logging
201
- if (
202
- t.isMemberExpression(callee) &&
203
- t.isIdentifier(callee.object) &&
204
- callee.object.name === 'console' &&
205
- t.isIdentifier(callee.property) &&
206
- ['log', 'info', 'warn'].includes(callee.property.name)
207
- ) {
204
+ if (isUnguardedConsoleCall(callee, t)) {
208
205
  const line = astPath.node.loc?.start.line || 1;
209
206
  const meta = RULE_REGISTRY.UNGUARDED_LOGGING;
210
207
  violations.push({
@@ -0,0 +1,359 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import * as t from '@babel/types';
4
+ import { RULE_REGISTRY } from './rules-registry.js';
5
+ import { toResultSync } from './rules-helpers.js';
6
+
7
+ const BARE_BOOLEANS = new Set([
8
+ 'loading', 'valid', 'active', 'visible', 'open', 'disabled',
9
+ 'checked', 'ready', 'editing', 'submitting', 'pending', 'busy',
10
+ 'selected', 'expanded'
11
+ ]);
12
+
13
+ const BARE_HANDLERS = new Set([
14
+ 'checkout', 'submit', 'save', 'delete', 'close', 'open',
15
+ 'reset', 'confirm', 'cancel', 'refresh', 'sync', 'send', 'pay'
16
+ ]);
17
+
18
+ const SECRET_PATTERNS = [
19
+ /(['"])(?:sk-[a-zA-Z0-9_-]{24,}|ghp_[a-zA-Z0-9]{30,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z-_]{35})\1/
20
+ ];
21
+
22
+ const FAKE_GREEN_PATTERNS = [
23
+ /expect\s*\(\s*(true|false|1|0)\s*\)\s*\.\s*to(?:Be|Equal|StrictEqual)\s*\(\s*\1\s*\)/,
24
+ /assert\s*\.\s*(?:strictEqual|equal|deepEqual)\s*\(\s*(true|false|1|0)\s*,\s*\1\s*\)/,
25
+ /assert\s*\.\s*ok\s*\(\s*(?:true|1)\s*\)/
26
+ ];
27
+
28
+ const IMG_WITHOUT_ALT_PATTERN = new RegExp(['<', 'img\\b', '(?![^>]*\\balt\\s*=)', '[^>]*>'].join(''), 'i');
29
+ const CLICKABLE_CONTAINER_PATTERN = new RegExp(['<', '(div|span)\\b', '(?![^>]*\\brole\\s*=\\s*[\'"](?:button|link|tab)[\'"])', '[^>]*\\b(?:@click|v-on:click)\\s*='].join(''), 'i');
30
+
31
+ /**
32
+ * Pillar 10: Check for co-located test files for molecule capsules.
33
+ */
34
+ const checkMoleculeCoLocatedTest = (filePath, relativePath, violations) => {
35
+ const baseName = path.basename(filePath);
36
+ const ext = path.extname(filePath);
37
+
38
+ // Stage 1: Atomic Concept Declarations
39
+ const isMoleculePath = relativePath.includes('molecules') || baseName.startsWith('m-');
40
+ const isComponentExt = ext === '.vue' || ext === '.tsx' || ext === '.jsx';
41
+ const isTestOrSpecFile = baseName.includes('.spec.') || baseName.includes('.test.');
42
+
43
+ // Stage 2: Unified Decision Variable & Early Guard Clause
44
+ const isMoleculeComponent = isMoleculePath && isComponentExt && !isTestOrSpecFile;
45
+ if (!isMoleculeComponent) return;
46
+
47
+ const dir = path.dirname(filePath);
48
+ const [siblings, dirError] = toResultSync(() => (fs.existsSync(dir) ? fs.readdirSync(dir) : []));
49
+
50
+ // Stage 1: Atomic Guard Check
51
+ const hasDirError = Boolean(dirError);
52
+ const hasSiblings = Boolean(siblings);
53
+ const canInspectSiblings = !hasDirError && hasSiblings;
54
+ if (!canInspectSiblings) return;
55
+
56
+ const isTestSibling = (fileName) => /\.(test|spec)\.[jt]sx?$/.test(fileName);
57
+ const hasCoLocatedTest = siblings.some(isTestSibling);
58
+ if (hasCoLocatedTest) return;
59
+
60
+ const meta = RULE_REGISTRY.TEST_MISSING_COLOCATED;
61
+ violations.push({
62
+ filePath: relativePath,
63
+ line: 1,
64
+ column: 1,
65
+ hazard: 'Molecule capsule missing co-located test file (*.spec.ts or *.test.ts)',
66
+ rule: 'TEST_MISSING_COLOCATED',
67
+ severity: meta.severity,
68
+ pillar: meta.pillar,
69
+ directive: meta.directive
70
+ });
71
+ };
72
+
73
+ export const checkExtendedTextPatterns = (content, lines, relativePath, filePath, violations) => {
74
+ checkMoleculeCoLocatedTest(filePath, relativePath, violations);
75
+
76
+ const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath);
77
+ const ext = path.extname(filePath);
78
+ const isTemplateFile = ext === '.vue' || ext === '.html' || ext === '.svelte';
79
+
80
+ lines.forEach((lineText, idx) => {
81
+ const lineNum = idx + 1;
82
+ const trimmed = lineText.trim();
83
+ const isComment = trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*');
84
+
85
+ // Pillar 9: Hardcoded Secret Detection
86
+ const hasEnvReference = trimmed.includes('process.env') || trimmed.includes('import.meta.env');
87
+ const canScanSecrets = !isComment && !hasEnvReference;
88
+
89
+ if (canScanSecrets) {
90
+ for (const pat of SECRET_PATTERNS) {
91
+ const matchesSecretPattern = pat.test(lineText);
92
+ const hasPlaceholder = lineText.includes('dummy') || lineText.includes('placeholder');
93
+ const isHardcodedSecret = matchesSecretPattern && !hasPlaceholder;
94
+
95
+ if (isHardcodedSecret) {
96
+ const meta = RULE_REGISTRY.SECURITY_HARDCODED_SECRET;
97
+ violations.push({
98
+ filePath: relativePath,
99
+ line: lineNum,
100
+ column: 1,
101
+ hazard: 'High-entropy API key or secret token detected in source code',
102
+ rule: 'SECURITY_HARDCODED_SECRET',
103
+ severity: meta.severity,
104
+ pillar: meta.pillar,
105
+ directive: meta.directive
106
+ });
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Pillar 9: Unsanitized v-html injection (Vue / HTML templates)
113
+ const hasVHtml = isTemplateFile && /\bv-html\s*=\s*["']/.test(lineText);
114
+ if (hasVHtml) {
115
+ const hasSanitizer = /(?:sanitize|DOMPurify|escapeHtml|filterXSS)/i.test(lineText);
116
+ const isUnsanitizedVHtml = !hasSanitizer;
117
+
118
+ if (isUnsanitizedVHtml) {
119
+ const meta = RULE_REGISTRY.SECURITY_RAW_HTML_INJECTION;
120
+ violations.push({
121
+ filePath: relativePath,
122
+ line: lineNum,
123
+ column: 1,
124
+ hazard: 'Unsanitized HTML injection via v-html detected without DOMPurify',
125
+ rule: 'SECURITY_RAW_HTML_INJECTION',
126
+ severity: meta.severity,
127
+ pillar: meta.pillar,
128
+ directive: meta.directive
129
+ });
130
+ }
131
+ }
132
+
133
+ // Pillar 8: Non-semantic clickable containers in templates (@click on div/span)
134
+ const hasClickableContainer = isTemplateFile && CLICKABLE_CONTAINER_PATTERN.test(lineText);
135
+ if (hasClickableContainer) {
136
+ const meta = RULE_REGISTRY.A11Y_CLICKABLE_NON_SEMANTIC;
137
+ violations.push({
138
+ filePath: relativePath,
139
+ line: lineNum,
140
+ column: 1,
141
+ hazard: 'Clickable generic container detected without native button/link semantics',
142
+ rule: 'A11Y_CLICKABLE_NON_SEMANTIC',
143
+ severity: meta.severity,
144
+ pillar: meta.pillar,
145
+ directive: meta.directive
146
+ });
147
+ }
148
+
149
+ // Pillar 8: Missing alt attribute on img tags
150
+ const hasMissingAltImg = isTemplateFile && IMG_WITHOUT_ALT_PATTERN.test(lineText);
151
+ if (hasMissingAltImg) {
152
+ const meta = RULE_REGISTRY.A11Y_IMAGE_MISSING_ALT;
153
+ violations.push({
154
+ filePath: relativePath,
155
+ line: lineNum,
156
+ column: 1,
157
+ hazard: 'Image element missing alt text attribute',
158
+ rule: 'A11Y_IMAGE_MISSING_ALT',
159
+ severity: meta.severity,
160
+ pillar: meta.pillar,
161
+ directive: meta.directive
162
+ });
163
+ }
164
+
165
+ // Pillar 10: Fake green tests (only in spec/test files)
166
+ const canCheckFakeGreen = isTestFile && !isComment;
167
+ if (canCheckFakeGreen) {
168
+ for (const pat of FAKE_GREEN_PATTERNS) {
169
+ const isFakeGreenMatch = pat.test(lineText);
170
+ if (isFakeGreenMatch) {
171
+ const meta = RULE_REGISTRY.TEST_FAKE_GREEN;
172
+ violations.push({
173
+ filePath: relativePath,
174
+ line: lineNum,
175
+ column: 1,
176
+ hazard: 'Fake green test assertion detected (asserting literal against itself)',
177
+ rule: 'TEST_FAKE_GREEN',
178
+ severity: meta.severity,
179
+ pillar: meta.pillar,
180
+ directive: meta.directive
181
+ });
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ });
187
+ };
188
+
189
+ export const createExtendedVisitors = ({ relativePath, violations }) => {
190
+ return {
191
+ JSXOpeningElement(astPath) {
192
+ const tagName = astPath.node.name?.name;
193
+
194
+ // Pillar 8: Clickable non-semantic JSX elements (div, span)
195
+ const isGenericContainer = tagName === 'div' || tagName === 'span';
196
+ if (isGenericContainer) {
197
+ const isClickAttr = (attr) => t.isJSXAttribute(attr) && attr.name?.name === 'onClick';
198
+ const isRoleAttr = (attr) => t.isJSXAttribute(attr) && attr.name?.name === 'role';
199
+ const hasClick = astPath.node.attributes.some(isClickAttr);
200
+ const hasRole = astPath.node.attributes.some(isRoleAttr);
201
+ const isClickableWithoutRole = hasClick && !hasRole;
202
+
203
+ if (isClickableWithoutRole) {
204
+ const line = astPath.node.loc?.start.line || 1;
205
+ const meta = RULE_REGISTRY.A11Y_CLICKABLE_NON_SEMANTIC;
206
+ violations.push({
207
+ filePath: relativePath,
208
+ line,
209
+ column: astPath.node.loc?.start.column || 1,
210
+ hazard: `Clickable non-semantic <${tagName}> detected without native button/link element`,
211
+ rule: 'A11Y_CLICKABLE_NON_SEMANTIC',
212
+ severity: meta.severity,
213
+ pillar: meta.pillar,
214
+ directive: meta.directive
215
+ });
216
+ }
217
+ }
218
+
219
+ // Pillar 8: Missing alt attribute on img JSX
220
+ const isImgElement = tagName === 'img';
221
+ if (isImgElement) {
222
+ const isAltAttr = (attr) => t.isJSXAttribute(attr) && attr.name?.name === 'alt';
223
+ const hasAlt = astPath.node.attributes.some(isAltAttr);
224
+ const isMissingAlt = !hasAlt;
225
+
226
+ if (isMissingAlt) {
227
+ const line = astPath.node.loc?.start.line || 1;
228
+ const meta = RULE_REGISTRY.A11Y_IMAGE_MISSING_ALT;
229
+ violations.push({
230
+ filePath: relativePath,
231
+ line,
232
+ column: astPath.node.loc?.start.column || 1,
233
+ hazard: 'Image element missing alt text attribute in JSX',
234
+ rule: 'A11Y_IMAGE_MISSING_ALT',
235
+ severity: meta.severity,
236
+ pillar: meta.pillar,
237
+ directive: meta.directive
238
+ });
239
+ }
240
+ }
241
+ },
242
+
243
+ JSXAttribute(astPath) {
244
+ const attrName = astPath.node.name?.name;
245
+
246
+ // Pillar 9: dangerouslySetInnerHTML without sanitizer
247
+ const isDangerouslySetHtml = attrName === 'dangerouslySetInnerHTML';
248
+ if (isDangerouslySetHtml) {
249
+ const value = astPath.node.value;
250
+ let isSanitized = false;
251
+
252
+ if (t.isJSXExpressionContainer(value)) {
253
+ const expr = value.expression;
254
+ if (t.isObjectExpression(expr)) {
255
+ for (const prop of expr.properties) {
256
+ const isHtmlProp = t.isObjectProperty(prop) && prop.key?.name === '__html';
257
+ if (isHtmlProp && t.isCallExpression(prop.value)) {
258
+ const calleeName = prop.value.callee?.name || prop.value.callee?.property?.name || '';
259
+ const hasSanitizeCallee = /sanitize/i.test(calleeName);
260
+ if (hasSanitizeCallee) isSanitized = true;
261
+ }
262
+ }
263
+ }
264
+ }
265
+
266
+ const isUnsanitized = !isSanitized;
267
+ if (isUnsanitized) {
268
+ const line = astPath.node.loc?.start.line || 1;
269
+ const meta = RULE_REGISTRY.SECURITY_RAW_HTML_INJECTION;
270
+ violations.push({
271
+ filePath: relativePath,
272
+ line,
273
+ column: astPath.node.loc?.start.column || 1,
274
+ hazard: 'Unsanitized HTML injection via dangerouslySetInnerHTML without DOMPurify',
275
+ rule: 'SECURITY_RAW_HTML_INJECTION',
276
+ severity: meta.severity,
277
+ pillar: meta.pillar,
278
+ directive: meta.directive
279
+ });
280
+ }
281
+ }
282
+
283
+ // Pillar 11: Action handler naming (e.g. onClick={checkout} instead of onClick={handleCheckout})
284
+ const isActionHandler = attrName === 'onClick' || attrName === 'onSubmit';
285
+ if (isActionHandler) {
286
+ const value = astPath.node.value;
287
+ const isContainerExpr = t.isJSXExpressionContainer(value);
288
+ const isIdentifierExpr = isContainerExpr && t.isIdentifier(value.expression);
289
+
290
+ if (isIdentifierExpr) {
291
+ const fnName = value.expression.name;
292
+ const isBareHandler = BARE_HANDLERS.has(fnName.toLowerCase());
293
+
294
+ if (isBareHandler) {
295
+ const line = astPath.node.loc?.start.line || 1;
296
+ const meta = RULE_REGISTRY.NAMING_HANDLER_PREFIX;
297
+ const suggested = `handle${fnName.charAt(0).toUpperCase()}${fnName.slice(1)}`;
298
+ violations.push({
299
+ filePath: relativePath,
300
+ line,
301
+ column: astPath.node.loc?.start.column || 1,
302
+ hazard: `Action handler "${fnName}" missing handle prefix (e.g. ${suggested})`,
303
+ rule: 'NAMING_HANDLER_PREFIX',
304
+ severity: meta.severity,
305
+ pillar: meta.pillar,
306
+ directive: meta.directive
307
+ });
308
+ }
309
+ }
310
+ }
311
+ },
312
+
313
+ VariableDeclarator(astPath) {
314
+ // Pillar 11: Bare boolean variables missing is/has/can/should prefix
315
+ const isIdentifierNode = t.isIdentifier(astPath.node.id);
316
+ const hasInitNode = Boolean(astPath.node.init);
317
+ const canInspectDeclarator = isIdentifierNode && hasInitNode;
318
+
319
+ if (canInspectDeclarator) {
320
+ const varName = astPath.node.id.name;
321
+ const isBareBoolean = BARE_BOOLEANS.has(varName.toLowerCase());
322
+
323
+ if (isBareBoolean) {
324
+ let isBooleanInit = false;
325
+ const init = astPath.node.init;
326
+
327
+ if (t.isBooleanLiteral(init)) {
328
+ isBooleanInit = true;
329
+ } else if (t.isCallExpression(init)) {
330
+ const callee = init.callee;
331
+ const calleeName = t.isIdentifier(callee) ? callee.name : '';
332
+ const isRefOrStateHook = calleeName === 'ref' || calleeName === 'useState';
333
+
334
+ if (isRefOrStateHook) {
335
+ const arg = init.arguments[0];
336
+ const isBoolLiteral = t.isBooleanLiteral(arg);
337
+ if (isBoolLiteral) isBooleanInit = true;
338
+ }
339
+ }
340
+
341
+ if (isBooleanInit) {
342
+ const line = astPath.node.loc?.start.line || 1;
343
+ const meta = RULE_REGISTRY.NAMING_BARE_BOOLEAN;
344
+ violations.push({
345
+ filePath: relativePath,
346
+ line,
347
+ column: astPath.node.loc?.start.column || 1,
348
+ hazard: `Bare boolean variable "${varName}" missing is/has/can/should prefix`,
349
+ rule: 'NAMING_BARE_BOOLEAN',
350
+ severity: meta.severity,
351
+ pillar: meta.pillar,
352
+ directive: meta.directive
353
+ });
354
+ }
355
+ }
356
+ }
357
+ }
358
+ };
359
+ };