@delegance/claude-autopilot 2.4.0 → 5.0.0-alpha.1

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 (129) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +164 -106
  3. package/bin/_launcher.js +77 -0
  4. package/bin/claude-autopilot.js +3 -0
  5. package/bin/guardrail.js +3 -0
  6. package/package.json +15 -9
  7. package/presets/generic/guardrail.config.yaml +35 -0
  8. package/presets/generic/stack.md +40 -0
  9. package/presets/nextjs-supabase/{autopilot.config.yaml → guardrail.config.yaml} +7 -0
  10. package/scripts/autoregress.ts +27 -11
  11. package/skills/autopilot/SKILL.md +170 -0
  12. package/skills/claude-autopilot.md +80 -0
  13. package/skills/guardrail.md +39 -0
  14. package/skills/migrate/SKILL.md +83 -0
  15. package/src/adapters/council/claude.ts +41 -0
  16. package/src/adapters/council/openai.ts +40 -0
  17. package/src/adapters/council/types.ts +7 -0
  18. package/src/adapters/loader.ts +7 -7
  19. package/src/adapters/review-engine/auto.ts +2 -2
  20. package/src/adapters/review-engine/claude.ts +9 -11
  21. package/src/adapters/review-engine/codex.ts +9 -11
  22. package/src/adapters/review-engine/gemini.ts +9 -11
  23. package/src/adapters/review-engine/openai-compatible.ts +10 -12
  24. package/src/adapters/review-engine/parse-output.ts +32 -6
  25. package/src/adapters/review-engine/prompt-builder.ts +19 -0
  26. package/src/adapters/review-engine/types.ts +1 -1
  27. package/src/adapters/vcs-host/commit-status.ts +39 -0
  28. package/src/adapters/vcs-host/github.ts +2 -2
  29. package/src/cli/baseline.ts +125 -0
  30. package/src/cli/ci.ts +11 -8
  31. package/src/cli/costs.ts +80 -0
  32. package/src/cli/council.ts +96 -0
  33. package/src/cli/detector.ts +21 -5
  34. package/src/cli/explain.ts +197 -0
  35. package/src/cli/fix.ts +249 -0
  36. package/src/cli/hook.ts +72 -27
  37. package/src/cli/ignore-helper.ts +116 -0
  38. package/src/cli/index.ts +302 -28
  39. package/src/cli/init.ts +12 -12
  40. package/src/cli/lsp.ts +200 -0
  41. package/src/cli/mcp.ts +206 -0
  42. package/src/cli/pr-comment.ts +5 -5
  43. package/src/cli/pr-desc.ts +168 -0
  44. package/src/cli/pr-review-comments.ts +3 -3
  45. package/src/cli/pr.ts +76 -0
  46. package/src/cli/preflight.ts +15 -32
  47. package/src/cli/report.ts +186 -0
  48. package/src/cli/run.ts +140 -36
  49. package/src/cli/scan.ts +233 -0
  50. package/src/cli/setup.ts +121 -15
  51. package/src/cli/test-gen.ts +125 -0
  52. package/src/cli/triage.ts +137 -0
  53. package/src/cli/watch.ts +52 -31
  54. package/src/cli/worker.ts +109 -0
  55. package/src/core/cache/review-cache.ts +2 -2
  56. package/src/core/chunking/index.ts +2 -2
  57. package/src/core/config/loader.ts +24 -12
  58. package/src/core/config/preset-resolver.ts +6 -6
  59. package/src/core/config/schema.ts +121 -3
  60. package/src/core/config/types.ts +57 -2
  61. package/src/core/council/config.ts +71 -0
  62. package/src/core/council/context.ts +17 -0
  63. package/src/core/council/runner.ts +83 -0
  64. package/src/core/council/types.ts +45 -0
  65. package/src/core/detect/llm-key.ts +89 -0
  66. package/src/core/detect/workspaces.ts +103 -0
  67. package/src/core/errors.ts +4 -4
  68. package/src/core/fix/generator.ts +149 -0
  69. package/src/core/ignore/index.ts +4 -4
  70. package/src/core/mcp/concurrency.ts +16 -0
  71. package/src/core/mcp/handlers/fix-finding.ts +126 -0
  72. package/src/core/mcp/handlers/get-capabilities.ts +62 -0
  73. package/src/core/mcp/handlers/get-findings.ts +36 -0
  74. package/src/core/mcp/handlers/review-diff.ts +65 -0
  75. package/src/core/mcp/handlers/scan-files.ts +65 -0
  76. package/src/core/mcp/handlers/validate-fix.ts +41 -0
  77. package/src/core/mcp/run-store.ts +85 -0
  78. package/src/core/mcp/workspace.ts +35 -0
  79. package/src/core/persist/baseline.ts +112 -0
  80. package/src/core/persist/cost-log.ts +1 -1
  81. package/src/core/persist/findings-cache.ts +1 -1
  82. package/src/core/persist/triage.ts +112 -0
  83. package/src/core/phases/static-rules.ts +18 -5
  84. package/src/core/pipeline/review-phase.ts +65 -26
  85. package/src/core/pipeline/run.ts +42 -10
  86. package/src/core/runtime/lock.ts +2 -2
  87. package/src/core/runtime/state.ts +2 -2
  88. package/src/core/schema-alignment/detector.ts +59 -0
  89. package/src/core/schema-alignment/extractor/index.ts +24 -0
  90. package/src/core/schema-alignment/extractor/prisma.ts +21 -0
  91. package/src/core/schema-alignment/extractor/sql.ts +99 -0
  92. package/src/core/schema-alignment/llm-check.ts +91 -0
  93. package/src/core/schema-alignment/scanner.ts +107 -0
  94. package/src/core/schema-alignment/types.ts +43 -0
  95. package/src/core/shell.ts +3 -3
  96. package/src/core/static-rules/registry.ts +17 -8
  97. package/src/core/static-rules/rules/brand-tokens.ts +145 -0
  98. package/src/core/static-rules/rules/hardcoded-secrets.ts +27 -1
  99. package/src/core/static-rules/rules/insecure-redirect.ts +67 -0
  100. package/src/core/static-rules/rules/missing-auth.ts +70 -0
  101. package/src/core/static-rules/rules/schema-alignment.ts +132 -0
  102. package/src/core/static-rules/rules/sql-injection.ts +71 -0
  103. package/src/core/static-rules/rules/ssrf.ts +63 -0
  104. package/src/core/static-rules/tailwind-extractor.ts +38 -0
  105. package/src/core/test-gen/coverage-analyzer.ts +93 -0
  106. package/src/core/test-gen/framework-detector.ts +21 -0
  107. package/src/core/test-gen/test-writer.ts +33 -0
  108. package/src/core/ui/design-context-loader.ts +87 -0
  109. package/src/core/worker/client.ts +46 -0
  110. package/src/core/worker/lockfile.ts +38 -0
  111. package/src/core/worker/server.ts +81 -0
  112. package/src/formatters/junit.ts +52 -0
  113. package/src/formatters/sarif.ts +2 -2
  114. package/src/index.ts +1 -2
  115. package/tests/snapshots/baselines/src-formatters-sarif.json +4 -4
  116. package/tests/snapshots/index.json +3 -3
  117. package/tests/snapshots/src-formatters-sarif.snap.ts +1 -1
  118. package/tests/snapshots/src-snapshots-impact-selector.snap.ts +3 -3
  119. package/tests/snapshots/src-snapshots-import-scanner.snap.ts +3 -3
  120. package/tests/snapshots/src-snapshots-serializer.snap.ts +2 -2
  121. package/bin/autopilot.js +0 -20
  122. package/skills/autopilot.md +0 -157
  123. /package/presets/go/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  124. /package/presets/python-fastapi/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  125. /package/presets/rails-postgres/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  126. /package/presets/t3/{autopilot.config.yaml → guardrail.config.yaml} +0 -0
  127. /package/{src → scripts}/snapshots/impact-selector.ts +0 -0
  128. /package/{src → scripts}/snapshots/import-scanner.ts +0 -0
  129. /package/{src → scripts}/snapshots/serializer.ts +0 -0
@@ -3,7 +3,33 @@ import type { StaticRule } from '../../phases/static-rules.ts';
3
3
  import type { Finding } from '../../findings/types.ts';
4
4
 
5
5
  const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
6
+ // Cloud provider keys
6
7
  { regex: /\bAKIA[0-9A-Z]{16}\b/, label: 'AWS Access Key ID' },
8
+ { regex: /\b(?:aws|AWS)_?(?:secret|SECRET)_?(?:key|KEY|access|ACCESS)\s*[:=]\s*['"][A-Za-z0-9/+]{40}['"]/, label: 'AWS Secret Access Key' },
9
+
10
+ // LLM / AI providers
11
+ { regex: /\bsk-ant-[a-zA-Z0-9\-_]{20,}\b/, label: 'Anthropic API key' },
12
+ { regex: /\bsk-[a-zA-Z0-9]{20,}\b(?!.*placeholder)/, label: 'OpenAI API key' },
13
+ { regex: /\bgsk_[a-zA-Z0-9]{20,}\b/, label: 'Groq API key' },
14
+
15
+ // Payment
16
+ { regex: /\bsk_live_[a-zA-Z0-9]{24,}\b/, label: 'Stripe secret key (live)' },
17
+ { regex: /\brk_live_[a-zA-Z0-9]{24,}\b/, label: 'Stripe restricted key (live)' },
18
+
19
+ // Source control / CI
20
+ { regex: /\bghp_[a-zA-Z0-9]{36}\b/, label: 'GitHub personal access token' },
21
+ { regex: /\bghs_[a-zA-Z0-9]{36}\b/, label: 'GitHub Actions token' },
22
+ { regex: /\bgithub_pat_[a-zA-Z0-9_]{82}\b/, label: 'GitHub fine-grained PAT' },
23
+
24
+ // Communication
25
+ { regex: /\bSG\.[a-zA-Z0-9\-_]{22,}\.[a-zA-Z0-9\-_]{43,}\b/, label: 'SendGrid API key' },
26
+ { regex: /\bAC[a-f0-9]{32}\b/, label: 'Twilio Account SID' },
27
+
28
+ // Database / BaaS
29
+ { regex: /\bservice_role\b.*\beyJ[a-zA-Z0-9._-]{100,}\b/, label: 'Supabase service role JWT' },
30
+ { regex: /\beyJ[a-zA-Z0-9._-]{150,}\b/, label: 'Long JWT (possible service key)' },
31
+
32
+ // Generic patterns
7
33
  { regex: /(?:^|[^a-z])(?:password|passwd|pwd)\s*[:=]\s*['"](?!\/)[^'"]{6,}['"]/, label: 'Hardcoded password' },
8
34
  { regex: /(?:api_key|apikey|api-key)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded API key' },
9
35
  { regex: /(?:secret|secret_key|secretkey)\s*[:=]\s*['"][^'"]{8,}['"]/, label: 'Hardcoded secret' },
@@ -13,7 +39,7 @@ const SECRET_PATTERNS: { regex: RegExp; label: string }[] = [
13
39
  ];
14
40
 
15
41
  // Patterns that indicate a placeholder, not a real secret
16
- const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]+>)/i;
42
+ const PLACEHOLDER = /(?:your[-_]?|xxx|placeholder|example|test|fake|dummy|changeme|<[^>]+>|process\.env|import\.meta\.env|\$\{)/i;
17
43
  const SKIP_EXTS = new Set(['.md', '.txt', '.yaml', '.yml', '.json', '.lock', '.snap']);
18
44
  const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
19
45
 
@@ -0,0 +1,67 @@
1
+ import * as fs from 'node:fs';
2
+ import type { StaticRule } from '../../phases/static-rules.ts';
3
+ import type { Finding } from '../../findings/types.ts';
4
+
5
+ // Redirect calls in Next.js / Express / Node
6
+ const REDIRECT_CALL = /(?:\bredirect\s*\(|NextResponse\.redirect\s*\(|res\.redirect\s*\(|router\.push\s*\()/;
7
+
8
+ // User-controlled input sources
9
+ const USER_INPUT_SOURCES = /(?:req\.|request\.|params\.|query\.|body\.|searchParams\.|headers\.|getParam|getQuery|getSearchParam)/;
10
+
11
+ // Template interpolation of user input into redirect target
12
+ const TAINTED_REDIRECT_TEMPLATE = /`[^`]*\$\{[^}]*(?:url|redirect|return|next|callback|target|to|from|path|href|location)[^}]*\}`/i;
13
+ const TAINTED_REDIRECT_VAR = /(?:url|redirect|returnUrl|returnTo|next|callbackUrl|target|to|from|path|href|location)\b/;
14
+
15
+ const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
16
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
17
+
18
+ export const insecureRedirectRule: StaticRule = {
19
+ name: 'insecure-redirect',
20
+ severity: 'warning',
21
+
22
+ async check(touchedFiles: string[]): Promise<Finding[]> {
23
+ const findings: Finding[] = [];
24
+ for (const file of touchedFiles) {
25
+ const ext = file.slice(file.lastIndexOf('.'));
26
+ if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
27
+ let content: string;
28
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
29
+ const lines = content.split('\n');
30
+
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i]!;
33
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
34
+
35
+ if (!REDIRECT_CALL.test(line)) continue;
36
+
37
+ // Check if the redirect target is user-controlled
38
+ const hasTaint = TAINTED_REDIRECT_TEMPLATE.test(line) || USER_INPUT_SOURCES.test(line);
39
+ if (!hasTaint) {
40
+ // Check if a variable with a suspicious name is passed
41
+ const argStr = line.replace(REDIRECT_CALL, '');
42
+ const hasRedirectVar = TAINTED_REDIRECT_VAR.test(line) && !/['"`]/.test(argStr);
43
+ if (!hasRedirectVar) continue;
44
+ }
45
+
46
+ // Skip if there's obvious validation nearby (startsWith, URL constructor, allowlist)
47
+ const context = lines.slice(Math.max(0, i - 5), i + 1).join('\n');
48
+ const hasValidation = /(?:startsWith\s*\(\s*['"]\/|new\s+URL|allowedRedirects|trustedOrigins|encodeURIComponent)/.test(context);
49
+ if (hasValidation) continue;
50
+
51
+ findings.push({
52
+ id: `insecure-redirect:${file}:${i + 1}`,
53
+ source: 'static-rules',
54
+ severity: 'warning',
55
+ category: 'insecure-redirect',
56
+ file,
57
+ line: i + 1,
58
+ message: 'Possible open redirect: redirect target may be user-controlled',
59
+ suggestion: 'Validate redirect targets — allow only relative paths (startsWith("/")) or an explicit allowlist of trusted origins',
60
+ protectedPath: false,
61
+ createdAt: new Date().toISOString(),
62
+ });
63
+ }
64
+ }
65
+ return findings;
66
+ },
67
+ };
@@ -0,0 +1,70 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { StaticRule } from '../../phases/static-rules.ts';
4
+ import type { Finding } from '../../findings/types.ts';
5
+
6
+ // Next.js App Router API routes and pages with data mutations
7
+ const API_ROUTE_PATTERN = /(?:app[/\\]api[/\\].*route\.[tj]sx?|pages[/\\]api[/\\].*\.[tj]sx?)$/;
8
+
9
+ // Auth function signatures — any of these indicate auth is checked
10
+ const AUTH_PATTERNS = [
11
+ /getServerSession\s*\(/,
12
+ /\bauth\s*\(\s*\)/, // next-auth v5 auth()
13
+ /createServerSupabase\s*\(/,
14
+ /createServerClient\s*\(/,
15
+ /getSession\s*\(/,
16
+ /verifyToken\s*\(/,
17
+ /authenticate\s*\(/,
18
+ /requireAuth\s*\(/,
19
+ /currentUser\s*\(/,
20
+ /withAuth\s*\(/,
21
+ /checkAuth\s*\(/,
22
+ /isAuthenticated\s*\(/,
23
+ /useServerSession\s*\(/,
24
+ /jwtVerify\s*\(/,
25
+ /verify\s*\(.*token/i,
26
+ /clerkClient/,
27
+ /getAuth\s*\(/, // Clerk
28
+ /session\s*\.\s*user/,
29
+ /req\s*\.\s*user\b/,
30
+ ];
31
+
32
+ // Mutation handler exports — GET-only routes are less critical
33
+ const MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(?:POST|PUT|PATCH|DELETE)\b/;
34
+ const MUTATION_HANDLER = /(?:POST|PUT|PATCH|DELETE)\s*\(/;
35
+
36
+ export const missingAuthRule: StaticRule = {
37
+ name: 'missing-auth',
38
+ severity: 'critical',
39
+
40
+ async check(touchedFiles: string[]): Promise<Finding[]> {
41
+ const findings: Finding[] = [];
42
+ for (const file of touchedFiles) {
43
+ if (!API_ROUTE_PATTERN.test(file)) continue;
44
+ let content: string;
45
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
46
+
47
+ // Only flag mutation handlers
48
+ if (!MUTATION_EXPORT.test(content) && !MUTATION_HANDLER.test(content)) continue;
49
+
50
+ // Check if any auth pattern is present in the file
51
+ const hasAuth = AUTH_PATTERNS.some(p => p.test(content));
52
+ if (hasAuth) continue;
53
+
54
+ const rel = path.basename(file);
55
+ findings.push({
56
+ id: `missing-auth:${file}:1`,
57
+ source: 'static-rules',
58
+ severity: 'critical',
59
+ category: 'missing-auth',
60
+ file,
61
+ line: 1,
62
+ message: `API route ${rel} has mutation handlers (POST/PUT/PATCH/DELETE) with no visible auth check`,
63
+ suggestion: 'Add authentication: call getServerSession(), auth(), or equivalent before processing the request body',
64
+ protectedPath: false,
65
+ createdAt: new Date().toISOString(),
66
+ });
67
+ }
68
+ return findings;
69
+ },
70
+ };
@@ -0,0 +1,132 @@
1
+ // src/core/static-rules/rules/schema-alignment.ts
2
+ import type { StaticRule } from '../../phases/static-rules.ts';
3
+ import type { Finding } from '../../findings/types.ts';
4
+ import type { SchemaAlignmentConfig, LayerScanResult, AlignmentFinding, Evidence, SchemaEntity } from '../../schema-alignment/types.ts';
5
+ import type { ReviewEngine } from '../../../adapters/review-engine/types.ts';
6
+ import { detect } from '../../schema-alignment/detector.ts';
7
+ import { extract } from '../../schema-alignment/extractor/index.ts';
8
+ import { scanLayers } from '../../schema-alignment/scanner.ts';
9
+ import { runLlmCheck } from '../../schema-alignment/llm-check.ts';
10
+
11
+ function isDestructive(entity: { operation: string }): boolean {
12
+ return entity.operation === 'drop_column' || entity.operation === 'rename_column';
13
+ }
14
+
15
+ function toFinding(af: AlignmentFinding, fallbackFile: string): Finding {
16
+ return {
17
+ id: `schema-alignment:${af.entity.table}:${af.entity.column ?? ''}:${af.layer}`,
18
+ source: 'static-rules',
19
+ severity: af.severity === 'error' ? 'critical' : 'warning',
20
+ category: 'schema-alignment',
21
+ // LLM may supply an explicit `file`; otherwise fall back to the migration
22
+ // file that triggered the check. Never use the table name as a path.
23
+ file: af.file ?? fallbackFile,
24
+ message: af.message,
25
+ suggestion: `Update the ${af.layer} layer to reflect the schema change in "${af.entity.column ?? af.entity.table}"`,
26
+ protectedPath: false,
27
+ createdAt: new Date().toISOString(),
28
+ };
29
+ }
30
+
31
+ function layerEvidence(result: LayerScanResult, layer: 'type' | 'api' | 'ui'): Evidence | null {
32
+ return layer === 'type' ? result.typeLayer : layer === 'api' ? result.apiLayer : result.uiLayer;
33
+ }
34
+
35
+ function structuralFinding(
36
+ result: LayerScanResult,
37
+ layer: 'type' | 'api' | 'ui',
38
+ defaultSev: 'warning' | 'error',
39
+ sourceFile: string,
40
+ ): Finding {
41
+ const destructive = isDestructive(result.entity);
42
+ const name = result.entity.column ?? result.entity.table;
43
+ const message = destructive
44
+ ? `Stale reference to dropped/renamed "${name}" still present in ${layer} layer after schema change`
45
+ : `No reference to "${name}" found in ${layer} layer — update may be missing after schema change`;
46
+ const severity: Finding['severity'] = destructive ? 'critical' : (defaultSev === 'error' ? 'critical' : 'warning');
47
+ // Destructive findings have Evidence (the stale reference's actual file);
48
+ // non-destructive findings don't have a layer file (that's the gap), so point
49
+ // back to the migration that caused the change.
50
+ const evidence = destructive ? layerEvidence(result, layer) : null;
51
+ return {
52
+ id: `schema-alignment:${result.entity.table}:${result.entity.column ?? ''}:${layer}`,
53
+ source: 'static-rules',
54
+ severity,
55
+ category: 'schema-alignment',
56
+ file: evidence?.file ?? sourceFile,
57
+ line: evidence?.line,
58
+ message,
59
+ suggestion: `Check the ${layer} layer for references to "${name}"`,
60
+ protectedPath: false,
61
+ createdAt: new Date().toISOString(),
62
+ };
63
+ }
64
+
65
+ export const schemaAlignmentRule: StaticRule = {
66
+ name: 'schema-alignment',
67
+ severity: 'warning',
68
+
69
+ async check(touchedFiles: string[], config: Record<string, unknown> = {}): Promise<Finding[]> {
70
+ const saConfig = config['schema-alignment'] as SchemaAlignmentConfig | undefined;
71
+ if (saConfig?.enabled === false) return [];
72
+
73
+ const cwd = process.cwd();
74
+ const migrationFiles = detect(touchedFiles, saConfig);
75
+ if (migrationFiles.length === 0) return [];
76
+
77
+ // Preserve source migration file for each entity so findings can point back
78
+ // to the SQL/Prisma file that caused the change.
79
+ type EntityWithSource = { entity: SchemaEntity; sourceFile: string };
80
+ const allEntities: EntityWithSource[] = migrationFiles.flatMap(f =>
81
+ extract(f).map(entity => ({ entity, sourceFile: f })),
82
+ );
83
+ if (allEntities.length === 0) return [];
84
+
85
+ const scanResults = scanLayers(allEntities.map(e => e.entity), cwd, saConfig);
86
+ // Index source files back onto scan results (scanLayers preserves order)
87
+ const resultsWithSource = scanResults.map((r, i) => ({ result: r, sourceFile: allEntities[i]!.sourceFile }));
88
+
89
+ // For destructive ops: gap = evidence WAS found (stale ref remains)
90
+ // For add/create: gap = evidence NOT found (layer not updated)
91
+ const gapResults = resultsWithSource.filter(({ result: r }) => {
92
+ if (isDestructive(r.entity)) return r.typeLayer !== null || r.apiLayer !== null || r.uiLayer !== null;
93
+ return r.typeLayer === null || r.apiLayer === null || r.uiLayer === null;
94
+ });
95
+
96
+ if (gapResults.length === 0) return [];
97
+
98
+ const defaultSev = saConfig?.severity ?? 'warning';
99
+ const llmEnabled = saConfig?.llmCheck !== false;
100
+ const engine = config['_engine'] as ReviewEngine | undefined;
101
+
102
+ // Structural mode — always compute these so we can fall back if LLM path yields nothing
103
+ const structural: Finding[] = [];
104
+ for (const { result: r, sourceFile } of gapResults) {
105
+ if (isDestructive(r.entity)) {
106
+ if (r.typeLayer) structural.push(structuralFinding(r, 'type', defaultSev, sourceFile));
107
+ if (r.apiLayer) structural.push(structuralFinding(r, 'api', defaultSev, sourceFile));
108
+ if (r.uiLayer) structural.push(structuralFinding(r, 'ui', defaultSev, sourceFile));
109
+ } else {
110
+ if (!r.typeLayer) structural.push(structuralFinding(r, 'type', defaultSev, sourceFile));
111
+ if (!r.apiLayer) structural.push(structuralFinding(r, 'api', defaultSev, sourceFile));
112
+ if (!r.uiLayer) structural.push(structuralFinding(r, 'ui', defaultSev, sourceFile));
113
+ }
114
+ }
115
+
116
+ if (llmEnabled && engine) {
117
+ const llmFindings = await runLlmCheck(migrationFiles, gapResults.map(g => g.result), engine);
118
+ // Fall back to structural findings if the LLM returned nothing parseable —
119
+ // avoids silently dropping real gaps when the model is down or returns prose.
120
+ if (llmFindings.length > 0) {
121
+ // Build table → sourceFile index so each LLM finding can be attributed
122
+ // back to its originating migration when the model didn't return a file.
123
+ const tableToSource = new Map<string, string>();
124
+ for (const { entity, sourceFile } of allEntities) tableToSource.set(entity.table, sourceFile);
125
+ return llmFindings.map(af => toFinding(af, tableToSource.get(af.entity.table) ?? migrationFiles[0]!));
126
+ }
127
+ return structural;
128
+ }
129
+
130
+ return structural;
131
+ },
132
+ };
@@ -0,0 +1,71 @@
1
+ import * as fs from 'node:fs';
2
+ import type { StaticRule } from '../../phases/static-rules.ts';
3
+ import type { Finding } from '../../findings/types.ts';
4
+
5
+ // String interpolation or concatenation inside a SQL-like string
6
+ const SQL_KEYWORDS = /\b(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|INTO|VALUES|SET|DROP|CREATE|ALTER|TRUNCATE|EXEC|EXECUTE)\b/i;
7
+
8
+ // Template literal or concatenation patterns with SQL
9
+ const TEMPLATE_SQL = /`[^`]*\$\{[^}]+\}[^`]*`/;
10
+ const CONCAT_SQL = /(?:["'][^"']*["']\s*\+\s*\w|[\w)\]]\s*\+\s*["'][^"']*["'])/;
11
+
12
+ // Common DB call patterns that accept raw SQL strings
13
+ const DB_CALL = /(?:\.query|\.execute|\.exec|\.run|\.prepare|db\.|pool\.|connection\.|knex\.|sequelize\.query|prisma\.\$queryRaw|drizzle\.execute)\s*\(/;
14
+
15
+ const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
16
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
17
+
18
+ export const sqlInjectionRule: StaticRule = {
19
+ name: 'sql-injection',
20
+ severity: 'critical',
21
+
22
+ async check(touchedFiles: string[]): Promise<Finding[]> {
23
+ const findings: Finding[] = [];
24
+ for (const file of touchedFiles) {
25
+ const ext = file.slice(file.lastIndexOf('.'));
26
+ if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
27
+ let content: string;
28
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
29
+ const lines = content.split('\n');
30
+
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i]!;
33
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
34
+
35
+ // Look for template literals or concatenation containing SQL keywords
36
+ const hasSql = SQL_KEYWORDS.test(line);
37
+ const hasInterpolation = TEMPLATE_SQL.test(line) || CONCAT_SQL.test(line);
38
+ const isDbCall = DB_CALL.test(line) || (i > 0 && DB_CALL.test(lines[i - 1]!));
39
+
40
+ if (hasSql && hasInterpolation) {
41
+ findings.push({
42
+ id: `sql-injection:${file}:${i + 1}`,
43
+ source: 'static-rules',
44
+ severity: 'critical',
45
+ category: 'sql-injection',
46
+ file,
47
+ line: i + 1,
48
+ message: 'Possible SQL injection: user input appears interpolated into SQL string',
49
+ suggestion: 'Use parameterized queries (e.g. db.query("... WHERE id = $1", [id])) or a query builder',
50
+ protectedPath: false,
51
+ createdAt: new Date().toISOString(),
52
+ });
53
+ } else if (isDbCall && hasInterpolation) {
54
+ findings.push({
55
+ id: `sql-injection:${file}:${i + 1}`,
56
+ source: 'static-rules',
57
+ severity: 'critical',
58
+ category: 'sql-injection',
59
+ file,
60
+ line: i + 1,
61
+ message: 'Possible SQL injection: dynamic string passed to DB query method',
62
+ suggestion: 'Use parameterized queries or a typed query builder instead of string concatenation',
63
+ protectedPath: false,
64
+ createdAt: new Date().toISOString(),
65
+ });
66
+ }
67
+ }
68
+ }
69
+ return findings;
70
+ },
71
+ };
@@ -0,0 +1,63 @@
1
+ import * as fs from 'node:fs';
2
+ import type { StaticRule } from '../../phases/static-rules.ts';
3
+ import type { Finding } from '../../findings/types.ts';
4
+
5
+ // HTTP client calls
6
+ const HTTP_CALL = /\b(?:fetch|axios\.get|axios\.post|axios\.put|axios\.delete|axios\.request|axios\s*\(|http\.get|https\.get|http\.request|https\.request|got\s*\(|needle\.get|superagent\.get|request\s*\()\s*\(/;
7
+
8
+ // User-controlled input sources
9
+ const USER_INPUT = /(?:req\.|request\.|params\.|query\.|body\.|searchParams\.|headers\.|url\b|getParam|getQuery|getHeader)/;
10
+
11
+ // Template literal or concatenation with a user-controlled value followed by URL context
12
+ const TAINTED_URL_TEMPLATE = /`[^`]*\$\{[^}]*(?:req|params|query|body|url|host|origin|domain|endpoint|target)[^}]*\}[^`]*`/i;
13
+ const TAINTED_URL_CONCAT = /(?:req|params|query|body|url|host|origin|domain|endpoint|target)\s*[+]/i;
14
+
15
+ const CODE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs']);
16
+ const TEST_PATH = /(?:__tests__|\.test\.|\.spec\.|\/test\/|\/tests\/)/;
17
+
18
+ export const ssrfRule: StaticRule = {
19
+ name: 'ssrf',
20
+ severity: 'critical',
21
+
22
+ async check(touchedFiles: string[]): Promise<Finding[]> {
23
+ const findings: Finding[] = [];
24
+ for (const file of touchedFiles) {
25
+ const ext = file.slice(file.lastIndexOf('.'));
26
+ if (!CODE_EXTS.has(ext) || TEST_PATH.test(file)) continue;
27
+ let content: string;
28
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
29
+ const lines = content.split('\n');
30
+
31
+ for (let i = 0; i < lines.length; i++) {
32
+ const line = lines[i]!;
33
+ if (line.trim().startsWith('//') || line.trim().startsWith('*')) continue;
34
+
35
+ const isHttpCall = HTTP_CALL.test(line);
36
+ if (!isHttpCall) continue;
37
+
38
+ // Check if the argument contains user-controlled input
39
+ const hasTaint = TAINTED_URL_TEMPLATE.test(line) || TAINTED_URL_CONCAT.test(line) || USER_INPUT.test(line);
40
+ if (!hasTaint) continue;
41
+
42
+ // Skip if there's obvious URL validation on nearby lines (allowlist, startsWith, etc.)
43
+ const context = lines.slice(Math.max(0, i - 3), i + 1).join('\n');
44
+ const hasValidation = /(?:allowlist|allowedOrigins|trustedDomains|startsWith|includes\s*\(\s*['"]https:\/\/|new\s+URL\s*\()/.test(context);
45
+ if (hasValidation) continue;
46
+
47
+ findings.push({
48
+ id: `ssrf:${file}:${i + 1}`,
49
+ source: 'static-rules',
50
+ severity: 'critical',
51
+ category: 'ssrf',
52
+ file,
53
+ line: i + 1,
54
+ message: 'Possible SSRF: HTTP request URL appears to be derived from user input',
55
+ suggestion: 'Validate the URL against an allowlist of trusted domains before making the request',
56
+ protectedPath: false,
57
+ createdAt: new Date().toISOString(),
58
+ });
59
+ }
60
+ }
61
+ return findings;
62
+ },
63
+ };
@@ -0,0 +1,38 @@
1
+ import * as fs from 'node:fs';
2
+
3
+ const HEX_COLOR = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
4
+
5
+ /**
6
+ * Extract canonical hex color values from a Tailwind config file.
7
+ * Uses regex extraction — reads theme.colors and theme.extend.colors values.
8
+ * Returns normalized lowercase hex strings, deduplicated.
9
+ */
10
+ export function extractTailwindColors(configPath: string): string[] {
11
+ if (!fs.existsSync(configPath)) return [];
12
+ let content: string;
13
+ try {
14
+ content = fs.readFileSync(configPath, 'utf8');
15
+ } catch {
16
+ return [];
17
+ }
18
+
19
+ // Narrow to theme block to avoid false matches outside theme config
20
+ const themeMatch = content.match(/theme\s*[=:]\s*\{([\s\S]*)/);
21
+ const searchContent = themeMatch ? themeMatch[0] : content;
22
+
23
+ const colors = new Set<string>();
24
+ HEX_COLOR.lastIndex = 0;
25
+ let m: RegExpExecArray | null;
26
+ while ((m = HEX_COLOR.exec(searchContent)) !== null) {
27
+ const raw = m[0]!.toLowerCase();
28
+ // Expand 3-digit shorthand to 6-digit
29
+ if (raw.length === 4) {
30
+ const r = raw[1]!, g = raw[2]!, b = raw[3]!;
31
+ colors.add(`#${r}${r}${g}${g}${b}${b}`);
32
+ } else {
33
+ colors.add(raw);
34
+ }
35
+ }
36
+
37
+ return [...colors];
38
+ }
@@ -0,0 +1,93 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ export interface CoverageGap {
5
+ file: string; // absolute path of source file
6
+ exports: string[]; // names of uncovered exports
7
+ testFile: string; // where the test should go (may not exist yet)
8
+ }
9
+
10
+ // Matches: export function foo, export const foo, export class Foo, export async function foo
11
+ const EXPORT_RE = /^\s*export\s+(?:async\s+)?(?:function|const|class|let|var)\s+(\w+)/gm;
12
+ // Matches: export default
13
+ const DEFAULT_EXPORT_RE = /^\s*export\s+default\s+(?:function|class|\w)/;
14
+
15
+ const TEST_EXTS = new Set(['.test.ts', '.test.tsx', '.test.js', '.spec.ts', '.spec.tsx', '.spec.js']);
16
+ const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx']);
17
+
18
+ function isTestFile(filePath: string): boolean {
19
+ const base = path.basename(filePath);
20
+ return TEST_EXTS.has(path.extname(filePath))
21
+ || base.includes('.test.')
22
+ || base.includes('.spec.')
23
+ || filePath.includes('__tests__')
24
+ || filePath.includes('/tests/');
25
+ }
26
+
27
+ function candidateTestPaths(sourceFile: string): string[] {
28
+ const dir = path.dirname(sourceFile);
29
+ const base = path.basename(sourceFile, path.extname(sourceFile));
30
+ const ext = path.extname(sourceFile);
31
+ return [
32
+ path.join(dir, `${base}.test${ext}`),
33
+ path.join(dir, `${base}.test.ts`),
34
+ path.join(dir, '__tests__', `${base}.test${ext}`),
35
+ path.join(dir, '__tests__', `${base}.test.ts`),
36
+ path.join(path.dirname(dir), 'tests', path.basename(dir), `${base}.test.ts`),
37
+ ];
38
+ }
39
+
40
+ function extractExports(content: string): string[] {
41
+ const names = new Set<string>();
42
+ EXPORT_RE.lastIndex = 0;
43
+ let m: RegExpExecArray | null;
44
+ while ((m = EXPORT_RE.exec(content)) !== null) {
45
+ if (m[1]) names.add(m[1]);
46
+ }
47
+ if (DEFAULT_EXPORT_RE.test(content)) names.add('default');
48
+ return [...names];
49
+ }
50
+
51
+ function testCoversExport(testContent: string, exportName: string, sourceBasename: string): boolean {
52
+ if (exportName === 'default') {
53
+ return testContent.includes(sourceBasename) || testContent.includes('import ');
54
+ }
55
+ return testContent.includes(exportName);
56
+ }
57
+
58
+ export function findCoverageGaps(files: string[]): CoverageGap[] {
59
+ const gaps: CoverageGap[] = [];
60
+
61
+ for (const file of files) {
62
+ const ext = path.extname(file);
63
+ if (!SOURCE_EXTS.has(ext) || isTestFile(file)) continue;
64
+
65
+ let content: string;
66
+ try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
67
+
68
+ const exports = extractExports(content);
69
+ if (exports.length === 0) continue;
70
+
71
+ // Find existing test file
72
+ const candidates = candidateTestPaths(file);
73
+ const existingTestPath = candidates.find(p => fs.existsSync(p));
74
+ const testFile = existingTestPath ?? candidates[0]!;
75
+
76
+ // Check which exports are covered
77
+ let testContent = '';
78
+ if (existingTestPath) {
79
+ try { testContent = fs.readFileSync(existingTestPath, 'utf8'); } catch { /* no test */ }
80
+ }
81
+
82
+ const sourceBasename = path.basename(file, ext);
83
+ const uncovered = existingTestPath
84
+ ? exports.filter(name => !testCoversExport(testContent, name, sourceBasename))
85
+ : exports;
86
+
87
+ if (uncovered.length > 0) {
88
+ gaps.push({ file, exports: uncovered, testFile });
89
+ }
90
+ }
91
+
92
+ return gaps;
93
+ }
@@ -0,0 +1,21 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+
4
+ export type TestFramework = 'jest' | 'vitest' | 'node:test';
5
+
6
+ export function detectTestFramework(cwd: string): TestFramework {
7
+ const pkgPath = path.join(cwd, 'package.json');
8
+ if (!fs.existsSync(pkgPath)) return 'node:test';
9
+ try {
10
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as {
11
+ devDependencies?: Record<string, string>;
12
+ dependencies?: Record<string, string>;
13
+ };
14
+ const deps = { ...pkg.devDependencies, ...pkg.dependencies };
15
+ if ('vitest' in deps) return 'vitest';
16
+ if ('jest' in deps || '@jest/globals' in deps || 'ts-jest' in deps) return 'jest';
17
+ return 'node:test';
18
+ } catch {
19
+ return 'node:test';
20
+ }
21
+ }
@@ -0,0 +1,33 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { CoverageGap } from './coverage-analyzer.ts';
4
+
5
+ export function writeGeneratedTest(gap: CoverageGap, generatedCode: string): string {
6
+ const dir = path.dirname(gap.testFile);
7
+ fs.mkdirSync(dir, { recursive: true });
8
+ fs.writeFileSync(gap.testFile, generatedCode, 'utf8');
9
+ return gap.testFile;
10
+ }
11
+
12
+ export function buildGenerationPrompt(gap: CoverageGap, sourceContent: string, framework: string): string {
13
+ const relPath = gap.file;
14
+ const exports = gap.exports.join(', ');
15
+ return `Generate a complete test file for the following TypeScript module.
16
+
17
+ Source file: ${relPath}
18
+ Uncovered exports: ${exports}
19
+ Test framework: ${framework}
20
+
21
+ Source code:
22
+ \`\`\`typescript
23
+ ${sourceContent.slice(0, 4000)}
24
+ \`\`\`
25
+
26
+ Requirements:
27
+ - Import the exports from "${relPath}" using a relative path
28
+ - Write one describe block per export
29
+ - Include a happy-path test and at least one edge case per export
30
+ - Use ${framework === 'node:test' ? "import { describe, it } from 'node:test'; import assert from 'node:assert/strict';" : `import { describe, it, expect } from '${framework}';`}
31
+ - Do NOT use mocks unless the function clearly requires external I/O
32
+ - Output ONLY the test file contents, no explanation`;
33
+ }