sentinelayer-cli 0.1.2 → 0.4.4

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/README.md +998 -996
  2. package/bin/create-sentinelayer.js +5 -5
  3. package/bin/sentinelayer-cli.js +4 -4
  4. package/bin/sl.js +5 -5
  5. package/package.json +63 -54
  6. package/src/agents/jules/config/definition.js +209 -209
  7. package/src/agents/jules/config/system-prompt.js +175 -175
  8. package/src/agents/jules/error-intake.js +51 -51
  9. package/src/agents/jules/fix-cycle.js +377 -377
  10. package/src/agents/jules/loop.js +367 -367
  11. package/src/agents/jules/pulse.js +327 -319
  12. package/src/agents/jules/stream.js +186 -186
  13. package/src/agents/jules/swarm/file-scanner.js +74 -74
  14. package/src/agents/jules/swarm/index.js +11 -11
  15. package/src/agents/jules/swarm/orchestrator.js +362 -362
  16. package/src/agents/jules/swarm/pattern-hunter.js +123 -123
  17. package/src/agents/jules/swarm/sub-agent.js +308 -308
  18. package/src/agents/jules/tools/auth-audit.js +557 -222
  19. package/src/agents/jules/tools/dispatch.js +327 -327
  20. package/src/agents/jules/tools/file-edit.js +180 -180
  21. package/src/agents/jules/tools/file-read.js +100 -100
  22. package/src/agents/jules/tools/frontend-analyze.js +570 -570
  23. package/src/agents/jules/tools/glob.js +168 -168
  24. package/src/agents/jules/tools/grep.js +228 -228
  25. package/src/agents/jules/tools/index.js +29 -29
  26. package/src/agents/jules/tools/path-guards.js +161 -161
  27. package/src/agents/jules/tools/runtime-audit.js +503 -493
  28. package/src/agents/jules/tools/shell.js +383 -383
  29. package/src/agents/jules/tools/url-policy.js +100 -0
  30. package/src/ai/aidenid.js +972 -945
  31. package/src/ai/client.js +508 -508
  32. package/src/ai/domain-target-store.js +268 -268
  33. package/src/ai/identity-store.js +270 -270
  34. package/src/ai/site-store.js +145 -145
  35. package/src/audit/agents/architecture.js +180 -180
  36. package/src/audit/agents/compliance.js +179 -179
  37. package/src/audit/agents/documentation.js +165 -165
  38. package/src/audit/agents/performance.js +145 -145
  39. package/src/audit/agents/security.js +215 -215
  40. package/src/audit/agents/testing.js +172 -172
  41. package/src/audit/orchestrator.js +557 -557
  42. package/src/audit/package.js +204 -204
  43. package/src/audit/registry.js +284 -284
  44. package/src/audit/replay.js +103 -103
  45. package/src/auth/gate.js +45 -11
  46. package/src/auth/http.js +270 -113
  47. package/src/auth/service.js +891 -848
  48. package/src/auth/session-store.js +359 -345
  49. package/src/cli.js +252 -252
  50. package/src/commands/ai/identity-lifecycle.js +1338 -1337
  51. package/src/commands/ai/provision-governance.js +1272 -1246
  52. package/src/commands/ai/shared.js +147 -147
  53. package/src/commands/ai.js +11 -11
  54. package/src/commands/apply.js +12 -12
  55. package/src/commands/audit.js +1166 -1166
  56. package/src/commands/auth.js +375 -366
  57. package/src/commands/chat.js +191 -191
  58. package/src/commands/config.js +184 -184
  59. package/src/commands/cost.js +311 -311
  60. package/src/commands/daemon/core.js +850 -850
  61. package/src/commands/daemon/extended.js +1048 -1048
  62. package/src/commands/daemon/shared.js +213 -213
  63. package/src/commands/daemon.js +11 -11
  64. package/src/commands/guide.js +174 -174
  65. package/src/commands/ingest.js +58 -58
  66. package/src/commands/init.js +55 -55
  67. package/src/commands/legacy-args.js +10 -10
  68. package/src/commands/mcp.js +461 -404
  69. package/src/commands/omargate.js +15 -15
  70. package/src/commands/persona.js +20 -20
  71. package/src/commands/plugin.js +260 -260
  72. package/src/commands/policy.js +132 -132
  73. package/src/commands/prompt.js +238 -238
  74. package/src/commands/review.js +704 -704
  75. package/src/commands/scan.js +866 -788
  76. package/src/commands/spec.js +716 -716
  77. package/src/commands/swarm.js +651 -651
  78. package/src/commands/telemetry.js +202 -202
  79. package/src/commands/watch.js +510 -510
  80. package/src/config/agent-dictionary.js +182 -182
  81. package/src/config/io.js +56 -56
  82. package/src/config/paths.js +18 -18
  83. package/src/config/schema.js +55 -55
  84. package/src/config/service.js +184 -184
  85. package/src/cost/budget.js +235 -235
  86. package/src/cost/history.js +188 -188
  87. package/src/cost/tracker.js +171 -171
  88. package/src/daemon/artifact-lineage.js +534 -534
  89. package/src/daemon/assignment-ledger.js +770 -770
  90. package/src/daemon/ast-parser-layer.js +258 -258
  91. package/src/daemon/budget-governor.js +633 -633
  92. package/src/daemon/callgraph-overlay.js +646 -646
  93. package/src/daemon/error-worker.js +626 -626
  94. package/src/daemon/hybrid-mapper.js +929 -929
  95. package/src/daemon/jira-lifecycle.js +632 -632
  96. package/src/daemon/operator-control.js +657 -657
  97. package/src/daemon/reliability-lane.js +471 -471
  98. package/src/daemon/watchdog.js +971 -971
  99. package/src/guide/generator.js +316 -316
  100. package/src/ingest/engine.js +918 -918
  101. package/src/legacy-cli.js +2592 -2435
  102. package/src/mcp/registry.js +695 -695
  103. package/src/memory/blackboard.js +301 -301
  104. package/src/memory/retrieval.js +581 -581
  105. package/src/plugin/manifest.js +553 -553
  106. package/src/policy/packs.js +144 -144
  107. package/src/prompt/generator.js +118 -106
  108. package/src/review/ai-review.js +669 -669
  109. package/src/review/local-review.js +1295 -1284
  110. package/src/review/replay.js +235 -235
  111. package/src/review/report.js +664 -664
  112. package/src/review/spec-binding.js +487 -487
  113. package/src/scaffold/generator.js +67 -0
  114. package/src/scaffold/templates.js +150 -0
  115. package/src/scan/generator.js +418 -351
  116. package/src/scan/gh-secrets.js +107 -0
  117. package/src/spec/generator.js +519 -519
  118. package/src/spec/regenerate.js +237 -237
  119. package/src/spec/templates.js +91 -91
  120. package/src/swarm/dashboard.js +247 -247
  121. package/src/swarm/factory.js +363 -363
  122. package/src/swarm/pentest.js +934 -934
  123. package/src/swarm/registry.js +419 -419
  124. package/src/swarm/report.js +158 -158
  125. package/src/swarm/runtime.js +576 -576
  126. package/src/swarm/scenario-dsl.js +272 -272
  127. package/src/telemetry/ledger.js +302 -302
  128. package/src/telemetry/sync.js +107 -61
  129. package/src/ui/markdown.js +220 -220
@@ -1,570 +1,570 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { execFileSync } from "node:child_process";
4
- import { glob as globTool } from "./glob.js";
5
- import { grep as grepTool } from "./grep.js";
6
- import { fileRead } from "./file-read.js";
7
-
8
- /**
9
- * FrontendAnalyze — 24 deterministic operations for the Jules Tanaka persona.
10
- * Each operation runs regex + file-system analysis. No LLM calls.
11
- * Saves Jules 3-5 tool calls per audit lens by bundling common frontend patterns.
12
- */
13
-
14
- const OPERATIONS = new Set([
15
- "detect_framework", "find_components", "find_routes", "find_hooks",
16
- "find_providers", "find_security_sinks", "count_state_hooks",
17
- "check_bundle_config", "check_security_headers", "find_env_exposure",
18
- "find_missing_cleanup", "find_stale_closures", "check_accessibility",
19
- "check_mobile_responsive", "find_test_coverage", "scope_graph",
20
- "check_error_boundaries", "audit_npm_deps", "check_image_optimization",
21
- "check_font_loading", "find_third_party_scripts", "check_service_workers",
22
- "check_realtime_connections", "check_css_health",
23
- ]);
24
-
25
- /**
26
- * @param {{ operation: string, path?: string, format?: string }} input
27
- * @returns {object} Structured JSON result per operation.
28
- */
29
- export function frontendAnalyze(input) {
30
- if (!input.operation || !OPERATIONS.has(input.operation)) {
31
- throw new FrontendAnalyzeError(
32
- `Unknown operation: ${input.operation}. Valid: ${[...OPERATIONS].join(", ")}`,
33
- );
34
- }
35
- const rootPath = input.path ? path.resolve(input.path) : process.cwd();
36
- if (!fs.existsSync(rootPath)) {
37
- throw new FrontendAnalyzeError(`Path not found: ${rootPath}`);
38
- }
39
- return DISPATCH[input.operation](rootPath);
40
- }
41
-
42
- // ── Operations ───────────────────────────────────────────────────────
43
-
44
- const DISPATCH = {
45
- detect_framework: detectFramework,
46
- find_components: findComponents,
47
- find_routes: findRoutes,
48
- find_hooks: findHooks,
49
- find_providers: findProviders,
50
- find_security_sinks: findSecuritySinks,
51
- count_state_hooks: countStateHooks,
52
- check_bundle_config: checkBundleConfig,
53
- check_security_headers: checkSecurityHeaders,
54
- find_env_exposure: findEnvExposure,
55
- find_missing_cleanup: findMissingCleanup,
56
- find_stale_closures: findStaleClosures,
57
- check_accessibility: checkAccessibility,
58
- check_mobile_responsive: checkMobileResponsive,
59
- find_test_coverage: findTestCoverage,
60
- scope_graph: scopeGraph,
61
- check_error_boundaries: checkErrorBoundaries,
62
- audit_npm_deps: auditNpmDeps,
63
- check_image_optimization: checkImageOptimization,
64
- check_font_loading: checkFontLoading,
65
- find_third_party_scripts: findThirdPartyScripts,
66
- check_service_workers: checkServiceWorkers,
67
- check_realtime_connections: checkRealtimeConnections,
68
- check_css_health: checkCssHealth,
69
- };
70
-
71
- function detectFramework(rootPath) {
72
- const pkg = readPackageJson(rootPath);
73
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
74
- const framework = detectFromDeps(deps);
75
- const files = safeGlob("*.{tsx,jsx,vue,svelte,ts,js}", rootPath);
76
- const testFiles = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
77
- return {
78
- framework: framework.name,
79
- version: deps[framework.pkg] || null,
80
- router: detectRouter(rootPath, deps),
81
- typescript: !!deps.typescript,
82
- stateManagement: detectState(deps),
83
- styling: detectStyling(deps, rootPath),
84
- testing: {
85
- unit: detectTestRunner(deps),
86
- e2e: detectE2eRunner(deps),
87
- component: deps["@testing-library/react"] ? "testing-library" : null,
88
- },
89
- packageManager: detectPackageManager(rootPath),
90
- linting: detectLinters(deps),
91
- entryPoints: detectEntryPoints(rootPath, framework.name),
92
- componentCount: files.filenames.filter(f => /\.(tsx|jsx|vue|svelte)$/.test(f)).length,
93
- hookCount: countCustomHooks(rootPath),
94
- providerCount: countProviders(rootPath),
95
- totalFrontendLoc: estimateFrontendLoc(rootPath),
96
- };
97
- }
98
-
99
- function findComponents(rootPath) {
100
- const files = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
101
- return {
102
- components: files.filenames.map(f => ({
103
- path: f,
104
- name: path.basename(f, path.extname(f)),
105
- type: path.extname(f).slice(1),
106
- })),
107
- count: files.numFiles,
108
- };
109
- }
110
-
111
- function findRoutes(rootPath) {
112
- const routes = [];
113
- // Next.js App Router
114
- for (const dir of ["src/app", "app"]) {
115
- const pages = safeGlob("**/page.{tsx,jsx,ts,js}", path.join(rootPath, dir));
116
- pages.filenames.forEach(f => routes.push({ path: `/${path.dirname(f)}`, file: path.join(dir, f), type: "next-app" }));
117
- }
118
- // Next.js Pages Router
119
- for (const dir of ["src/pages", "pages"]) {
120
- const pages = safeGlob("**/*.{tsx,jsx,ts,js}", path.join(rootPath, dir));
121
- pages.filenames.filter(f => !f.startsWith("api/") && !f.startsWith("_")).forEach(f =>
122
- routes.push({ path: `/${f.replace(/\.(tsx|jsx|ts|js)$/, "").replace(/\/index$/, "")}`, file: path.join(dir, f), type: "next-pages" }),
123
- );
124
- }
125
- // Generic index files
126
- if (routes.length === 0) {
127
- for (const entry of ["src/index.tsx", "src/index.jsx", "src/main.tsx", "src/main.jsx", "index.html"]) {
128
- if (fs.existsSync(path.join(rootPath, entry))) routes.push({ path: "/", file: entry, type: "spa" });
129
- }
130
- }
131
- return { routes, count: routes.length };
132
- }
133
-
134
- function findHooks(rootPath) {
135
- const result = safeGrep("export\\s+(default\\s+)?function\\s+use[A-Z]", rootPath, "*.{ts,tsx,js,jsx}");
136
- const hooks = result.content.split("\n").filter(Boolean).map(line => {
137
- const match = line.match(/^(.+?):(\d+):.*function\s+(use\w+)/);
138
- return match ? { file: match[1], line: parseInt(match[2]), name: match[3] } : null;
139
- }).filter(Boolean);
140
- return { hooks, count: hooks.length };
141
- }
142
-
143
- function findProviders(rootPath) {
144
- const result = safeGrep("createContext|React\\.createContext|<\\w+Provider", rootPath, "*.{tsx,jsx,ts,js}");
145
- const providers = result.content.split("\n").filter(Boolean).map(line => {
146
- const match = line.match(/^(.+?):(\d+):(.*)/);
147
- return match ? { file: match[1], line: parseInt(match[2]), snippet: match[3].trim().slice(0, 100) } : null;
148
- }).filter(Boolean);
149
- return { providers, count: providers.length };
150
- }
151
-
152
- function findSecuritySinks(rootPath) {
153
- const patterns = [
154
- { type: "dangerouslySetInnerHTML", pattern: "dangerouslySetInnerHTML", severity: "P1" },
155
- { type: "innerHTML", pattern: "\\.innerHTML\\s*=", severity: "P1" },
156
- { type: "v-html", pattern: "v-html", severity: "P1" },
157
- { type: "eval", pattern: "\\beval\\s*\\(", severity: "P0" },
158
- { type: "document.write", pattern: "document\\.write\\s*\\(", severity: "P1" },
159
- { type: "srcdoc", pattern: "srcdoc\\s*=", severity: "P2" },
160
- { type: "javascript_url", pattern: 'href\\s*=\\s*["\']javascript:', severity: "P0" },
161
- { type: "svg_script", pattern: "<script[^>]*>", severity: "P1" },
162
- ];
163
- const sinks = [];
164
- for (const { type, pattern, severity } of patterns) {
165
- const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,vue,svelte,ts,js,html}");
166
- result.content.split("\n").filter(Boolean).forEach(line => {
167
- const match = line.match(/^(.+?):(\d+):/);
168
- if (match) sinks.push({ type, file: match[1], line: parseInt(match[2]), severity });
169
- });
170
- }
171
- const counts = { P0: 0, P1: 0, P2: 0 };
172
- sinks.forEach(s => counts[s.severity]++);
173
- return { sinks, totalSinks: sinks.length, ...counts };
174
- }
175
-
176
- function countStateHooks(rootPath) {
177
- const result = safeGrep("useState\\s*[<(]", rootPath, "*.{tsx,jsx}");
178
- const fileCounts = {};
179
- result.content.split("\n").filter(Boolean).forEach(line => {
180
- const match = line.match(/^(.+?):\d+:/);
181
- if (match) fileCounts[match[1]] = (fileCounts[match[1]] || 0) + 1;
182
- });
183
- const components = Object.entries(fileCounts)
184
- .map(([file, count]) => ({
185
- file,
186
- useStateCount: count,
187
- risk: count <= 5 ? "normal" : count <= 10 ? "scrutiny" : count <= 15 ? "refactor" : "god_component",
188
- }))
189
- .sort((a, b) => b.useStateCount - a.useStateCount);
190
- return {
191
- components,
192
- godComponents: components.filter(c => c.risk === "god_component"),
193
- refactorCandidates: components.filter(c => c.risk === "refactor" || c.risk === "god_component"),
194
- };
195
- }
196
-
197
- function checkBundleConfig(rootPath) {
198
- const configs = {};
199
- for (const name of ["next.config.js", "next.config.mjs", "next.config.ts", "vite.config.ts", "vite.config.js", "webpack.config.js"]) {
200
- const fp = path.join(rootPath, name);
201
- if (fs.existsSync(fp)) {
202
- try { configs[name] = fs.readFileSync(fp, "utf-8").slice(0, 2000); } catch { /* skip */ }
203
- }
204
- }
205
- return { configs: Object.keys(configs), details: configs };
206
- }
207
-
208
- function checkSecurityHeaders(rootPath) {
209
- const findings = [];
210
- // Check middleware/headers config
211
- for (const file of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js", "next.config.js", "next.config.mjs"]) {
212
- const fp = path.join(rootPath, file);
213
- if (!fs.existsSync(fp)) continue;
214
- const content = safeReadFile(fp);
215
- if (!content) continue;
216
- const headers = ["Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Strict-Transport-Security", "Referrer-Policy"];
217
- for (const h of headers) {
218
- if (!content.includes(h) && !content.toLowerCase().includes(h.toLowerCase())) {
219
- findings.push({ header: h, file, present: false, severity: h === "Content-Security-Policy" ? "P1" : "P2" });
220
- }
221
- }
222
- }
223
- return { findings, checkedFiles: findings.length > 0 ? [...new Set(findings.map(f => f.file))] : [] };
224
- }
225
-
226
- function findEnvExposure(rootPath) {
227
- const prefixes = ["NEXT_PUBLIC_", "VITE_", "REACT_APP_", "NUXT_PUBLIC_"];
228
- const findings = [];
229
- for (const prefix of prefixes) {
230
- const result = safeGrep(`${prefix}\\w+`, rootPath, "*.{tsx,jsx,ts,js,vue,svelte}");
231
- result.content.split("\n").filter(Boolean).forEach(line => {
232
- const match = line.match(new RegExp(`^(.+?):(\\d+):.*?(${prefix}\\w+)`));
233
- if (match) {
234
- const varName = match[3];
235
- const isSensitive = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i.test(varName);
236
- findings.push({
237
- file: match[1], line: parseInt(match[2]), variable: varName,
238
- severity: isSensitive ? "P1" : "P3", sensitive: isSensitive,
239
- });
240
- }
241
- });
242
- }
243
- return { findings, sensitiveCount: findings.filter(f => f.sensitive).length, totalCount: findings.length };
244
- }
245
-
246
- function findMissingCleanup(rootPath) {
247
- // Find useEffect without return statement (simplified heuristic)
248
- const result = safeGrep("useEffect\\s*\\(", rootPath, "*.{tsx,jsx,ts,js}");
249
- const effectFiles = [...new Set(result.content.split("\n").filter(Boolean).map(l => l.match(/^(.+?):/)?.[1]).filter(Boolean))];
250
- const findings = [];
251
- for (const file of effectFiles.slice(0, 50)) {
252
- const content = safeReadFile(path.resolve(rootPath, file));
253
- if (!content) continue;
254
- const effectBlocks = content.match(/useEffect\s*\(\s*\(\s*\)\s*=>\s*\{[^}]{0,500}\}/g) || [];
255
- for (const block of effectBlocks) {
256
- if (!block.includes("return") && (block.includes("setInterval") || block.includes("addEventListener") || block.includes("subscribe") || block.includes("setTimeout"))) {
257
- findings.push({ file, severity: "P2", pattern: "useEffect with subscription/timer but no cleanup return" });
258
- }
259
- }
260
- }
261
- return { findings, count: findings.length };
262
- }
263
-
264
- function findStaleClosures(rootPath) {
265
- const result = safeGrep("useEffect\\s*\\([^)]*,\\s*\\[\\s*\\]\\s*\\)", rootPath, "*.{tsx,jsx,ts,js}");
266
- const findings = result.content.split("\n").filter(Boolean).map(line => {
267
- const match = line.match(/^(.+?):(\d+):/);
268
- return match ? { file: match[1], line: parseInt(match[2]), severity: "P2", pattern: "useEffect with empty deps — potential stale closure" } : null;
269
- }).filter(Boolean);
270
- return { findings, count: findings.length };
271
- }
272
-
273
- function checkAccessibility(rootPath) {
274
- const checks = [
275
- { pattern: "<img\\s", desc: "img tag found — verify alt attribute present", severity: "P2" },
276
- { pattern: "<button", desc: "button element — verify has accessible label", severity: "P3" },
277
- { pattern: 'role="button"', desc: "div/span with role=button — verify keyboard reachability", severity: "P2" },
278
- { pattern: "tabIndex.*-1", desc: "tabIndex=-1 removes from tab order", severity: "P3" },
279
- { pattern: "aria-hidden", desc: "aria-hidden — verify not hiding interactive content", severity: "P3" },
280
- ];
281
- const findings = [];
282
- for (const { pattern, desc, severity } of checks) {
283
- const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,vue,svelte,html}");
284
- findings.push({ check: desc, matchCount: result.numMatches, severity, files: result.filenames.slice(0, 5) });
285
- }
286
- // Check for skip navigation link
287
- const skipLink = safeGrep("skip.*(nav|content|main)", rootPath, "*.{tsx,jsx,vue,svelte,html}");
288
- findings.push({ check: "skip navigation link present", matchCount: skipLink.numMatches, severity: skipLink.numMatches > 0 ? "pass" : "P3", files: skipLink.filenames.slice(0, 3) });
289
- return { findings, totalChecks: findings.length };
290
- }
291
-
292
- function checkMobileResponsive(rootPath) {
293
- const findings = [];
294
- // Viewport meta
295
- const viewport = safeGrep('name="viewport"', rootPath, "*.{html,tsx,jsx}");
296
- findings.push({ check: "viewport meta tag", present: viewport.numMatches > 0, severity: viewport.numMatches > 0 ? "pass" : "P1" });
297
- // Media queries
298
- const mediaQueries = safeGrep("@media", rootPath, "*.{css,scss,less,tsx,jsx}");
299
- findings.push({ check: "media queries present", count: mediaQueries.numMatches, severity: mediaQueries.numMatches > 0 ? "pass" : "P2" });
300
- // Tailwind responsive prefixes
301
- const tailwind = safeGrep("\\b(sm|md|lg|xl|2xl):", rootPath, "*.{tsx,jsx,vue,svelte,html}");
302
- findings.push({ check: "tailwind responsive prefixes", count: tailwind.numMatches, severity: "info" });
303
- return { findings };
304
- }
305
-
306
- function findTestCoverage(rootPath) {
307
- const components = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
308
- const tests = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
309
- const storyFiles = safeGlob("*.stories.{tsx,jsx,ts,js}", rootPath);
310
- const componentNames = new Set(components.filenames.map(f => path.basename(f, path.extname(f))));
311
- const testedNames = new Set(tests.filenames.map(f => path.basename(f).replace(/\.(test|spec)\.\w+$/, "")));
312
- const untested = [...componentNames].filter(n => !testedNames.has(n));
313
- return {
314
- componentCount: components.numFiles,
315
- testCount: tests.numFiles,
316
- storyCount: storyFiles.numFiles,
317
- coverageRatio: components.numFiles > 0 ? ((testedNames.size / componentNames.size) * 100).toFixed(1) + "%" : "N/A",
318
- untestedComponents: untested.slice(0, 20),
319
- untestedCount: untested.length,
320
- };
321
- }
322
-
323
- function scopeGraph(rootPath) {
324
- const components = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
325
- const configs = safeGlob("*.config.{js,ts,mjs}", rootPath);
326
- const styles = safeGlob("*.{css,scss,less,module.css}", rootPath);
327
- const tests = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
328
- return {
329
- components: components.numFiles,
330
- configs: configs.filenames,
331
- styles: styles.numFiles,
332
- tests: tests.numFiles,
333
- totalFrontendFiles: components.numFiles + configs.numFiles + styles.numFiles,
334
- };
335
- }
336
-
337
- function checkErrorBoundaries(rootPath) {
338
- const errorFiles = safeGrep("error\\.(tsx|jsx|ts|js)$", rootPath, "*.{tsx,jsx,ts,js}");
339
- const errorBoundaryClass = safeGrep("ErrorBoundary|componentDidCatch|getDerivedStateFromError", rootPath, "*.{tsx,jsx,ts,js}");
340
- const routes = findRoutes(rootPath);
341
- return {
342
- errorBoundaryFiles: errorFiles.numMatches + errorBoundaryClass.numMatches,
343
- routeCount: routes.count,
344
- coverage: routes.count > 0 ? `${Math.min(errorFiles.numMatches + errorBoundaryClass.numMatches, routes.count)}/${routes.count}` : "N/A",
345
- severity: (errorFiles.numMatches + errorBoundaryClass.numMatches) === 0 && routes.count > 0 ? "P2" : "pass",
346
- };
347
- }
348
-
349
- function auditNpmDeps(rootPath) {
350
- try {
351
- const output = execFileSync("npm", ["audit", "--json", "--production"], {
352
- cwd: rootPath, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
353
- });
354
- const audit = JSON.parse(output);
355
- return {
356
- vulnerabilities: audit.metadata?.vulnerabilities || {},
357
- totalDeps: audit.metadata?.dependencies || 0,
358
- advisories: Object.values(audit.vulnerabilities || {}).slice(0, 10).map(v => ({
359
- name: v.name, severity: v.severity, range: v.range, fixAvailable: v.fixAvailable,
360
- })),
361
- };
362
- } catch (err) {
363
- try {
364
- const parsed = JSON.parse(err.stdout || "{}");
365
- return { vulnerabilities: parsed.metadata?.vulnerabilities || {}, totalDeps: parsed.metadata?.dependencies || 0, error: null };
366
- } catch {
367
- return { vulnerabilities: {}, totalDeps: 0, error: "npm audit failed" };
368
- }
369
- }
370
- }
371
-
372
- function checkImageOptimization(rootPath) {
373
- const rawImg = safeGrep("<img\\s", rootPath, "*.{tsx,jsx,vue,svelte,html}");
374
- const nextImage = safeGrep("from ['\"]next/image['\"]|<Image\\s", rootPath, "*.{tsx,jsx}");
375
- // Count all img tags, then subtract those with width/height — avoids rg-incompatible lookahead
376
- const allImgTags = safeGrep("<img\\s", rootPath, "*.{tsx,jsx,vue,html}");
377
- const imgWithDimensions = safeGrep("<img[^>]*(width|height)", rootPath, "*.{tsx,jsx,vue,html}");
378
- const missingDimensions = { numMatches: Math.max(0, allImgTags.numMatches - imgWithDimensions.numMatches) };
379
- return {
380
- rawImgTags: rawImg.numMatches,
381
- nextImageUsage: nextImage.numMatches,
382
- missingDimensions: missingDimensions.numMatches,
383
- severity: rawImg.numMatches > 0 && nextImage.numMatches === 0 ? "P2" : "pass",
384
- };
385
- }
386
-
387
- function checkFontLoading(rootPath) {
388
- const fontDisplay = safeGrep("font-display", rootPath, "*.{css,scss,tsx,jsx}");
389
- const preloadFont = safeGrep('rel="preload".*as="font"', rootPath, "*.{tsx,jsx,html}");
390
- const googleFonts = safeGrep("fonts\\.googleapis\\.com", rootPath, "*.{tsx,jsx,html,css}");
391
- return {
392
- fontDisplayUsage: fontDisplay.numMatches,
393
- preloadedFonts: preloadFont.numMatches,
394
- googleFontsUsage: googleFonts.numMatches,
395
- severity: fontDisplay.numMatches === 0 && googleFonts.numMatches > 0 ? "P2" : "pass",
396
- };
397
- }
398
-
399
- function findThirdPartyScripts(rootPath) {
400
- const patterns = [
401
- { name: "Google Analytics", pattern: "gtag|googletagmanager|ga\\(" },
402
- { name: "Segment", pattern: "analytics\\.identify|analytics\\.track|segment\\.com" },
403
- { name: "Sentry", pattern: "sentry\\.io|@sentry/" },
404
- { name: "Intercom", pattern: "intercom|Intercom\\(" },
405
- { name: "Hotjar", pattern: "hotjar" },
406
- { name: "Mixpanel", pattern: "mixpanel" },
407
- { name: "LaunchDarkly", pattern: "launchdarkly" },
408
- { name: "Datadog RUM", pattern: "datadoghq|dd-rum" },
409
- ];
410
- const found = [];
411
- for (const { name, pattern } of patterns) {
412
- const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,ts,js,html}");
413
- if (result.numMatches > 0) found.push({ name, files: result.filenames.slice(0, 3), matches: result.numMatches });
414
- }
415
- return { scripts: found, count: found.length };
416
- }
417
-
418
- function checkServiceWorkers(rootPath) {
419
- const swFiles = safeGlob("**/service-worker*.{js,ts}", rootPath);
420
- const swRegister = safeGrep("serviceWorker\\.register|navigator\\.serviceWorker", rootPath, "*.{tsx,jsx,ts,js}");
421
- return {
422
- serviceWorkerFiles: swFiles.filenames,
423
- registrationPoints: swRegister.numMatches,
424
- present: swFiles.numFiles > 0 || swRegister.numMatches > 0,
425
- };
426
- }
427
-
428
- function checkRealtimeConnections(rootPath) {
429
- const ws = safeGrep("new WebSocket|WebSocket\\(", rootPath, "*.{tsx,jsx,ts,js}");
430
- const sse = safeGrep("new EventSource|EventSource\\(", rootPath, "*.{tsx,jsx,ts,js}");
431
- const socketIo = safeGrep("socket\\.io|io\\(", rootPath, "*.{tsx,jsx,ts,js}");
432
- return {
433
- webSockets: ws.numMatches, serverSentEvents: sse.numMatches, socketIo: socketIo.numMatches,
434
- total: ws.numMatches + sse.numMatches + socketIo.numMatches,
435
- };
436
- }
437
-
438
- function checkCssHealth(rootPath) {
439
- const important = safeGrep("!important", rootPath, "*.{css,scss,less}");
440
- const tailwindConfig = fs.existsSync(path.join(rootPath, "tailwind.config.js")) || fs.existsSync(path.join(rootPath, "tailwind.config.ts"));
441
- const darkMode = safeGrep("dark:|prefers-color-scheme:\\s*dark", rootPath, "*.{css,scss,tsx,jsx,html}");
442
- return {
443
- importantCount: important.numMatches,
444
- tailwindConfigured: tailwindConfig,
445
- darkModeSupport: darkMode.numMatches > 0,
446
- severity: important.numMatches > 20 ? "P2" : "pass",
447
- };
448
- }
449
-
450
- // ── Helpers ──────────────────────────────────────────────────────────
451
-
452
- function readPackageJson(rootPath) {
453
- try { return JSON.parse(fs.readFileSync(path.join(rootPath, "package.json"), "utf-8")); }
454
- catch { return { dependencies: {}, devDependencies: {}, scripts: {} }; }
455
- }
456
-
457
- function detectFromDeps(deps) {
458
- if (deps.next) return { name: "next.js", pkg: "next" };
459
- if (deps.nuxt) return { name: "nuxt", pkg: "nuxt" };
460
- if (deps["@sveltejs/kit"]) return { name: "sveltekit", pkg: "@sveltejs/kit" };
461
- if (deps.svelte) return { name: "svelte", pkg: "svelte" };
462
- if (deps.vue) return { name: "vue", pkg: "vue" };
463
- if (deps["@angular/core"]) return { name: "angular", pkg: "@angular/core" };
464
- if (deps.remix || deps["@remix-run/react"]) return { name: "remix", pkg: "@remix-run/react" };
465
- if (deps.gatsby) return { name: "gatsby", pkg: "gatsby" };
466
- if (deps.react) return { name: "react", pkg: "react" };
467
- return { name: "unknown", pkg: null };
468
- }
469
-
470
- function detectRouter(rootPath, deps) {
471
- if (fs.existsSync(path.join(rootPath, "src/app")) || fs.existsSync(path.join(rootPath, "app"))) return "app";
472
- if (fs.existsSync(path.join(rootPath, "src/pages")) || fs.existsSync(path.join(rootPath, "pages"))) return "pages";
473
- if (deps["react-router-dom"] || deps["react-router"]) return "react-router";
474
- if (deps["vue-router"]) return "vue-router";
475
- return null;
476
- }
477
-
478
- function detectState(deps) {
479
- if (deps.zustand) return "zustand";
480
- if (deps["@reduxjs/toolkit"] || deps.redux) return "redux";
481
- if (deps.jotai) return "jotai";
482
- if (deps.recoil) return "recoil";
483
- if (deps["@tanstack/react-query"]) return "tanstack-query";
484
- if (deps.swr) return "swr";
485
- if (deps.mobx) return "mobx";
486
- if (deps.valtio) return "valtio";
487
- return "context-only";
488
- }
489
-
490
- function detectStyling(deps, rootPath) {
491
- if (deps.tailwindcss || fs.existsSync(path.join(rootPath, "tailwind.config.js")) || fs.existsSync(path.join(rootPath, "tailwind.config.ts"))) return "tailwind";
492
- if (deps["styled-components"]) return "styled-components";
493
- if (deps["@emotion/react"]) return "emotion";
494
- if (deps["@chakra-ui/react"]) return "chakra";
495
- if (deps["@mui/material"]) return "mui";
496
- return "css";
497
- }
498
-
499
- function detectTestRunner(deps) {
500
- if (deps.vitest) return "vitest";
501
- if (deps.jest) return "jest";
502
- if (deps.mocha) return "mocha";
503
- return null;
504
- }
505
-
506
- function detectE2eRunner(deps) {
507
- if (deps.playwright || deps["@playwright/test"]) return "playwright";
508
- if (deps.cypress) return "cypress";
509
- return null;
510
- }
511
-
512
- function detectPackageManager(rootPath) {
513
- if (fs.existsSync(path.join(rootPath, "pnpm-lock.yaml"))) return "pnpm";
514
- if (fs.existsSync(path.join(rootPath, "yarn.lock"))) return "yarn";
515
- if (fs.existsSync(path.join(rootPath, "bun.lockb"))) return "bun";
516
- return "npm";
517
- }
518
-
519
- function detectLinters(deps) {
520
- const linters = [];
521
- if (deps.eslint) linters.push("eslint");
522
- if (deps.prettier) linters.push("prettier");
523
- if (deps.biome || deps["@biomejs/biome"]) linters.push("biome");
524
- return linters;
525
- }
526
-
527
- function detectEntryPoints(rootPath, framework) {
528
- const candidates = framework === "next.js"
529
- ? ["src/app/layout.tsx", "src/app/page.tsx", "app/layout.tsx", "app/page.tsx", "pages/_app.tsx", "pages/index.tsx"]
530
- : ["src/index.tsx", "src/index.jsx", "src/main.tsx", "src/main.jsx", "src/App.tsx", "src/App.jsx", "index.html"];
531
- return candidates.filter(c => fs.existsSync(path.join(rootPath, c)));
532
- }
533
-
534
- function countCustomHooks(rootPath) {
535
- const result = safeGrep("export\\s+(default\\s+)?function\\s+use[A-Z]", rootPath, "*.{ts,tsx,js,jsx}");
536
- return result.numMatches;
537
- }
538
-
539
- function countProviders(rootPath) {
540
- const result = safeGrep("createContext\\(", rootPath, "*.{ts,tsx,js,jsx}");
541
- return result.numMatches;
542
- }
543
-
544
- function estimateFrontendLoc(rootPath) {
545
- const files = safeGlob("*.{tsx,jsx,vue,svelte,css,scss}", rootPath);
546
- // Rough estimate: count files × average LOC
547
- return files.numFiles * 80;
548
- }
549
-
550
- function safeGlob(pattern, rootPath) {
551
- try { return globTool({ pattern, path: rootPath }); }
552
- catch { return { filenames: [], numFiles: 0, truncated: false }; }
553
- }
554
-
555
- function safeGrep(pattern, rootPath, globFilter) {
556
- try { return grepTool({ pattern, path: rootPath, glob: globFilter, output_mode: "content", head_limit: 100 }); }
557
- catch { return { content: "", numMatches: 0, numFiles: 0, filenames: [] }; }
558
- }
559
-
560
- function safeReadFile(filePath) {
561
- try { return fs.readFileSync(filePath, "utf-8"); }
562
- catch { return null; }
563
- }
564
-
565
- export class FrontendAnalyzeError extends Error {
566
- constructor(message) {
567
- super(message);
568
- this.name = "FrontendAnalyzeError";
569
- }
570
- }
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+ import { glob as globTool } from "./glob.js";
5
+ import { grep as grepTool } from "./grep.js";
6
+ import { fileRead } from "./file-read.js";
7
+
8
+ /**
9
+ * FrontendAnalyze — 24 deterministic operations for the Jules Tanaka persona.
10
+ * Each operation runs regex + file-system analysis. No LLM calls.
11
+ * Saves Jules 3-5 tool calls per audit lens by bundling common frontend patterns.
12
+ */
13
+
14
+ const OPERATIONS = new Set([
15
+ "detect_framework", "find_components", "find_routes", "find_hooks",
16
+ "find_providers", "find_security_sinks", "count_state_hooks",
17
+ "check_bundle_config", "check_security_headers", "find_env_exposure",
18
+ "find_missing_cleanup", "find_stale_closures", "check_accessibility",
19
+ "check_mobile_responsive", "find_test_coverage", "scope_graph",
20
+ "check_error_boundaries", "audit_npm_deps", "check_image_optimization",
21
+ "check_font_loading", "find_third_party_scripts", "check_service_workers",
22
+ "check_realtime_connections", "check_css_health",
23
+ ]);
24
+
25
+ /**
26
+ * @param {{ operation: string, path?: string, format?: string }} input
27
+ * @returns {object} Structured JSON result per operation.
28
+ */
29
+ export function frontendAnalyze(input) {
30
+ if (!input.operation || !OPERATIONS.has(input.operation)) {
31
+ throw new FrontendAnalyzeError(
32
+ `Unknown operation: ${input.operation}. Valid: ${[...OPERATIONS].join(", ")}`,
33
+ );
34
+ }
35
+ const rootPath = input.path ? path.resolve(input.path) : process.cwd();
36
+ if (!fs.existsSync(rootPath)) {
37
+ throw new FrontendAnalyzeError(`Path not found: ${rootPath}`);
38
+ }
39
+ return DISPATCH[input.operation](rootPath);
40
+ }
41
+
42
+ // ── Operations ───────────────────────────────────────────────────────
43
+
44
+ const DISPATCH = {
45
+ detect_framework: detectFramework,
46
+ find_components: findComponents,
47
+ find_routes: findRoutes,
48
+ find_hooks: findHooks,
49
+ find_providers: findProviders,
50
+ find_security_sinks: findSecuritySinks,
51
+ count_state_hooks: countStateHooks,
52
+ check_bundle_config: checkBundleConfig,
53
+ check_security_headers: checkSecurityHeaders,
54
+ find_env_exposure: findEnvExposure,
55
+ find_missing_cleanup: findMissingCleanup,
56
+ find_stale_closures: findStaleClosures,
57
+ check_accessibility: checkAccessibility,
58
+ check_mobile_responsive: checkMobileResponsive,
59
+ find_test_coverage: findTestCoverage,
60
+ scope_graph: scopeGraph,
61
+ check_error_boundaries: checkErrorBoundaries,
62
+ audit_npm_deps: auditNpmDeps,
63
+ check_image_optimization: checkImageOptimization,
64
+ check_font_loading: checkFontLoading,
65
+ find_third_party_scripts: findThirdPartyScripts,
66
+ check_service_workers: checkServiceWorkers,
67
+ check_realtime_connections: checkRealtimeConnections,
68
+ check_css_health: checkCssHealth,
69
+ };
70
+
71
+ function detectFramework(rootPath) {
72
+ const pkg = readPackageJson(rootPath);
73
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
74
+ const framework = detectFromDeps(deps);
75
+ const files = safeGlob("*.{tsx,jsx,vue,svelte,ts,js}", rootPath);
76
+ const testFiles = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
77
+ return {
78
+ framework: framework.name,
79
+ version: deps[framework.pkg] || null,
80
+ router: detectRouter(rootPath, deps),
81
+ typescript: !!deps.typescript,
82
+ stateManagement: detectState(deps),
83
+ styling: detectStyling(deps, rootPath),
84
+ testing: {
85
+ unit: detectTestRunner(deps),
86
+ e2e: detectE2eRunner(deps),
87
+ component: deps["@testing-library/react"] ? "testing-library" : null,
88
+ },
89
+ packageManager: detectPackageManager(rootPath),
90
+ linting: detectLinters(deps),
91
+ entryPoints: detectEntryPoints(rootPath, framework.name),
92
+ componentCount: files.filenames.filter(f => /\.(tsx|jsx|vue|svelte)$/.test(f)).length,
93
+ hookCount: countCustomHooks(rootPath),
94
+ providerCount: countProviders(rootPath),
95
+ totalFrontendLoc: estimateFrontendLoc(rootPath),
96
+ };
97
+ }
98
+
99
+ function findComponents(rootPath) {
100
+ const files = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
101
+ return {
102
+ components: files.filenames.map(f => ({
103
+ path: f,
104
+ name: path.basename(f, path.extname(f)),
105
+ type: path.extname(f).slice(1),
106
+ })),
107
+ count: files.numFiles,
108
+ };
109
+ }
110
+
111
+ function findRoutes(rootPath) {
112
+ const routes = [];
113
+ // Next.js App Router
114
+ for (const dir of ["src/app", "app"]) {
115
+ const pages = safeGlob("**/page.{tsx,jsx,ts,js}", path.join(rootPath, dir));
116
+ pages.filenames.forEach(f => routes.push({ path: `/${path.dirname(f)}`, file: path.join(dir, f), type: "next-app" }));
117
+ }
118
+ // Next.js Pages Router
119
+ for (const dir of ["src/pages", "pages"]) {
120
+ const pages = safeGlob("**/*.{tsx,jsx,ts,js}", path.join(rootPath, dir));
121
+ pages.filenames.filter(f => !f.startsWith("api/") && !f.startsWith("_")).forEach(f =>
122
+ routes.push({ path: `/${f.replace(/\.(tsx|jsx|ts|js)$/, "").replace(/\/index$/, "")}`, file: path.join(dir, f), type: "next-pages" }),
123
+ );
124
+ }
125
+ // Generic index files
126
+ if (routes.length === 0) {
127
+ for (const entry of ["src/index.tsx", "src/index.jsx", "src/main.tsx", "src/main.jsx", "index.html"]) {
128
+ if (fs.existsSync(path.join(rootPath, entry))) routes.push({ path: "/", file: entry, type: "spa" });
129
+ }
130
+ }
131
+ return { routes, count: routes.length };
132
+ }
133
+
134
+ function findHooks(rootPath) {
135
+ const result = safeGrep("export\\s+(default\\s+)?function\\s+use[A-Z]", rootPath, "*.{ts,tsx,js,jsx}");
136
+ const hooks = result.content.split("\n").filter(Boolean).map(line => {
137
+ const match = line.match(/^(.+?):(\d+):.*function\s+(use\w+)/);
138
+ return match ? { file: match[1], line: parseInt(match[2]), name: match[3] } : null;
139
+ }).filter(Boolean);
140
+ return { hooks, count: hooks.length };
141
+ }
142
+
143
+ function findProviders(rootPath) {
144
+ const result = safeGrep("createContext|React\\.createContext|<\\w+Provider", rootPath, "*.{tsx,jsx,ts,js}");
145
+ const providers = result.content.split("\n").filter(Boolean).map(line => {
146
+ const match = line.match(/^(.+?):(\d+):(.*)/);
147
+ return match ? { file: match[1], line: parseInt(match[2]), snippet: match[3].trim().slice(0, 100) } : null;
148
+ }).filter(Boolean);
149
+ return { providers, count: providers.length };
150
+ }
151
+
152
+ function findSecuritySinks(rootPath) {
153
+ const patterns = [
154
+ { type: "dangerouslySetInnerHTML", pattern: "dangerouslySetInnerHTML", severity: "P1" },
155
+ { type: "innerHTML", pattern: "\\.innerHTML\\s*=", severity: "P1" },
156
+ { type: "v-html", pattern: "v-html", severity: "P1" },
157
+ { type: "eval", pattern: "\\beval\\s*\\(", severity: "P0" },
158
+ { type: "document.write", pattern: "document\\.write\\s*\\(", severity: "P1" },
159
+ { type: "srcdoc", pattern: "srcdoc\\s*=", severity: "P2" },
160
+ { type: "javascript_url", pattern: 'href\\s*=\\s*["\']javascript:', severity: "P0" },
161
+ { type: "svg_script", pattern: "<script[^>]*>", severity: "P1" },
162
+ ];
163
+ const sinks = [];
164
+ for (const { type, pattern, severity } of patterns) {
165
+ const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,vue,svelte,ts,js,html}");
166
+ result.content.split("\n").filter(Boolean).forEach(line => {
167
+ const match = line.match(/^(.+?):(\d+):/);
168
+ if (match) sinks.push({ type, file: match[1], line: parseInt(match[2]), severity });
169
+ });
170
+ }
171
+ const counts = { P0: 0, P1: 0, P2: 0 };
172
+ sinks.forEach(s => counts[s.severity]++);
173
+ return { sinks, totalSinks: sinks.length, ...counts };
174
+ }
175
+
176
+ function countStateHooks(rootPath) {
177
+ const result = safeGrep("useState\\s*[<(]", rootPath, "*.{tsx,jsx}");
178
+ const fileCounts = {};
179
+ result.content.split("\n").filter(Boolean).forEach(line => {
180
+ const match = line.match(/^(.+?):\d+:/);
181
+ if (match) fileCounts[match[1]] = (fileCounts[match[1]] || 0) + 1;
182
+ });
183
+ const components = Object.entries(fileCounts)
184
+ .map(([file, count]) => ({
185
+ file,
186
+ useStateCount: count,
187
+ risk: count <= 5 ? "normal" : count <= 10 ? "scrutiny" : count <= 15 ? "refactor" : "god_component",
188
+ }))
189
+ .sort((a, b) => b.useStateCount - a.useStateCount);
190
+ return {
191
+ components,
192
+ godComponents: components.filter(c => c.risk === "god_component"),
193
+ refactorCandidates: components.filter(c => c.risk === "refactor" || c.risk === "god_component"),
194
+ };
195
+ }
196
+
197
+ function checkBundleConfig(rootPath) {
198
+ const configs = {};
199
+ for (const name of ["next.config.js", "next.config.mjs", "next.config.ts", "vite.config.ts", "vite.config.js", "webpack.config.js"]) {
200
+ const fp = path.join(rootPath, name);
201
+ if (fs.existsSync(fp)) {
202
+ try { configs[name] = fs.readFileSync(fp, "utf-8").slice(0, 2000); } catch { /* skip */ }
203
+ }
204
+ }
205
+ return { configs: Object.keys(configs), details: configs };
206
+ }
207
+
208
+ function checkSecurityHeaders(rootPath) {
209
+ const findings = [];
210
+ // Check middleware/headers config
211
+ for (const file of ["middleware.ts", "middleware.js", "src/middleware.ts", "src/middleware.js", "next.config.js", "next.config.mjs"]) {
212
+ const fp = path.join(rootPath, file);
213
+ if (!fs.existsSync(fp)) continue;
214
+ const content = safeReadFile(fp);
215
+ if (!content) continue;
216
+ const headers = ["Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Strict-Transport-Security", "Referrer-Policy"];
217
+ for (const h of headers) {
218
+ if (!content.includes(h) && !content.toLowerCase().includes(h.toLowerCase())) {
219
+ findings.push({ header: h, file, present: false, severity: h === "Content-Security-Policy" ? "P1" : "P2" });
220
+ }
221
+ }
222
+ }
223
+ return { findings, checkedFiles: findings.length > 0 ? [...new Set(findings.map(f => f.file))] : [] };
224
+ }
225
+
226
+ function findEnvExposure(rootPath) {
227
+ const prefixes = ["NEXT_PUBLIC_", "VITE_", "REACT_APP_", "NUXT_PUBLIC_"];
228
+ const findings = [];
229
+ for (const prefix of prefixes) {
230
+ const result = safeGrep(`${prefix}\\w+`, rootPath, "*.{tsx,jsx,ts,js,vue,svelte}");
231
+ result.content.split("\n").filter(Boolean).forEach(line => {
232
+ const match = line.match(new RegExp(`^(.+?):(\\d+):.*?(${prefix}\\w+)`));
233
+ if (match) {
234
+ const varName = match[3];
235
+ const isSensitive = /KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL/i.test(varName);
236
+ findings.push({
237
+ file: match[1], line: parseInt(match[2]), variable: varName,
238
+ severity: isSensitive ? "P1" : "P3", sensitive: isSensitive,
239
+ });
240
+ }
241
+ });
242
+ }
243
+ return { findings, sensitiveCount: findings.filter(f => f.sensitive).length, totalCount: findings.length };
244
+ }
245
+
246
+ function findMissingCleanup(rootPath) {
247
+ // Find useEffect without return statement (simplified heuristic)
248
+ const result = safeGrep("useEffect\\s*\\(", rootPath, "*.{tsx,jsx,ts,js}");
249
+ const effectFiles = [...new Set(result.content.split("\n").filter(Boolean).map(l => l.match(/^(.+?):/)?.[1]).filter(Boolean))];
250
+ const findings = [];
251
+ for (const file of effectFiles.slice(0, 50)) {
252
+ const content = safeReadFile(path.resolve(rootPath, file));
253
+ if (!content) continue;
254
+ const effectBlocks = content.match(/useEffect\s*\(\s*\(\s*\)\s*=>\s*\{[^}]{0,500}\}/g) || [];
255
+ for (const block of effectBlocks) {
256
+ if (!block.includes("return") && (block.includes("setInterval") || block.includes("addEventListener") || block.includes("subscribe") || block.includes("setTimeout"))) {
257
+ findings.push({ file, severity: "P2", pattern: "useEffect with subscription/timer but no cleanup return" });
258
+ }
259
+ }
260
+ }
261
+ return { findings, count: findings.length };
262
+ }
263
+
264
+ function findStaleClosures(rootPath) {
265
+ const result = safeGrep("useEffect\\s*\\([^)]*,\\s*\\[\\s*\\]\\s*\\)", rootPath, "*.{tsx,jsx,ts,js}");
266
+ const findings = result.content.split("\n").filter(Boolean).map(line => {
267
+ const match = line.match(/^(.+?):(\d+):/);
268
+ return match ? { file: match[1], line: parseInt(match[2]), severity: "P2", pattern: "useEffect with empty deps — potential stale closure" } : null;
269
+ }).filter(Boolean);
270
+ return { findings, count: findings.length };
271
+ }
272
+
273
+ function checkAccessibility(rootPath) {
274
+ const checks = [
275
+ { pattern: "<img\\s", desc: "img tag found — verify alt attribute present", severity: "P2" },
276
+ { pattern: "<button", desc: "button element — verify has accessible label", severity: "P3" },
277
+ { pattern: 'role="button"', desc: "div/span with role=button — verify keyboard reachability", severity: "P2" },
278
+ { pattern: "tabIndex.*-1", desc: "tabIndex=-1 removes from tab order", severity: "P3" },
279
+ { pattern: "aria-hidden", desc: "aria-hidden — verify not hiding interactive content", severity: "P3" },
280
+ ];
281
+ const findings = [];
282
+ for (const { pattern, desc, severity } of checks) {
283
+ const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,vue,svelte,html}");
284
+ findings.push({ check: desc, matchCount: result.numMatches, severity, files: result.filenames.slice(0, 5) });
285
+ }
286
+ // Check for skip navigation link
287
+ const skipLink = safeGrep("skip.*(nav|content|main)", rootPath, "*.{tsx,jsx,vue,svelte,html}");
288
+ findings.push({ check: "skip navigation link present", matchCount: skipLink.numMatches, severity: skipLink.numMatches > 0 ? "pass" : "P3", files: skipLink.filenames.slice(0, 3) });
289
+ return { findings, totalChecks: findings.length };
290
+ }
291
+
292
+ function checkMobileResponsive(rootPath) {
293
+ const findings = [];
294
+ // Viewport meta
295
+ const viewport = safeGrep('name="viewport"', rootPath, "*.{html,tsx,jsx}");
296
+ findings.push({ check: "viewport meta tag", present: viewport.numMatches > 0, severity: viewport.numMatches > 0 ? "pass" : "P1" });
297
+ // Media queries
298
+ const mediaQueries = safeGrep("@media", rootPath, "*.{css,scss,less,tsx,jsx}");
299
+ findings.push({ check: "media queries present", count: mediaQueries.numMatches, severity: mediaQueries.numMatches > 0 ? "pass" : "P2" });
300
+ // Tailwind responsive prefixes
301
+ const tailwind = safeGrep("\\b(sm|md|lg|xl|2xl):", rootPath, "*.{tsx,jsx,vue,svelte,html}");
302
+ findings.push({ check: "tailwind responsive prefixes", count: tailwind.numMatches, severity: "info" });
303
+ return { findings };
304
+ }
305
+
306
+ function findTestCoverage(rootPath) {
307
+ const components = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
308
+ const tests = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
309
+ const storyFiles = safeGlob("*.stories.{tsx,jsx,ts,js}", rootPath);
310
+ const componentNames = new Set(components.filenames.map(f => path.basename(f, path.extname(f))));
311
+ const testedNames = new Set(tests.filenames.map(f => path.basename(f).replace(/\.(test|spec)\.\w+$/, "")));
312
+ const untested = [...componentNames].filter(n => !testedNames.has(n));
313
+ return {
314
+ componentCount: components.numFiles,
315
+ testCount: tests.numFiles,
316
+ storyCount: storyFiles.numFiles,
317
+ coverageRatio: components.numFiles > 0 ? ((testedNames.size / componentNames.size) * 100).toFixed(1) + "%" : "N/A",
318
+ untestedComponents: untested.slice(0, 20),
319
+ untestedCount: untested.length,
320
+ };
321
+ }
322
+
323
+ function scopeGraph(rootPath) {
324
+ const components = safeGlob("*.{tsx,jsx,vue,svelte}", rootPath);
325
+ const configs = safeGlob("*.config.{js,ts,mjs}", rootPath);
326
+ const styles = safeGlob("*.{css,scss,less,module.css}", rootPath);
327
+ const tests = safeGlob("*.{test,spec}.{tsx,jsx,ts,js,mjs}", rootPath);
328
+ return {
329
+ components: components.numFiles,
330
+ configs: configs.filenames,
331
+ styles: styles.numFiles,
332
+ tests: tests.numFiles,
333
+ totalFrontendFiles: components.numFiles + configs.numFiles + styles.numFiles,
334
+ };
335
+ }
336
+
337
+ function checkErrorBoundaries(rootPath) {
338
+ const errorFiles = safeGrep("error\\.(tsx|jsx|ts|js)$", rootPath, "*.{tsx,jsx,ts,js}");
339
+ const errorBoundaryClass = safeGrep("ErrorBoundary|componentDidCatch|getDerivedStateFromError", rootPath, "*.{tsx,jsx,ts,js}");
340
+ const routes = findRoutes(rootPath);
341
+ return {
342
+ errorBoundaryFiles: errorFiles.numMatches + errorBoundaryClass.numMatches,
343
+ routeCount: routes.count,
344
+ coverage: routes.count > 0 ? `${Math.min(errorFiles.numMatches + errorBoundaryClass.numMatches, routes.count)}/${routes.count}` : "N/A",
345
+ severity: (errorFiles.numMatches + errorBoundaryClass.numMatches) === 0 && routes.count > 0 ? "P2" : "pass",
346
+ };
347
+ }
348
+
349
+ function auditNpmDeps(rootPath) {
350
+ try {
351
+ const output = execFileSync("npm", ["audit", "--json", "--production"], {
352
+ cwd: rootPath, encoding: "utf-8", timeout: 30000, stdio: ["pipe", "pipe", "pipe"],
353
+ });
354
+ const audit = JSON.parse(output);
355
+ return {
356
+ vulnerabilities: audit.metadata?.vulnerabilities || {},
357
+ totalDeps: audit.metadata?.dependencies || 0,
358
+ advisories: Object.values(audit.vulnerabilities || {}).slice(0, 10).map(v => ({
359
+ name: v.name, severity: v.severity, range: v.range, fixAvailable: v.fixAvailable,
360
+ })),
361
+ };
362
+ } catch (err) {
363
+ try {
364
+ const parsed = JSON.parse(err.stdout || "{}");
365
+ return { vulnerabilities: parsed.metadata?.vulnerabilities || {}, totalDeps: parsed.metadata?.dependencies || 0, error: null };
366
+ } catch {
367
+ return { vulnerabilities: {}, totalDeps: 0, error: "npm audit failed" };
368
+ }
369
+ }
370
+ }
371
+
372
+ function checkImageOptimization(rootPath) {
373
+ const rawImg = safeGrep("<img\\s", rootPath, "*.{tsx,jsx,vue,svelte,html}");
374
+ const nextImage = safeGrep("from ['\"]next/image['\"]|<Image\\s", rootPath, "*.{tsx,jsx}");
375
+ // Count all img tags, then subtract those with width/height — avoids rg-incompatible lookahead
376
+ const allImgTags = safeGrep("<img\\s", rootPath, "*.{tsx,jsx,vue,html}");
377
+ const imgWithDimensions = safeGrep("<img[^>]*(width|height)", rootPath, "*.{tsx,jsx,vue,html}");
378
+ const missingDimensions = { numMatches: Math.max(0, allImgTags.numMatches - imgWithDimensions.numMatches) };
379
+ return {
380
+ rawImgTags: rawImg.numMatches,
381
+ nextImageUsage: nextImage.numMatches,
382
+ missingDimensions: missingDimensions.numMatches,
383
+ severity: rawImg.numMatches > 0 && nextImage.numMatches === 0 ? "P2" : "pass",
384
+ };
385
+ }
386
+
387
+ function checkFontLoading(rootPath) {
388
+ const fontDisplay = safeGrep("font-display", rootPath, "*.{css,scss,tsx,jsx}");
389
+ const preloadFont = safeGrep('rel="preload".*as="font"', rootPath, "*.{tsx,jsx,html}");
390
+ const googleFonts = safeGrep("fonts\\.googleapis\\.com", rootPath, "*.{tsx,jsx,html,css}");
391
+ return {
392
+ fontDisplayUsage: fontDisplay.numMatches,
393
+ preloadedFonts: preloadFont.numMatches,
394
+ googleFontsUsage: googleFonts.numMatches,
395
+ severity: fontDisplay.numMatches === 0 && googleFonts.numMatches > 0 ? "P2" : "pass",
396
+ };
397
+ }
398
+
399
+ function findThirdPartyScripts(rootPath) {
400
+ const patterns = [
401
+ { name: "Google Analytics", pattern: "gtag|googletagmanager|ga\\(" },
402
+ { name: "Segment", pattern: "analytics\\.identify|analytics\\.track|segment\\.com" },
403
+ { name: "Sentry", pattern: "sentry\\.io|@sentry/" },
404
+ { name: "Intercom", pattern: "intercom|Intercom\\(" },
405
+ { name: "Hotjar", pattern: "hotjar" },
406
+ { name: "Mixpanel", pattern: "mixpanel" },
407
+ { name: "LaunchDarkly", pattern: "launchdarkly" },
408
+ { name: "Datadog RUM", pattern: "datadoghq|dd-rum" },
409
+ ];
410
+ const found = [];
411
+ for (const { name, pattern } of patterns) {
412
+ const result = safeGrep(pattern, rootPath, "*.{tsx,jsx,ts,js,html}");
413
+ if (result.numMatches > 0) found.push({ name, files: result.filenames.slice(0, 3), matches: result.numMatches });
414
+ }
415
+ return { scripts: found, count: found.length };
416
+ }
417
+
418
+ function checkServiceWorkers(rootPath) {
419
+ const swFiles = safeGlob("**/service-worker*.{js,ts}", rootPath);
420
+ const swRegister = safeGrep("serviceWorker\\.register|navigator\\.serviceWorker", rootPath, "*.{tsx,jsx,ts,js}");
421
+ return {
422
+ serviceWorkerFiles: swFiles.filenames,
423
+ registrationPoints: swRegister.numMatches,
424
+ present: swFiles.numFiles > 0 || swRegister.numMatches > 0,
425
+ };
426
+ }
427
+
428
+ function checkRealtimeConnections(rootPath) {
429
+ const ws = safeGrep("new WebSocket|WebSocket\\(", rootPath, "*.{tsx,jsx,ts,js}");
430
+ const sse = safeGrep("new EventSource|EventSource\\(", rootPath, "*.{tsx,jsx,ts,js}");
431
+ const socketIo = safeGrep("socket\\.io|io\\(", rootPath, "*.{tsx,jsx,ts,js}");
432
+ return {
433
+ webSockets: ws.numMatches, serverSentEvents: sse.numMatches, socketIo: socketIo.numMatches,
434
+ total: ws.numMatches + sse.numMatches + socketIo.numMatches,
435
+ };
436
+ }
437
+
438
+ function checkCssHealth(rootPath) {
439
+ const important = safeGrep("!important", rootPath, "*.{css,scss,less}");
440
+ const tailwindConfig = fs.existsSync(path.join(rootPath, "tailwind.config.js")) || fs.existsSync(path.join(rootPath, "tailwind.config.ts"));
441
+ const darkMode = safeGrep("dark:|prefers-color-scheme:\\s*dark", rootPath, "*.{css,scss,tsx,jsx,html}");
442
+ return {
443
+ importantCount: important.numMatches,
444
+ tailwindConfigured: tailwindConfig,
445
+ darkModeSupport: darkMode.numMatches > 0,
446
+ severity: important.numMatches > 20 ? "P2" : "pass",
447
+ };
448
+ }
449
+
450
+ // ── Helpers ──────────────────────────────────────────────────────────
451
+
452
+ function readPackageJson(rootPath) {
453
+ try { return JSON.parse(fs.readFileSync(path.join(rootPath, "package.json"), "utf-8")); }
454
+ catch { return { dependencies: {}, devDependencies: {}, scripts: {} }; }
455
+ }
456
+
457
+ function detectFromDeps(deps) {
458
+ if (deps.next) return { name: "next.js", pkg: "next" };
459
+ if (deps.nuxt) return { name: "nuxt", pkg: "nuxt" };
460
+ if (deps["@sveltejs/kit"]) return { name: "sveltekit", pkg: "@sveltejs/kit" };
461
+ if (deps.svelte) return { name: "svelte", pkg: "svelte" };
462
+ if (deps.vue) return { name: "vue", pkg: "vue" };
463
+ if (deps["@angular/core"]) return { name: "angular", pkg: "@angular/core" };
464
+ if (deps.remix || deps["@remix-run/react"]) return { name: "remix", pkg: "@remix-run/react" };
465
+ if (deps.gatsby) return { name: "gatsby", pkg: "gatsby" };
466
+ if (deps.react) return { name: "react", pkg: "react" };
467
+ return { name: "unknown", pkg: null };
468
+ }
469
+
470
+ function detectRouter(rootPath, deps) {
471
+ if (fs.existsSync(path.join(rootPath, "src/app")) || fs.existsSync(path.join(rootPath, "app"))) return "app";
472
+ if (fs.existsSync(path.join(rootPath, "src/pages")) || fs.existsSync(path.join(rootPath, "pages"))) return "pages";
473
+ if (deps["react-router-dom"] || deps["react-router"]) return "react-router";
474
+ if (deps["vue-router"]) return "vue-router";
475
+ return null;
476
+ }
477
+
478
+ function detectState(deps) {
479
+ if (deps.zustand) return "zustand";
480
+ if (deps["@reduxjs/toolkit"] || deps.redux) return "redux";
481
+ if (deps.jotai) return "jotai";
482
+ if (deps.recoil) return "recoil";
483
+ if (deps["@tanstack/react-query"]) return "tanstack-query";
484
+ if (deps.swr) return "swr";
485
+ if (deps.mobx) return "mobx";
486
+ if (deps.valtio) return "valtio";
487
+ return "context-only";
488
+ }
489
+
490
+ function detectStyling(deps, rootPath) {
491
+ if (deps.tailwindcss || fs.existsSync(path.join(rootPath, "tailwind.config.js")) || fs.existsSync(path.join(rootPath, "tailwind.config.ts"))) return "tailwind";
492
+ if (deps["styled-components"]) return "styled-components";
493
+ if (deps["@emotion/react"]) return "emotion";
494
+ if (deps["@chakra-ui/react"]) return "chakra";
495
+ if (deps["@mui/material"]) return "mui";
496
+ return "css";
497
+ }
498
+
499
+ function detectTestRunner(deps) {
500
+ if (deps.vitest) return "vitest";
501
+ if (deps.jest) return "jest";
502
+ if (deps.mocha) return "mocha";
503
+ return null;
504
+ }
505
+
506
+ function detectE2eRunner(deps) {
507
+ if (deps.playwright || deps["@playwright/test"]) return "playwright";
508
+ if (deps.cypress) return "cypress";
509
+ return null;
510
+ }
511
+
512
+ function detectPackageManager(rootPath) {
513
+ if (fs.existsSync(path.join(rootPath, "pnpm-lock.yaml"))) return "pnpm";
514
+ if (fs.existsSync(path.join(rootPath, "yarn.lock"))) return "yarn";
515
+ if (fs.existsSync(path.join(rootPath, "bun.lockb"))) return "bun";
516
+ return "npm";
517
+ }
518
+
519
+ function detectLinters(deps) {
520
+ const linters = [];
521
+ if (deps.eslint) linters.push("eslint");
522
+ if (deps.prettier) linters.push("prettier");
523
+ if (deps.biome || deps["@biomejs/biome"]) linters.push("biome");
524
+ return linters;
525
+ }
526
+
527
+ function detectEntryPoints(rootPath, framework) {
528
+ const candidates = framework === "next.js"
529
+ ? ["src/app/layout.tsx", "src/app/page.tsx", "app/layout.tsx", "app/page.tsx", "pages/_app.tsx", "pages/index.tsx"]
530
+ : ["src/index.tsx", "src/index.jsx", "src/main.tsx", "src/main.jsx", "src/App.tsx", "src/App.jsx", "index.html"];
531
+ return candidates.filter(c => fs.existsSync(path.join(rootPath, c)));
532
+ }
533
+
534
+ function countCustomHooks(rootPath) {
535
+ const result = safeGrep("export\\s+(default\\s+)?function\\s+use[A-Z]", rootPath, "*.{ts,tsx,js,jsx}");
536
+ return result.numMatches;
537
+ }
538
+
539
+ function countProviders(rootPath) {
540
+ const result = safeGrep("createContext\\(", rootPath, "*.{ts,tsx,js,jsx}");
541
+ return result.numMatches;
542
+ }
543
+
544
+ function estimateFrontendLoc(rootPath) {
545
+ const files = safeGlob("*.{tsx,jsx,vue,svelte,css,scss}", rootPath);
546
+ // Rough estimate: count files × average LOC
547
+ return files.numFiles * 80;
548
+ }
549
+
550
+ function safeGlob(pattern, rootPath) {
551
+ try { return globTool({ pattern, path: rootPath }); }
552
+ catch { return { filenames: [], numFiles: 0, truncated: false }; }
553
+ }
554
+
555
+ function safeGrep(pattern, rootPath, globFilter) {
556
+ try { return grepTool({ pattern, path: rootPath, glob: globFilter, output_mode: "content", head_limit: 100 }); }
557
+ catch { return { content: "", numMatches: 0, numFiles: 0, filenames: [] }; }
558
+ }
559
+
560
+ function safeReadFile(filePath) {
561
+ try { return fs.readFileSync(filePath, "utf-8"); }
562
+ catch { return null; }
563
+ }
564
+
565
+ export class FrontendAnalyzeError extends Error {
566
+ constructor(message) {
567
+ super(message);
568
+ this.name = "FrontendAnalyzeError";
569
+ }
570
+ }