spaghetti-slicer 0.1.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/dist/index.js ADDED
@@ -0,0 +1,1158 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/cli/index.ts
27
+ var import_commander = require("commander");
28
+ var import_ora = __toESM(require("ora"));
29
+ var fs2 = __toESM(require("fs"));
30
+
31
+ // src/engine/walker.ts
32
+ var import_glob = require("glob");
33
+ var path = __toESM(require("path"));
34
+ var fs = __toESM(require("fs"));
35
+ var IGNORE_PATTERNS = [
36
+ "**/node_modules/**",
37
+ "**/.next/**",
38
+ "**/dist/**",
39
+ "**/build/**",
40
+ "**/coverage/**",
41
+ "**/.git/**"
42
+ ];
43
+ function walkFiles(rootPath) {
44
+ const absoluteRoot = path.resolve(rootPath);
45
+ const stat = fs.statSync(absoluteRoot);
46
+ if (stat.isFile()) {
47
+ return [absoluteRoot];
48
+ }
49
+ const files = (0, import_glob.globSync)("**/*.{ts,tsx,js,jsx}", {
50
+ cwd: absoluteRoot,
51
+ absolute: true,
52
+ ignore: IGNORE_PATTERNS
53
+ });
54
+ return files;
55
+ }
56
+
57
+ // src/engine/scorer.ts
58
+ var CATEGORY_WEIGHTS = {
59
+ architecture: 25,
60
+ react: 20,
61
+ typescript: 20,
62
+ a11y: 20,
63
+ performance: 15
64
+ };
65
+ var SEVERITY_DEDUCTIONS = {
66
+ critical: 10,
67
+ warning: 5,
68
+ info: 2
69
+ };
70
+ function scoreCategory(violations) {
71
+ let score = 100;
72
+ for (const v of violations) {
73
+ score -= SEVERITY_DEDUCTIONS[v.severity];
74
+ }
75
+ return Math.max(0, score);
76
+ }
77
+ function computeReport(violations, totalFiles, scannedFiles, durationMs) {
78
+ const categories = ["architecture", "react", "typescript", "a11y", "performance"];
79
+ const categoryScores = {};
80
+ for (const cat of categories) {
81
+ const catViolations = violations.filter((v) => v.category === cat);
82
+ categoryScores[cat] = scoreCategory(catViolations);
83
+ }
84
+ const totalScore = Math.round(
85
+ categories.reduce(
86
+ (sum, cat) => sum + categoryScores[cat] * (CATEGORY_WEIGHTS[cat] / 100),
87
+ 0
88
+ )
89
+ );
90
+ const criticalCount = violations.filter((v) => v.severity === "critical").length;
91
+ const warningCount = violations.filter((v) => v.severity === "warning").length;
92
+ const infoCount = violations.filter((v) => v.severity === "info").length;
93
+ return {
94
+ totalFiles,
95
+ scannedFiles,
96
+ totalViolations: violations.length,
97
+ criticalCount,
98
+ warningCount,
99
+ infoCount,
100
+ categoryScores,
101
+ totalScore,
102
+ violations,
103
+ durationMs
104
+ };
105
+ }
106
+
107
+ // src/engine/parser.ts
108
+ var import_parser = require("@typescript-eslint/parser");
109
+ function parseFile(fileContent, filePath) {
110
+ try {
111
+ return (0, import_parser.parse)(fileContent, {
112
+ loc: true,
113
+ range: true,
114
+ tokens: false,
115
+ comment: false,
116
+ ecmaFeatures: { jsx: true }
117
+ });
118
+ } catch {
119
+ console.warn(`[spaghetti-slicer] Failed to parse ${filePath}, skipping.`);
120
+ return null;
121
+ }
122
+ }
123
+ function findNodes(node, type) {
124
+ const results = [];
125
+ function visit(n) {
126
+ if (n.type === type) {
127
+ results.push(n);
128
+ }
129
+ for (const key of Object.keys(n)) {
130
+ if (key === "parent") continue;
131
+ const child = n[key];
132
+ if (child && typeof child === "object") {
133
+ if (Array.isArray(child)) {
134
+ for (const item of child) {
135
+ if (item && typeof item === "object" && "type" in item) {
136
+ visit(item);
137
+ }
138
+ }
139
+ } else if ("type" in child) {
140
+ visit(child);
141
+ }
142
+ }
143
+ }
144
+ }
145
+ visit(node);
146
+ return results;
147
+ }
148
+ function getNodeLine(node) {
149
+ return node.loc?.start.line ?? 0;
150
+ }
151
+ function getNodeColumn(node) {
152
+ return node.loc?.start.column ?? 0;
153
+ }
154
+
155
+ // src/rules/architecture/component-length.ts
156
+ var COMPONENT_MAX_LINES = 200;
157
+ function returnsJSX(node) {
158
+ const returns = findNodes(node, "ReturnStatement");
159
+ return returns.some((r) => {
160
+ if (!r.argument) return false;
161
+ const arg = r.argument;
162
+ return arg.type === "JSXElement" || arg.type === "JSXFragment" || arg.type === "ParenthesizedExpression" && (arg.expression.type === "JSXElement" || arg.expression.type === "JSXFragment");
163
+ });
164
+ }
165
+ function getFunctionName(node) {
166
+ if (node.type === "FunctionDeclaration") return node.id?.name ?? "Anonymous";
167
+ const parent = node.parent;
168
+ if (parent?.type === "VariableDeclarator") {
169
+ const id = parent.id;
170
+ if (id.type === "Identifier") return id.name;
171
+ }
172
+ return "Anonymous";
173
+ }
174
+ function attachParents(node, parent) {
175
+ ;
176
+ node.parent = parent;
177
+ for (const key of Object.keys(node)) {
178
+ if (key === "parent") continue;
179
+ const child = node[key];
180
+ if (child && typeof child === "object") {
181
+ if (Array.isArray(child)) {
182
+ for (const item of child) {
183
+ if (item && typeof item === "object" && "type" in item) {
184
+ attachParents(item, node);
185
+ }
186
+ }
187
+ } else if ("type" in child) {
188
+ attachParents(child, node);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ var componentLengthRule = {
194
+ id: "component-length",
195
+ name: "Component Length",
196
+ description: "React components should not exceed 200 lines.",
197
+ category: "architecture",
198
+ severity: "critical",
199
+ run(filePath, fileContent) {
200
+ const ast = parseFile(fileContent, filePath);
201
+ if (!ast) return [];
202
+ attachParents(ast);
203
+ const violations = [];
204
+ const funcDecls = findNodes(ast, "FunctionDeclaration");
205
+ for (const node of funcDecls) {
206
+ if (!returnsJSX(node)) continue;
207
+ const start = node.loc?.start.line ?? 0;
208
+ const end = node.loc?.end.line ?? 0;
209
+ const lines = end - start + 1;
210
+ if (lines > COMPONENT_MAX_LINES) {
211
+ const name = getFunctionName(node);
212
+ violations.push({
213
+ ruleId: "component-length",
214
+ message: `Component '${name}' is ${lines} lines. Break it into smaller components.`,
215
+ filePath,
216
+ line: getNodeLine(node),
217
+ severity: "critical",
218
+ category: "architecture"
219
+ });
220
+ }
221
+ }
222
+ const arrowFns = findNodes(ast, "ArrowFunctionExpression");
223
+ for (const node of arrowFns) {
224
+ if (!returnsJSX(node)) continue;
225
+ const start = node.loc?.start.line ?? 0;
226
+ const end = node.loc?.end.line ?? 0;
227
+ const lines = end - start + 1;
228
+ if (lines > COMPONENT_MAX_LINES) {
229
+ const name = getFunctionName(node);
230
+ violations.push({
231
+ ruleId: "component-length",
232
+ message: `Component '${name}' is ${lines} lines. Break it into smaller components.`,
233
+ filePath,
234
+ line: getNodeLine(node),
235
+ severity: "critical",
236
+ category: "architecture"
237
+ });
238
+ }
239
+ }
240
+ return violations;
241
+ }
242
+ };
243
+
244
+ // src/rules/architecture/business-logic-in-jsx.ts
245
+ var TRANSFORM_METHODS = ["filter", "reduce", "sort", "flatMap", "find", "findIndex"];
246
+ function isTransformCall(node) {
247
+ const callee = node.callee;
248
+ if (callee.type !== "MemberExpression") return false;
249
+ const prop = callee.property;
250
+ return prop.type === "Identifier" && TRANSFORM_METHODS.includes(prop.name);
251
+ }
252
+ function countTernaryNesting(node) {
253
+ let depth = 1;
254
+ let current = node;
255
+ while (current.type === "ConditionalExpression" && current.consequent.type === "ConditionalExpression") {
256
+ depth++;
257
+ current = current.consequent;
258
+ }
259
+ return depth;
260
+ }
261
+ var businessLogicInJSXRule = {
262
+ id: "business-logic-in-jsx",
263
+ name: "Business Logic in JSX",
264
+ description: "Data transformations should be moved out of JSX expressions.",
265
+ category: "architecture",
266
+ severity: "warning",
267
+ run(filePath, fileContent) {
268
+ const ast = parseFile(fileContent, filePath);
269
+ if (!ast) return [];
270
+ const violations = [];
271
+ const containers = findNodes(
272
+ ast,
273
+ "JSXExpressionContainer"
274
+ );
275
+ for (const container of containers) {
276
+ const calls = findNodes(container, "CallExpression");
277
+ for (const call of calls) {
278
+ if (isTransformCall(call)) {
279
+ violations.push({
280
+ ruleId: "business-logic-in-jsx",
281
+ message: "Inline data transformation found in JSX. Move to a variable or custom hook.",
282
+ filePath,
283
+ line: getNodeLine(call),
284
+ column: getNodeColumn(call),
285
+ severity: "warning",
286
+ category: "architecture"
287
+ });
288
+ break;
289
+ }
290
+ }
291
+ const ternaries = findNodes(
292
+ container,
293
+ "ConditionalExpression"
294
+ );
295
+ for (const ternary of ternaries) {
296
+ if (countTernaryNesting(ternary) > 2) {
297
+ violations.push({
298
+ ruleId: "business-logic-in-jsx",
299
+ message: "Inline data transformation found in JSX. Move to a variable or custom hook.",
300
+ filePath,
301
+ line: getNodeLine(ternary),
302
+ column: getNodeColumn(ternary),
303
+ severity: "warning",
304
+ category: "architecture"
305
+ });
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ return violations;
311
+ }
312
+ };
313
+
314
+ // src/rules/architecture/direct-fetch-in-component.ts
315
+ function isCustomHookOrService(filePath) {
316
+ const lower = filePath.toLowerCase();
317
+ return lower.includes("/hooks/") || lower.includes("/services/") || lower.includes("/api/") || lower.includes(".service.") || lower.includes(".hook.");
318
+ }
319
+ function getFunctionName2(node) {
320
+ if (node.type === "FunctionDeclaration") {
321
+ return node.id?.name ?? null;
322
+ }
323
+ if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression") {
324
+ const parent = node.parent;
325
+ if (parent?.type === "VariableDeclarator") {
326
+ const id = parent.id;
327
+ if (id.type === "Identifier") return id.name;
328
+ }
329
+ }
330
+ return null;
331
+ }
332
+ function isComponentFunction(node) {
333
+ const name = getFunctionName2(node);
334
+ if (!name) return false;
335
+ return /^[A-Z]/.test(name);
336
+ }
337
+ function attachParents2(node, parent) {
338
+ ;
339
+ node.parent = parent;
340
+ for (const key of Object.keys(node)) {
341
+ if (key === "parent") continue;
342
+ const child = node[key];
343
+ if (child && typeof child === "object") {
344
+ if (Array.isArray(child)) {
345
+ for (const item of child) {
346
+ if (item && typeof item === "object" && "type" in item) {
347
+ attachParents2(item, node);
348
+ }
349
+ }
350
+ } else if ("type" in child) {
351
+ attachParents2(child, node);
352
+ }
353
+ }
354
+ }
355
+ }
356
+ function getAncestorComponentFunction(node) {
357
+ let current = node.parent;
358
+ while (current) {
359
+ if (current.type === "FunctionDeclaration" || current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
360
+ if (isComponentFunction(current)) return current;
361
+ }
362
+ current = current.parent;
363
+ }
364
+ return null;
365
+ }
366
+ var directFetchInComponentRule = {
367
+ id: "direct-fetch-in-component",
368
+ name: "Direct Fetch in Component",
369
+ description: "fetch/axios calls should not be made directly inside components.",
370
+ category: "architecture",
371
+ severity: "critical",
372
+ run(filePath, fileContent) {
373
+ if (isCustomHookOrService(filePath)) return [];
374
+ const ast = parseFile(fileContent, filePath);
375
+ if (!ast) return [];
376
+ attachParents2(ast);
377
+ const violations = [];
378
+ const calls = findNodes(ast, "CallExpression");
379
+ for (const call of calls) {
380
+ const callee = call.callee;
381
+ let isFetchCall = false;
382
+ if (callee.type === "Identifier" && callee.name === "fetch") {
383
+ isFetchCall = true;
384
+ }
385
+ if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.object.name === "axios") {
386
+ isFetchCall = true;
387
+ }
388
+ if (callee.type === "Identifier" && callee.name === "axios") {
389
+ isFetchCall = true;
390
+ }
391
+ if (!isFetchCall) continue;
392
+ const componentAncestor = getAncestorComponentFunction(call);
393
+ if (!componentAncestor) continue;
394
+ violations.push({
395
+ ruleId: "direct-fetch-in-component",
396
+ message: "Direct fetch() call found in component. Move to a custom hook or service layer.",
397
+ filePath,
398
+ line: getNodeLine(call),
399
+ column: getNodeColumn(call),
400
+ severity: "critical",
401
+ category: "architecture"
402
+ });
403
+ }
404
+ return violations;
405
+ }
406
+ };
407
+
408
+ // src/rules/architecture/state-bloat.ts
409
+ var MAX_STATE_HOOKS = 5;
410
+ function returnsJSX2(node) {
411
+ const returns = findNodes(node, "ReturnStatement");
412
+ return returns.some((r) => {
413
+ if (!r.argument) return false;
414
+ const arg = r.argument;
415
+ return arg.type === "JSXElement" || arg.type === "JSXFragment" || arg.type === "ParenthesizedExpression" && (arg.expression.type === "JSXElement" || arg.expression.type === "JSXFragment");
416
+ });
417
+ }
418
+ function getFunctionName3(node) {
419
+ if (node.type === "FunctionDeclaration") return node.id?.name ?? "Anonymous";
420
+ const parent = node.parent;
421
+ if (parent?.type === "VariableDeclarator") {
422
+ const id = parent.id;
423
+ if (id.type === "Identifier") return id.name;
424
+ }
425
+ return "Anonymous";
426
+ }
427
+ function attachParents3(node, parent) {
428
+ ;
429
+ node.parent = parent;
430
+ for (const key of Object.keys(node)) {
431
+ if (key === "parent") continue;
432
+ const child = node[key];
433
+ if (child && typeof child === "object") {
434
+ if (Array.isArray(child)) {
435
+ for (const item of child) {
436
+ if (item && typeof item === "object" && "type" in item) {
437
+ attachParents3(item, node);
438
+ }
439
+ }
440
+ } else if ("type" in child) {
441
+ attachParents3(child, node);
442
+ }
443
+ }
444
+ }
445
+ }
446
+ function isuseStateCall(node) {
447
+ const callee = node.callee;
448
+ if (callee.type === "Identifier" && callee.name === "useState") {
449
+ return true;
450
+ }
451
+ if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.object.name === "React" && callee.property.type === "Identifier" && callee.property.name === "useState") {
452
+ return true;
453
+ }
454
+ return false;
455
+ }
456
+ function isDirectHookInComponent(node, componentNode) {
457
+ let current = node.parent;
458
+ while (current) {
459
+ if (current === componentNode) return true;
460
+ if (current.type === "FunctionDeclaration" || current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
461
+ return false;
462
+ }
463
+ current = current.parent;
464
+ }
465
+ return false;
466
+ }
467
+ var stateBloatRule = {
468
+ id: "state-bloat",
469
+ name: "State Bloat",
470
+ description: "Components should not contain more than 5 useState hooks.",
471
+ category: "architecture",
472
+ severity: "warning",
473
+ run(filePath, fileContent) {
474
+ const ast = parseFile(fileContent, filePath);
475
+ if (!ast) return [];
476
+ attachParents3(ast);
477
+ const violations = [];
478
+ const checkComponent = (node) => {
479
+ if (!returnsJSX2(node)) return;
480
+ const alluseStateCalls = findNodes(node, "CallExpression").filter(isuseStateCall).filter((call) => isDirectHookInComponent(call, node));
481
+ const count = alluseStateCalls.length;
482
+ if (count > MAX_STATE_HOOKS) {
483
+ const name = getFunctionName3(node);
484
+ violations.push({
485
+ ruleId: "state-bloat",
486
+ message: `Component '${name}' contains ${count} useState hooks. Combine them into a single useReducer or group them into custom hooks.`,
487
+ filePath,
488
+ line: getNodeLine(node),
489
+ severity: "warning",
490
+ category: "architecture"
491
+ });
492
+ }
493
+ };
494
+ const funcDecls = findNodes(ast, "FunctionDeclaration");
495
+ for (const node of funcDecls) {
496
+ checkComponent(node);
497
+ }
498
+ const arrowFns = findNodes(ast, "ArrowFunctionExpression");
499
+ for (const node of arrowFns) {
500
+ checkComponent(node);
501
+ }
502
+ return violations;
503
+ }
504
+ };
505
+
506
+ // src/rules/architecture/hardcoded-secrets-endpoints.ts
507
+ var URL_REGEX = /^(https?|wss?):\/\/[^\s'"`{}()]+/;
508
+ var SECRET_KEY_REGEX = /(api_?key|secret|password|token|credential)/i;
509
+ var PLACEHOLDER_REGEX = /^(TODO|placeholder|dummy|test|your[-_]api[-_]key|your[-_]secret|your[-_]token|your[-_]password|enter[-_]here|x+)/i;
510
+ function attachParents4(node, parent) {
511
+ ;
512
+ node.parent = parent;
513
+ for (const key of Object.keys(node)) {
514
+ if (key === "parent") continue;
515
+ const child = node[key];
516
+ if (child && typeof child === "object") {
517
+ if (Array.isArray(child)) {
518
+ for (const item of child) {
519
+ if (item && typeof item === "object" && "type" in item) {
520
+ attachParents4(item, node);
521
+ }
522
+ }
523
+ } else if ("type" in child) {
524
+ attachParents4(child, node);
525
+ }
526
+ }
527
+ }
528
+ }
529
+ function getVariableName(node) {
530
+ const parent = node.parent;
531
+ if (!parent) return null;
532
+ if (parent.type === "VariableDeclarator") {
533
+ if (parent.id.type === "Identifier") {
534
+ return parent.id.name;
535
+ }
536
+ }
537
+ if (parent.type === "Property") {
538
+ if (parent.key.type === "Identifier") {
539
+ return parent.key.name;
540
+ }
541
+ if (parent.key.type === "Literal" && typeof parent.key.value === "string") {
542
+ return parent.key.value;
543
+ }
544
+ }
545
+ if (parent.type === "AssignmentExpression") {
546
+ if (parent.left.type === "Identifier") {
547
+ return parent.left.name;
548
+ }
549
+ if (parent.left.type === "MemberExpression") {
550
+ const prop = parent.left.property;
551
+ if (prop.type === "Identifier") {
552
+ return prop.name;
553
+ }
554
+ if (prop.type === "Literal" && typeof prop.value === "string") {
555
+ return prop.value;
556
+ }
557
+ }
558
+ }
559
+ return null;
560
+ }
561
+ var hardcodedSecretsEndpointsRule = {
562
+ id: "hardcoded-secrets-endpoints",
563
+ name: "Hardcoded Secrets and Endpoints",
564
+ description: "Do not hardcode raw URLs or secrets in the codebase.",
565
+ category: "architecture",
566
+ severity: "critical",
567
+ run(filePath, fileContent) {
568
+ const ast = parseFile(fileContent, filePath);
569
+ if (!ast) return [];
570
+ attachParents4(ast);
571
+ const violations = [];
572
+ const checkStringValue = (value, node) => {
573
+ const trimmed = value.trim();
574
+ if (URL_REGEX.test(trimmed)) {
575
+ violations.push({
576
+ ruleId: "hardcoded-secrets-endpoints",
577
+ message: `Hardcoded API endpoint found: '${trimmed}'. Extract to an environment variable.`,
578
+ filePath,
579
+ line: getNodeLine(node),
580
+ column: getNodeColumn(node),
581
+ severity: "critical",
582
+ category: "architecture"
583
+ });
584
+ return;
585
+ }
586
+ if (trimmed.length > 4 && !PLACEHOLDER_REGEX.test(trimmed)) {
587
+ const varName = getVariableName(node);
588
+ if (varName && SECRET_KEY_REGEX.test(varName)) {
589
+ violations.push({
590
+ ruleId: "hardcoded-secrets-endpoints",
591
+ message: `Potential hardcoded secret found in variable '${varName}'. Extract to an environment variable.`,
592
+ filePath,
593
+ line: getNodeLine(node),
594
+ column: getNodeColumn(node),
595
+ severity: "critical",
596
+ category: "architecture"
597
+ });
598
+ }
599
+ }
600
+ };
601
+ const literals = findNodes(ast, "Literal");
602
+ for (const node of literals) {
603
+ if (node.parent?.type === "Property" && node.parent.key === node) {
604
+ continue;
605
+ }
606
+ if (typeof node.value === "string") {
607
+ checkStringValue(node.value, node);
608
+ }
609
+ }
610
+ const templateLiterals = findNodes(ast, "TemplateLiteral");
611
+ for (const node of templateLiterals) {
612
+ if (node.quasis.length === 1) {
613
+ const value = node.quasis[0].value.cooked ?? "";
614
+ checkStringValue(value, node);
615
+ }
616
+ }
617
+ return violations;
618
+ }
619
+ };
620
+
621
+ // src/rules/react/index-as-key.ts
622
+ function attachParents5(node, parent) {
623
+ ;
624
+ node.parent = parent;
625
+ for (const key of Object.keys(node)) {
626
+ if (key === "parent") continue;
627
+ const child = node[key];
628
+ if (child && typeof child === "object") {
629
+ if (Array.isArray(child)) {
630
+ for (const item of child) {
631
+ if (item && typeof item === "object" && "type" in item) {
632
+ attachParents5(item, node);
633
+ }
634
+ }
635
+ } else if ("type" in child) {
636
+ attachParents5(child, node);
637
+ }
638
+ }
639
+ }
640
+ }
641
+ function getMapIndexParam(callExpr) {
642
+ const callee = callExpr.callee;
643
+ if (callee.type !== "MemberExpression") return null;
644
+ const prop = callee.property;
645
+ if (prop.type !== "Identifier" || prop.name !== "map") return null;
646
+ const args = callExpr.arguments;
647
+ if (!args.length) return null;
648
+ const callback = args[0];
649
+ if (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")
650
+ return null;
651
+ if (callback.params.length < 2) return null;
652
+ const indexParam = callback.params[1];
653
+ if (indexParam.type === "Identifier") return indexParam.name;
654
+ return null;
655
+ }
656
+ var indexAsKeyRule = {
657
+ id: "index-as-key",
658
+ name: "Index as Key",
659
+ description: "Array index should not be used as a React key prop.",
660
+ category: "react",
661
+ severity: "critical",
662
+ run(filePath, fileContent) {
663
+ const ast = parseFile(fileContent, filePath);
664
+ if (!ast) return [];
665
+ attachParents5(ast);
666
+ const violations = [];
667
+ const callExprs = findNodes(ast, "CallExpression");
668
+ for (const call of callExprs) {
669
+ const indexParam = getMapIndexParam(call);
670
+ if (!indexParam) continue;
671
+ const jsxAttrs = findNodes(call, "JSXAttribute");
672
+ for (const attr of jsxAttrs) {
673
+ if (attr.name.type !== "JSXIdentifier" || attr.name.name !== "key") continue;
674
+ const val = attr.value;
675
+ if (val?.type === "JSXExpressionContainer" && val.expression.type === "Identifier" && val.expression.name === indexParam) {
676
+ violations.push({
677
+ ruleId: "index-as-key",
678
+ message: "Array index used as key prop. Use a stable unique identifier instead.",
679
+ filePath,
680
+ line: getNodeLine(attr),
681
+ column: getNodeColumn(attr),
682
+ severity: "critical",
683
+ category: "react"
684
+ });
685
+ }
686
+ }
687
+ }
688
+ return violations;
689
+ }
690
+ };
691
+
692
+ // src/rules/react/missing-error-boundary.ts
693
+ function isErrorBoundaryClass(node) {
694
+ if (!node.superClass) return false;
695
+ const methods = findNodes(node, "MethodDefinition");
696
+ return methods.some((m) => {
697
+ const key = m.key;
698
+ if (key.type !== "Identifier") return false;
699
+ return key.name === "componentDidCatch" || key.name === "getDerivedStateFromError";
700
+ });
701
+ }
702
+ var hasErrorBoundary = false;
703
+ var firstFilePath = "";
704
+ var firstCheckDone = false;
705
+ function resetErrorBoundaryState() {
706
+ hasErrorBoundary = false;
707
+ firstFilePath = "";
708
+ firstCheckDone = false;
709
+ }
710
+ var missingErrorBoundaryRule = {
711
+ id: "missing-error-boundary",
712
+ name: "Missing Error Boundary",
713
+ description: "Codebase should have at least one error boundary component.",
714
+ category: "react",
715
+ severity: "warning",
716
+ run(filePath, fileContent) {
717
+ if (!firstCheckDone) {
718
+ firstFilePath = filePath;
719
+ firstCheckDone = true;
720
+ }
721
+ const ast = parseFile(fileContent, filePath);
722
+ if (!ast) return [];
723
+ const classes = findNodes(ast, "ClassDeclaration");
724
+ if (classes.some(isErrorBoundaryClass)) {
725
+ hasErrorBoundary = true;
726
+ }
727
+ return [];
728
+ }
729
+ };
730
+ function finalizeErrorBoundaryViolations() {
731
+ if (!hasErrorBoundary && firstFilePath) {
732
+ return [
733
+ {
734
+ ruleId: "missing-error-boundary",
735
+ message: "No error boundary found in codebase. Wrap route-level components with an error boundary.",
736
+ filePath: firstFilePath,
737
+ line: 1,
738
+ severity: "warning",
739
+ category: "react"
740
+ }
741
+ ];
742
+ }
743
+ return [];
744
+ }
745
+
746
+ // src/rules/react/no-sub-renders.ts
747
+ var RENDER_NAME_REGEX = /^render([A-Z].*)?$/;
748
+ function returnsJSX3(node) {
749
+ const returns = findNodes(node, "ReturnStatement");
750
+ return returns.some((r) => {
751
+ if (!r.argument) return false;
752
+ const arg = r.argument;
753
+ return arg.type === "JSXElement" || arg.type === "JSXFragment" || arg.type === "ParenthesizedExpression" && (arg.expression.type === "JSXElement" || arg.expression.type === "JSXFragment");
754
+ });
755
+ }
756
+ function attachParents6(node, parent) {
757
+ ;
758
+ node.parent = parent;
759
+ for (const key of Object.keys(node)) {
760
+ if (key === "parent") continue;
761
+ const child = node[key];
762
+ if (child && typeof child === "object") {
763
+ if (Array.isArray(child)) {
764
+ for (const item of child) {
765
+ if (item && typeof item === "object" && "type" in item) {
766
+ attachParents6(item, node);
767
+ }
768
+ }
769
+ } else if ("type" in child) {
770
+ attachParents6(child, node);
771
+ }
772
+ }
773
+ }
774
+ }
775
+ function getEnclosingComponent(node) {
776
+ let current = node.parent;
777
+ while (current) {
778
+ if (current.type === "FunctionDeclaration" || current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
779
+ if (returnsJSX3(current)) {
780
+ return current;
781
+ }
782
+ }
783
+ current = current.parent;
784
+ }
785
+ return null;
786
+ }
787
+ function getFunctionName4(node) {
788
+ if (node.type === "FunctionDeclaration") {
789
+ return node.id?.name ?? null;
790
+ }
791
+ const parent = node.parent;
792
+ if (parent?.type === "VariableDeclarator") {
793
+ if (parent.id.type === "Identifier") {
794
+ return parent.id.name;
795
+ }
796
+ }
797
+ return null;
798
+ }
799
+ var noSubRendersRule = {
800
+ id: "no-sub-renders",
801
+ name: "No Sub-Renders",
802
+ description: "Helper rendering functions should not be defined inside a component body.",
803
+ category: "react",
804
+ severity: "warning",
805
+ run(filePath, fileContent) {
806
+ const ast = parseFile(fileContent, filePath);
807
+ if (!ast) return [];
808
+ attachParents6(ast);
809
+ const violations = [];
810
+ const checkFunctionNode = (node) => {
811
+ const parentComp = getEnclosingComponent(node);
812
+ if (!parentComp) return;
813
+ const name = getFunctionName4(node);
814
+ if (!name) return;
815
+ const isRenderName = RENDER_NAME_REGEX.test(name);
816
+ const doesReturnJSX = returnsJSX3(node);
817
+ if (isRenderName || doesReturnJSX) {
818
+ violations.push({
819
+ ruleId: "no-sub-renders",
820
+ message: `Helper rendering function '${name}' defined inside component body. Extract it into a standalone React component.`,
821
+ filePath,
822
+ line: getNodeLine(node),
823
+ column: getNodeColumn(node),
824
+ severity: "warning",
825
+ category: "react"
826
+ });
827
+ }
828
+ };
829
+ const funcDecls = findNodes(ast, "FunctionDeclaration");
830
+ for (const node of funcDecls) {
831
+ checkFunctionNode(node);
832
+ }
833
+ const arrowFns = findNodes(ast, "ArrowFunctionExpression");
834
+ for (const node of arrowFns) {
835
+ checkFunctionNode(node);
836
+ }
837
+ const funcExprs = findNodes(ast, "FunctionExpression");
838
+ for (const node of funcExprs) {
839
+ checkFunctionNode(node);
840
+ }
841
+ return violations;
842
+ }
843
+ };
844
+
845
+ // src/rules/react/fat-controller.ts
846
+ var MIN_COMPONENT_LINES = 20;
847
+ var MAX_LOGIC_RATIO = 0.75;
848
+ function isJSXReturn(r) {
849
+ if (!r.argument) return false;
850
+ const arg = r.argument;
851
+ return arg.type === "JSXElement" || arg.type === "JSXFragment" || arg.type === "ParenthesizedExpression" && (arg.expression.type === "JSXElement" || arg.expression.type === "JSXFragment");
852
+ }
853
+ function returnsJSX4(node) {
854
+ const returns = findNodes(node, "ReturnStatement");
855
+ return returns.some(isJSXReturn);
856
+ }
857
+ function getFunctionName5(node) {
858
+ if (node.type === "FunctionDeclaration") return node.id?.name ?? "Anonymous";
859
+ const parent = node.parent;
860
+ if (parent?.type === "VariableDeclarator") {
861
+ const id = parent.id;
862
+ if (id.type === "Identifier") return id.name;
863
+ }
864
+ return "Anonymous";
865
+ }
866
+ function attachParents7(node, parent) {
867
+ ;
868
+ node.parent = parent;
869
+ for (const key of Object.keys(node)) {
870
+ if (key === "parent") continue;
871
+ const child = node[key];
872
+ if (child && typeof child === "object") {
873
+ if (Array.isArray(child)) {
874
+ for (const item of child) {
875
+ if (item && typeof item === "object" && "type" in item) {
876
+ attachParents7(item, node);
877
+ }
878
+ }
879
+ } else if ("type" in child) {
880
+ attachParents7(child, node);
881
+ }
882
+ }
883
+ }
884
+ }
885
+ function isDirectReturnOf(node, componentNode) {
886
+ let current = node.parent;
887
+ while (current) {
888
+ if (current === componentNode) return true;
889
+ if (current.type === "FunctionDeclaration" || current.type === "ArrowFunctionExpression" || current.type === "FunctionExpression") {
890
+ return false;
891
+ }
892
+ current = current.parent;
893
+ }
894
+ return false;
895
+ }
896
+ var fatControllerRule = {
897
+ id: "fat-controller",
898
+ name: "Fat Controller",
899
+ description: "Component body logic should not exceed 75% of the total component size.",
900
+ category: "react",
901
+ severity: "warning",
902
+ run(filePath, fileContent) {
903
+ const ast = parseFile(fileContent, filePath);
904
+ if (!ast) return [];
905
+ attachParents7(ast);
906
+ const violations = [];
907
+ const checkComponent = (node) => {
908
+ if (!returnsJSX4(node)) return;
909
+ const start = node.loc?.start.line ?? 0;
910
+ const end = node.loc?.end.line ?? 0;
911
+ const totalLines = end - start + 1;
912
+ if (totalLines <= MIN_COMPONENT_LINES) return;
913
+ const directReturns = findNodes(node, "ReturnStatement").filter((r) => isDirectReturnOf(r, node)).filter(isJSXReturn);
914
+ if (directReturns.length === 0) return;
915
+ const earliestReturnLine = Math.min(...directReturns.map((r) => r.loc?.start.line ?? 0));
916
+ const logicLines = earliestReturnLine - start;
917
+ const ratio = logicLines / totalLines;
918
+ if (ratio > MAX_LOGIC_RATIO) {
919
+ const name = getFunctionName5(node);
920
+ violations.push({
921
+ ruleId: "fat-controller",
922
+ message: `Component '${name}' body has ${Math.round(ratio * 100)}% JavaScript logic code and less than 25% JSX return markup. Extract logic into a custom hook.`,
923
+ filePath,
924
+ line: getNodeLine(node),
925
+ severity: "warning",
926
+ category: "react"
927
+ });
928
+ }
929
+ };
930
+ const funcDecls = findNodes(ast, "FunctionDeclaration");
931
+ for (const node of funcDecls) {
932
+ checkComponent(node);
933
+ }
934
+ const arrowFns = findNodes(ast, "ArrowFunctionExpression");
935
+ for (const node of arrowFns) {
936
+ checkComponent(node);
937
+ }
938
+ return violations;
939
+ }
940
+ };
941
+
942
+ // src/rules/performance/image-missing-dimensions.ts
943
+ var IMAGE_TAGS = ["img", "Image"];
944
+ var imageMissingDimensionsRule = {
945
+ id: "image-missing-dimensions",
946
+ name: "Image Missing Dimensions",
947
+ description: "<img> elements should have width and height to prevent CLS.",
948
+ category: "performance",
949
+ severity: "warning",
950
+ run(filePath, fileContent) {
951
+ const ast = parseFile(fileContent, filePath);
952
+ if (!ast) return [];
953
+ const violations = [];
954
+ const elements = findNodes(ast, "JSXOpeningElement");
955
+ for (const el of elements) {
956
+ const name = el.name;
957
+ if (name.type !== "JSXIdentifier") continue;
958
+ if (!IMAGE_TAGS.includes(name.name)) continue;
959
+ const attrs = el.attributes;
960
+ const hasWidth = attrs.some(
961
+ (a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "width"
962
+ );
963
+ const hasHeight = attrs.some(
964
+ (a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "height"
965
+ );
966
+ if (!hasWidth || !hasHeight) {
967
+ violations.push({
968
+ ruleId: "image-missing-dimensions",
969
+ message: "<img> element missing width/height attributes. This causes Cumulative Layout Shift (CLS).",
970
+ filePath,
971
+ line: getNodeLine(el),
972
+ column: getNodeColumn(el),
973
+ severity: "warning",
974
+ category: "performance"
975
+ });
976
+ }
977
+ }
978
+ return violations;
979
+ }
980
+ };
981
+
982
+ // src/rules/index.ts
983
+ var allRules = [
984
+ componentLengthRule,
985
+ businessLogicInJSXRule,
986
+ directFetchInComponentRule,
987
+ stateBloatRule,
988
+ hardcodedSecretsEndpointsRule,
989
+ indexAsKeyRule,
990
+ missingErrorBoundaryRule,
991
+ noSubRendersRule,
992
+ fatControllerRule,
993
+ imageMissingDimensionsRule
994
+ ];
995
+
996
+ // src/reporter/console.ts
997
+ var import_chalk = __toESM(require("chalk"));
998
+ var import_boxen = __toESM(require("boxen"));
999
+ var CATEGORY_LABELS = {
1000
+ architecture: "Architecture",
1001
+ react: "React",
1002
+ typescript: "TypeScript",
1003
+ a11y: "Accessibility",
1004
+ performance: "Performance"
1005
+ };
1006
+ function scoreBar(score) {
1007
+ const filled = Math.round(score / 10);
1008
+ const empty = 10 - filled;
1009
+ return import_chalk.default.green("\u2588".repeat(filled)) + import_chalk.default.gray("\u2591".repeat(empty));
1010
+ }
1011
+ function scoreLabel(score) {
1012
+ if (score >= 90) return import_chalk.default.green("\u2705 Excellent");
1013
+ if (score >= 75) return import_chalk.default.green("\u2705 Good");
1014
+ if (score >= 60) return import_chalk.default.yellow("\u26A0\uFE0F Needs Work");
1015
+ return import_chalk.default.red("\u274C Poor");
1016
+ }
1017
+ function printReport(report) {
1018
+ console.log(
1019
+ (0, import_boxen.default)(
1020
+ import_chalk.default.bold.cyan(" spaghetti-slicer v0.1.0") + "\n Scanning React/TS codebase...",
1021
+ { padding: 1, borderStyle: "round", borderColor: "cyan" }
1022
+ )
1023
+ );
1024
+ const duration = (report.durationMs / 1e3).toFixed(1);
1025
+ console.log(
1026
+ import_chalk.default.green("\u2714") + ` ${report.scannedFiles} files scanned in ${duration}s
1027
+ `
1028
+ );
1029
+ const criticals = report.violations.filter((v) => v.severity === "critical");
1030
+ const warnings = report.violations.filter((v) => v.severity === "warning");
1031
+ const infos = report.violations.filter((v) => v.severity === "info");
1032
+ if (criticals.length > 0) {
1033
+ console.log(import_chalk.default.red.bold(`CRITICAL (${criticals.length})`));
1034
+ for (const v of criticals) {
1035
+ console.log(
1036
+ import_chalk.default.red(`\u2717 ${v.filePath}:${v.line}`)
1037
+ );
1038
+ console.log(
1039
+ ` ${import_chalk.default.gray(`[${v.category}]`)} ${v.message}`
1040
+ );
1041
+ }
1042
+ console.log();
1043
+ }
1044
+ if (warnings.length > 0) {
1045
+ console.log(import_chalk.default.yellow.bold(`WARNINGS (${warnings.length})`));
1046
+ for (const v of warnings) {
1047
+ console.log(
1048
+ import_chalk.default.yellow(`\u26A0 ${v.filePath}:${v.line}`)
1049
+ );
1050
+ console.log(
1051
+ ` ${import_chalk.default.gray(`[${v.category}]`)} ${v.message}`
1052
+ );
1053
+ }
1054
+ console.log();
1055
+ }
1056
+ if (infos.length > 0) {
1057
+ console.log(import_chalk.default.blue.bold(`INFO (${infos.length})`));
1058
+ for (const v of infos) {
1059
+ console.log(
1060
+ import_chalk.default.blue(`\u2139 ${v.filePath}:${v.line}`)
1061
+ );
1062
+ console.log(
1063
+ ` ${import_chalk.default.gray(`[${v.category}]`)} ${v.message}`
1064
+ );
1065
+ }
1066
+ console.log();
1067
+ }
1068
+ const categories = [
1069
+ "architecture",
1070
+ "react",
1071
+ "typescript",
1072
+ "a11y",
1073
+ "performance"
1074
+ ];
1075
+ const scoreLines = categories.map((cat) => {
1076
+ const s = report.categoryScores[cat];
1077
+ const label = CATEGORY_LABELS[cat].padEnd(14);
1078
+ return ` ${label} ${scoreBar(s)} ${s}/100`;
1079
+ }).join("\n");
1080
+ const summary = [
1081
+ import_chalk.default.bold(" AUDIT SCORE"),
1082
+ "",
1083
+ scoreLines,
1084
+ "",
1085
+ ` Total Score: ${import_chalk.default.bold(String(report.totalScore))}/100 ${scoreLabel(report.totalScore)}`,
1086
+ "",
1087
+ ` ${import_chalk.default.red(`${report.criticalCount} critical`)} \u2022 ${import_chalk.default.yellow(`${report.warningCount} warnings`)} \u2022 ${import_chalk.default.blue(`${report.infoCount} info`)}`
1088
+ ].join("\n");
1089
+ console.log(
1090
+ (0, import_boxen.default)(summary, {
1091
+ padding: { top: 0, bottom: 0, left: 0, right: 2 },
1092
+ borderStyle: "round",
1093
+ borderColor: report.totalScore >= 75 ? "green" : report.totalScore >= 60 ? "yellow" : "red"
1094
+ })
1095
+ );
1096
+ }
1097
+
1098
+ // src/reporter/json.ts
1099
+ function toJSON(report) {
1100
+ return JSON.stringify(report, null, 2);
1101
+ }
1102
+
1103
+ // src/cli/index.ts
1104
+ var program = new import_commander.Command();
1105
+ program.name("spaghetti-slicer").description("Frontend best practices auditor for React/TypeScript codebases").version("0.1.0").argument("<path>", "Directory or file to audit").option("--json", "Output results as JSON to stdout").option("--fix", "Show auto-fix suggestions").option("--rule <category>", "Only run rules from one category").option("--min-score <number>", "Exit with code 1 if score is below threshold").action(async (targetPath, options) => {
1106
+ const start = Date.now();
1107
+ if (!fs2.existsSync(targetPath)) {
1108
+ console.error(`Error: path '${targetPath}' does not exist.`);
1109
+ process.exit(1);
1110
+ }
1111
+ const files = walkFiles(targetPath);
1112
+ const totalFiles = files.length;
1113
+ let activeRules = allRules;
1114
+ if (options.rule) {
1115
+ const cat = options.rule;
1116
+ activeRules = allRules.filter((r) => r.category === cat);
1117
+ }
1118
+ resetErrorBoundaryState();
1119
+ const spinner = options.json ? null : (0, import_ora.default)(`Scanning ${totalFiles} files...`).start();
1120
+ const allViolations = [];
1121
+ let scannedFiles = 0;
1122
+ for (const filePath of files) {
1123
+ let content;
1124
+ try {
1125
+ content = fs2.readFileSync(filePath, "utf-8");
1126
+ } catch {
1127
+ continue;
1128
+ }
1129
+ for (const rule of activeRules) {
1130
+ try {
1131
+ const violations = rule.run(filePath, content);
1132
+ allViolations.push(...violations);
1133
+ } catch {
1134
+ }
1135
+ }
1136
+ scannedFiles++;
1137
+ }
1138
+ const errorBoundaryViolations = finalizeErrorBoundaryViolations();
1139
+ allViolations.push(...errorBoundaryViolations);
1140
+ spinner?.succeed(`${scannedFiles} files scanned`);
1141
+ const durationMs = Date.now() - start;
1142
+ const report = computeReport(allViolations, totalFiles, scannedFiles, durationMs);
1143
+ if (options.json) {
1144
+ process.stdout.write(toJSON(report) + "\n");
1145
+ } else {
1146
+ printReport(report);
1147
+ if (options.fix) {
1148
+ console.log("\nAuto-fix suggestions:");
1149
+ console.log(" Run: eslint --fix for no-explicit-any violations");
1150
+ console.log(" Run: eslint-plugin-jsx-a11y for accessibility violations");
1151
+ }
1152
+ }
1153
+ const minScore = options.minScore ? parseInt(options.minScore, 10) : null;
1154
+ if (minScore !== null && report.totalScore < minScore) {
1155
+ process.exit(1);
1156
+ }
1157
+ });
1158
+ program.parse();