@veraxhq/verax 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +14 -36
  4. package/src/cli/commands/default.js +523 -0
  5. package/src/cli/commands/doctor.js +165 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +402 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +296 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +34 -0
  14. package/src/cli/util/expectation-extractor.js +378 -0
  15. package/src/cli/util/findings-writer.js +31 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +366 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +29 -0
  21. package/src/cli/util/project-discovery.js +277 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/summary-writer.js +32 -0
  26. package/src/verax/cli/ci-summary.js +35 -0
  27. package/src/verax/cli/context-explanation.js +89 -0
  28. package/src/verax/cli/doctor.js +277 -0
  29. package/src/verax/cli/error-normalizer.js +154 -0
  30. package/src/verax/cli/explain-output.js +105 -0
  31. package/src/verax/cli/finding-explainer.js +130 -0
  32. package/src/verax/cli/init.js +237 -0
  33. package/src/verax/cli/run-overview.js +163 -0
  34. package/src/verax/cli/url-safety.js +101 -0
  35. package/src/verax/cli/wizard.js +98 -0
  36. package/src/verax/cli/zero-findings-explainer.js +57 -0
  37. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  38. package/src/verax/core/action-classifier.js +86 -0
  39. package/src/verax/core/budget-engine.js +218 -0
  40. package/src/verax/core/canonical-outcomes.js +157 -0
  41. package/src/verax/core/decision-snapshot.js +335 -0
  42. package/src/verax/core/determinism-model.js +403 -0
  43. package/src/verax/core/incremental-store.js +237 -0
  44. package/src/verax/core/invariants.js +356 -0
  45. package/src/verax/core/promise-model.js +230 -0
  46. package/src/verax/core/replay-validator.js +350 -0
  47. package/src/verax/core/replay.js +222 -0
  48. package/src/verax/core/run-id.js +175 -0
  49. package/src/verax/core/run-manifest.js +99 -0
  50. package/src/verax/core/silence-impact.js +369 -0
  51. package/src/verax/core/silence-model.js +521 -0
  52. package/src/verax/detect/comparison.js +2 -34
  53. package/src/verax/detect/confidence-engine.js +764 -329
  54. package/src/verax/detect/detection-engine.js +293 -0
  55. package/src/verax/detect/evidence-index.js +177 -0
  56. package/src/verax/detect/expectation-model.js +194 -172
  57. package/src/verax/detect/explanation-helpers.js +187 -0
  58. package/src/verax/detect/finding-detector.js +450 -0
  59. package/src/verax/detect/findings-writer.js +44 -8
  60. package/src/verax/detect/flow-detector.js +366 -0
  61. package/src/verax/detect/index.js +172 -286
  62. package/src/verax/detect/interactive-findings.js +613 -0
  63. package/src/verax/detect/signal-mapper.js +308 -0
  64. package/src/verax/detect/verdict-engine.js +563 -0
  65. package/src/verax/evidence-index-writer.js +61 -0
  66. package/src/verax/index.js +90 -14
  67. package/src/verax/intel/effect-detector.js +368 -0
  68. package/src/verax/intel/handler-mapper.js +249 -0
  69. package/src/verax/intel/index.js +281 -0
  70. package/src/verax/intel/route-extractor.js +280 -0
  71. package/src/verax/intel/ts-program.js +256 -0
  72. package/src/verax/intel/vue-navigation-extractor.js +579 -0
  73. package/src/verax/intel/vue-router-extractor.js +323 -0
  74. package/src/verax/learn/action-contract-extractor.js +335 -101
  75. package/src/verax/learn/ast-contract-extractor.js +95 -5
  76. package/src/verax/learn/flow-extractor.js +172 -0
  77. package/src/verax/learn/manifest-writer.js +97 -47
  78. package/src/verax/learn/project-detector.js +40 -0
  79. package/src/verax/learn/route-extractor.js +27 -96
  80. package/src/verax/learn/state-extractor.js +212 -0
  81. package/src/verax/learn/static-extractor-navigation.js +114 -0
  82. package/src/verax/learn/static-extractor-validation.js +88 -0
  83. package/src/verax/learn/static-extractor.js +112 -4
  84. package/src/verax/learn/truth-assessor.js +24 -21
  85. package/src/verax/observe/aria-sensor.js +211 -0
  86. package/src/verax/observe/browser.js +10 -5
  87. package/src/verax/observe/console-sensor.js +1 -17
  88. package/src/verax/observe/domain-boundary.js +10 -1
  89. package/src/verax/observe/expectation-executor.js +512 -0
  90. package/src/verax/observe/flow-matcher.js +143 -0
  91. package/src/verax/observe/focus-sensor.js +196 -0
  92. package/src/verax/observe/human-driver.js +643 -275
  93. package/src/verax/observe/index.js +908 -27
  94. package/src/verax/observe/index.js.backup +1 -0
  95. package/src/verax/observe/interaction-discovery.js +365 -14
  96. package/src/verax/observe/interaction-runner.js +563 -198
  97. package/src/verax/observe/loading-sensor.js +139 -0
  98. package/src/verax/observe/navigation-sensor.js +255 -0
  99. package/src/verax/observe/network-sensor.js +55 -7
  100. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  101. package/src/verax/observe/observed-expectation.js +305 -0
  102. package/src/verax/observe/page-frontier.js +234 -0
  103. package/src/verax/observe/settle.js +37 -17
  104. package/src/verax/observe/state-sensor.js +389 -0
  105. package/src/verax/observe/timing-sensor.js +228 -0
  106. package/src/verax/observe/traces-writer.js +61 -20
  107. package/src/verax/observe/ui-signal-sensor.js +136 -17
  108. package/src/verax/scan-summary-writer.js +77 -15
  109. package/src/verax/shared/artifact-manager.js +110 -8
  110. package/src/verax/shared/budget-profiles.js +136 -0
  111. package/src/verax/shared/ci-detection.js +39 -0
  112. package/src/verax/shared/config-loader.js +170 -0
  113. package/src/verax/shared/dynamic-route-utils.js +218 -0
  114. package/src/verax/shared/expectation-coverage.js +44 -0
  115. package/src/verax/shared/expectation-prover.js +81 -0
  116. package/src/verax/shared/expectation-tracker.js +201 -0
  117. package/src/verax/shared/expectations-writer.js +60 -0
  118. package/src/verax/shared/first-run.js +44 -0
  119. package/src/verax/shared/progress-reporter.js +171 -0
  120. package/src/verax/shared/retry-policy.js +14 -1
  121. package/src/verax/shared/root-artifacts.js +49 -0
  122. package/src/verax/shared/scan-budget.js +86 -0
  123. package/src/verax/shared/url-normalizer.js +162 -0
  124. package/src/verax/shared/zip-artifacts.js +65 -0
  125. package/src/verax/validate/context-validator.js +244 -0
  126. package/src/verax/validate/context-validator.js.bak +0 -0
@@ -0,0 +1,366 @@
1
+ import { chromium } from 'playwright';
2
+ import { writeFileSync, mkdirSync } from 'fs';
3
+ import { resolve, join } from 'path';
4
+ import { redactHeaders, redactUrl, redactBody, redactConsole, getRedactionCounters } from './redact.js';
5
+
6
+ /**
7
+ * Real Browser Observation Engine
8
+ * Monitors expectations from learn.json using actual Playwright browser
9
+ */
10
+
11
+ export async function observeExpectations(expectations, url, evidencePath, onProgress) {
12
+ const observations = [];
13
+ let observed = 0;
14
+ let notObserved = 0;
15
+ const redactionCounters = { headersRedacted: 0, tokensRedacted: 0 };
16
+ let browser = null;
17
+ let page = null;
18
+
19
+ try {
20
+ // Launch browser
21
+ browser = await chromium.launch({
22
+ headless: true,
23
+ });
24
+
25
+ page = await browser.newPage({
26
+ viewport: { width: 1280, height: 800 },
27
+ userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
28
+ });
29
+
30
+ // Set up network and console monitoring
31
+ const networkLogs = [];
32
+ const consoleLogs = [];
33
+
34
+ page.on('request', (request) => {
35
+ const redactedHeaders = redactHeaders(request.headers(), redactionCounters);
36
+ const redactedUrl = redactUrl(request.url(), redactionCounters);
37
+ let redactedBody = null;
38
+ try {
39
+ const body = request.postData();
40
+ redactedBody = body ? redactBody(body, redactionCounters) : null;
41
+ } catch {
42
+ redactedBody = null;
43
+ }
44
+
45
+ networkLogs.push({
46
+ url: redactedUrl,
47
+ method: request.method(),
48
+ headers: redactedHeaders,
49
+ body: redactedBody,
50
+ timestamp: new Date().toISOString(),
51
+ });
52
+ });
53
+
54
+ page.on('console', (msg) => {
55
+ const redactedText = redactConsole(msg.text(), redactionCounters);
56
+ consoleLogs.push({
57
+ type: msg.type(),
58
+ text: redactedText,
59
+ timestamp: new Date().toISOString(),
60
+ });
61
+ });
62
+
63
+ // Navigate to base URL first
64
+ try {
65
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
66
+ } catch (error) {
67
+ // Continue even if initial load fails
68
+ if (onProgress) {
69
+ onProgress({
70
+ event: 'observe:warning',
71
+ message: `Failed to load base URL: ${error.message}`,
72
+ });
73
+ }
74
+ }
75
+
76
+ // Track visited URLs for navigation observations
77
+ const visitedUrls = new Set([url]);
78
+
79
+ // Process each expectation
80
+ for (let i = 0; i < expectations.length; i++) {
81
+ const exp = expectations[i];
82
+ const expNum = i + 1;
83
+
84
+ if (onProgress) {
85
+ onProgress({
86
+ event: 'observe:attempt',
87
+ index: expNum,
88
+ total: expectations.length,
89
+ type: exp.type,
90
+ promise: exp.promise,
91
+ });
92
+ }
93
+
94
+ const observation = {
95
+ id: exp.id,
96
+ type: exp.type,
97
+ promise: exp.promise,
98
+ source: exp.source,
99
+ observed: false,
100
+ observedAt: null,
101
+ evidenceFiles: [],
102
+ reason: null,
103
+ };
104
+
105
+ try {
106
+ let result = false;
107
+ let evidence = null;
108
+
109
+ if (exp.type === 'navigation') {
110
+ result = await observeNavigation(
111
+ page,
112
+ exp,
113
+ url,
114
+ visitedUrls,
115
+ evidencePath,
116
+ expNum
117
+ );
118
+ evidence = result ? `nav_${expNum}_after.png` : null;
119
+ } else if (exp.type === 'network') {
120
+ result = await observeNetwork(page, exp, networkLogs, 5000);
121
+ if (result) {
122
+ const evidenceFile = `network_${expNum}.json`;
123
+ try {
124
+ mkdirSync(evidencePath, { recursive: true });
125
+ const targetUrl = exp.promise.value;
126
+ const relevant = networkLogs.filter((log) =>
127
+ log.url === targetUrl || log.url.includes(targetUrl) || targetUrl.includes(log.url)
128
+ );
129
+ writeFileSync(resolve(evidencePath, evidenceFile), JSON.stringify(relevant, null, 2), 'utf-8');
130
+ } catch {
131
+ // best effort
132
+ }
133
+ evidence = evidenceFile;
134
+ } else {
135
+ evidence = null;
136
+ }
137
+ } else if (exp.type === 'state') {
138
+ result = await observeState(page, exp, evidencePath, expNum);
139
+ evidence = result ? `state_${expNum}_after.png` : null;
140
+ }
141
+
142
+ if (result) {
143
+ observation.observed = true;
144
+ observation.observedAt = new Date().toISOString();
145
+ if (evidence) observation.evidenceFiles.push(evidence);
146
+ observed++;
147
+ } else {
148
+ observation.reason = 'No matching event observed';
149
+ notObserved++;
150
+ }
151
+ } catch (error) {
152
+ observation.reason = `Error: ${error.message}`;
153
+ notObserved++;
154
+ }
155
+
156
+ observations.push(observation);
157
+
158
+ if (onProgress) {
159
+ onProgress({
160
+ event: 'observe:result',
161
+ index: expNum,
162
+ observed: observation.observed,
163
+ reason: observation.reason,
164
+ });
165
+ }
166
+ }
167
+
168
+ // Persist shared evidence
169
+ try {
170
+ mkdirSync(evidencePath, { recursive: true });
171
+ const networkPath = resolve(evidencePath, 'network_logs.json');
172
+ writeFileSync(networkPath, JSON.stringify(networkLogs, null, 2), 'utf-8');
173
+ const consolePath = resolve(evidencePath, 'console_logs.json');
174
+ writeFileSync(consolePath, JSON.stringify(consoleLogs, null, 2), 'utf-8');
175
+ } catch {
176
+ // Best effort; do not throw
177
+ }
178
+
179
+ return {
180
+ observations,
181
+ stats: {
182
+ attempted: expectations.length,
183
+ observed,
184
+ notObserved,
185
+ },
186
+ redaction: getRedactionCounters(redactionCounters),
187
+ observedAt: new Date().toISOString(),
188
+ };
189
+ } finally {
190
+ // Clean up
191
+ if (page) {
192
+ try {
193
+ await page.close();
194
+ } catch (e) {
195
+ // Ignore close errors
196
+ }
197
+ }
198
+ if (browser) {
199
+ try {
200
+ await browser.close();
201
+ } catch (e) {
202
+ // Ignore close errors
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Observe navigation expectation
210
+ * Attempts to find and click element, observes URL/SPA changes
211
+ */
212
+ async function observeNavigation(page, expectation, baseUrl, visitedUrls, evidencePath, expNum) {
213
+ const targetPath = expectation.promise.value;
214
+
215
+ try {
216
+ // Screenshot before interaction
217
+ const beforePath = resolve(evidencePath, `nav_${expNum}_before.png`);
218
+ await page.screenshot({ path: beforePath }).catch(() => {});
219
+
220
+ // Find element by searching all anchor tags
221
+ const element = await page.evaluate((path) => {
222
+ const anchors = Array.from(document.querySelectorAll('a'));
223
+ const found = anchors.find(a => {
224
+ const href = a.getAttribute('href');
225
+ return href === path || href.includes(path);
226
+ });
227
+ return found ? { tag: 'a', href: found.getAttribute('href') } : null;
228
+ }, targetPath);
229
+
230
+ if (!element) {
231
+ return false;
232
+ }
233
+
234
+ const urlBefore = page.url();
235
+ const contentBefore = await page.content();
236
+
237
+ // Click the element - try multiple approaches
238
+ try {
239
+ await page.locator(`a[href="${element.href}"]`).click({ timeout: 3000 });
240
+ } catch (e) {
241
+ try {
242
+ await page.click(`a[href="${element.href}"]`);
243
+ } catch (e2) {
244
+ // Try clicking by text content
245
+ const text = await page.evaluate((href) => {
246
+ const anchors = Array.from(document.querySelectorAll('a'));
247
+ const found = anchors.find(a => a.getAttribute('href') === href);
248
+ return found ? found.textContent : null;
249
+ }, element.href);
250
+
251
+ if (text) {
252
+ await page.click(`a:has-text("${text}")`).catch(() => {});
253
+ }
254
+ }
255
+ }
256
+
257
+ // Wait for navigation or SPA update
258
+ try {
259
+ await page.waitForNavigation({ waitUntil: 'networkidle', timeout: 2000 }).catch(() => {});
260
+ } catch (e) {
261
+ // Navigation might not happen
262
+ }
263
+
264
+ // Wait for potential SPA updates
265
+ await page.waitForTimeout(300);
266
+
267
+ // Screenshot after interaction
268
+ const afterPath = resolve(evidencePath, `nav_${expNum}_after.png`);
269
+ await page.screenshot({ path: afterPath }).catch(() => {});
270
+
271
+ const urlAfter = page.url();
272
+ const contentAfter = await page.content();
273
+
274
+ // Check if URL changed or content changed
275
+ if (urlBefore !== urlAfter || contentBefore !== contentAfter) {
276
+ visitedUrls.add(urlAfter);
277
+ return true;
278
+ }
279
+
280
+ return false;
281
+ } catch (error) {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Observe network expectation
288
+ * Checks if matching request was made
289
+ */
290
+ async function observeNetwork(page, expectation, networkLogs, timeoutMs) {
291
+ const targetUrl = expectation.promise.value;
292
+ const startTime = Date.now();
293
+
294
+ return new Promise((resolve) => {
295
+ const checkTimer = setInterval(() => {
296
+ const found = networkLogs.some((log) => {
297
+ return (
298
+ log.url === targetUrl ||
299
+ log.url.includes(targetUrl) ||
300
+ targetUrl.includes(log.url)
301
+ );
302
+ });
303
+
304
+ if (found) {
305
+ clearInterval(checkTimer);
306
+ resolve(true);
307
+ }
308
+
309
+ if (Date.now() - startTime > timeoutMs) {
310
+ clearInterval(checkTimer);
311
+ resolve(false);
312
+ }
313
+ }, 100);
314
+ });
315
+ }
316
+
317
+ /**
318
+ * Observe state expectation
319
+ * Detects DOM changes or loading indicators
320
+ */
321
+ async function observeState(page, expectation, evidencePath, expNum) {
322
+ try {
323
+ // Screenshot before
324
+ const beforePath = resolve(evidencePath, `state_${expNum}_before.png`);
325
+ await page.screenshot({ path: beforePath });
326
+
327
+ const htmlBefore = await page.content();
328
+
329
+ // Wait briefly for potential state changes
330
+ await page.waitForTimeout(2000);
331
+
332
+ const htmlAfter = await page.content();
333
+
334
+ // Screenshot after
335
+ const afterPath = resolve(evidencePath, `state_${expNum}_after.png`);
336
+ await page.screenshot({ path: afterPath });
337
+
338
+ // Check if DOM changed
339
+ if (htmlBefore !== htmlAfter) {
340
+ return true;
341
+ }
342
+
343
+ // Check for common state indicators (loading, error, success messages)
344
+ const hasStateIndicators =
345
+ (await page.$('.loading')) ||
346
+ (await page.$('[role="status"]')) ||
347
+ (await page.$('.toast')) ||
348
+ (await page.$('[aria-live]'));
349
+
350
+ return !!hasStateIndicators;
351
+ } catch (error) {
352
+ return false;
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Check if page content changed (for SPA detection)
358
+ */
359
+ async function checkPageContentChanged(page) {
360
+ try {
361
+ const bodyText = await page.locator('body').textContent();
362
+ return bodyText && bodyText.length > 0;
363
+ } catch (error) {
364
+ return false;
365
+ }
366
+ }
@@ -0,0 +1,25 @@
1
+ import { atomicWriteJson } from './atomic-write.js';
2
+ import { resolve } from 'path';
3
+
4
+ /**
5
+ * Write observe.json artifact
6
+ */
7
+ export function writeObserveJson(runDir, observeData) {
8
+ const observePath = resolve(runDir, 'observe.json');
9
+
10
+ const payload = {
11
+ observations: observeData.observations || [],
12
+ stats: {
13
+ attempted: observeData.stats?.attempted || 0,
14
+ observed: observeData.stats?.observed || 0,
15
+ notObserved: observeData.stats?.notObserved || 0,
16
+ },
17
+ redaction: {
18
+ headersRedacted: observeData.redaction?.headersRedacted || 0,
19
+ tokensRedacted: observeData.redaction?.tokensRedacted || 0,
20
+ },
21
+ observedAt: observeData.observedAt || new Date().toISOString(),
22
+ };
23
+
24
+ atomicWriteJson(observePath, payload);
25
+ }
@@ -0,0 +1,29 @@
1
+ import { join } from 'path';
2
+ import { mkdirSync } from 'fs';
3
+
4
+ /**
5
+ * Build run artifact paths
6
+ */
7
+ export function getRunPaths(projectRoot, outDir, runId) {
8
+ const baseDir = join(projectRoot, outDir, 'runs', runId);
9
+
10
+ return {
11
+ baseDir,
12
+ runStatusJson: join(baseDir, 'run.status.json'),
13
+ runMetaJson: join(baseDir, 'run.meta.json'),
14
+ summaryJson: join(baseDir, 'summary.json'),
15
+ findingsJson: join(baseDir, 'findings.json'),
16
+ tracesJsonl: join(baseDir, 'traces.jsonl'),
17
+ evidenceDir: join(baseDir, 'evidence'),
18
+ learnJson: join(baseDir, 'learn.json'),
19
+ observeJson: join(baseDir, 'observe.json'),
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Ensure all required directories exist
25
+ */
26
+ export function ensureRunDirectories(paths) {
27
+ mkdirSync(paths.baseDir, { recursive: true });
28
+ mkdirSync(paths.evidenceDir, { recursive: true });
29
+ }
@@ -0,0 +1,277 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { resolve, dirname } from 'path';
3
+
4
+ /**
5
+ * Project Discovery Module
6
+ * Detects framework, router, source root, and dev server configuration
7
+ */
8
+
9
+ export async function discoverProject(srcPath) {
10
+ const projectRoot = resolve(srcPath);
11
+
12
+ // Find the nearest package.json
13
+ const packageJsonPath = findPackageJson(projectRoot);
14
+
15
+ // If there's a package.json, use its directory
16
+ // Otherwise, use the srcPath (even if it's a static HTML project)
17
+ const projectDir = packageJsonPath ? dirname(packageJsonPath) : projectRoot;
18
+
19
+ let packageJson = null;
20
+ if (packageJsonPath && existsSync(packageJsonPath)) {
21
+ try {
22
+ packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
23
+ } catch (error) {
24
+ packageJson = null;
25
+ }
26
+ }
27
+
28
+ // Detect framework
29
+ const framework = detectFramework(projectDir, packageJson);
30
+ const router = detectRouter(framework, projectDir);
31
+
32
+ // Determine package manager
33
+ const packageManager = detectPackageManager(projectDir);
34
+
35
+ // Extract scripts
36
+ const scripts = {
37
+ dev: packageJson?.scripts?.dev || null,
38
+ build: packageJson?.scripts?.build || null,
39
+ start: packageJson?.scripts?.start || null,
40
+ };
41
+
42
+ return {
43
+ framework,
44
+ router,
45
+ sourceRoot: projectDir,
46
+ packageManager,
47
+ scripts,
48
+ detectedAt: new Date().toISOString(),
49
+ packageJsonPath,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Find the nearest package.json by walking up directories
55
+ */
56
+ function findPackageJson(startPath) {
57
+ let currentPath = resolve(startPath);
58
+
59
+ // First check if package.json exists in startPath itself
60
+ const immediatePackage = resolve(currentPath, 'package.json');
61
+ if (existsSync(immediatePackage)) {
62
+ return immediatePackage;
63
+ }
64
+
65
+ // Then walk up (limit to 5 levels for monorepos, not 10)
66
+ for (let i = 0; i < 5; i++) {
67
+ const parentPath = dirname(currentPath);
68
+ if (parentPath === currentPath) {
69
+ // Reached filesystem root
70
+ break;
71
+ }
72
+
73
+ currentPath = parentPath;
74
+ const packageJsonPath = resolve(currentPath, 'package.json');
75
+ if (existsSync(packageJsonPath)) {
76
+ return packageJsonPath;
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Detect the framework type
85
+ */
86
+ function detectFramework(projectDir, packageJson) {
87
+ // Check for Next.js
88
+ if (hasNextJs(projectDir, packageJson)) {
89
+ return 'nextjs';
90
+ }
91
+
92
+ // Check for Vite + React
93
+ if (hasViteReact(projectDir, packageJson)) {
94
+ return 'react-vite';
95
+ }
96
+
97
+ // Check for Create React App
98
+ if (hasCreateReactApp(packageJson)) {
99
+ return 'react-cra';
100
+ }
101
+
102
+ // Check for static HTML
103
+ if (hasStaticHtml(projectDir)) {
104
+ return 'static-html';
105
+ }
106
+
107
+ // Unknown framework
108
+ return 'unknown';
109
+ }
110
+
111
+ /**
112
+ * Detect Next.js
113
+ */
114
+ function hasNextJs(projectDir, packageJson) {
115
+ // Check for next.config.js or next.config.mjs
116
+ const hasNextConfig = existsSync(resolve(projectDir, 'next.config.js')) ||
117
+ existsSync(resolve(projectDir, 'next.config.mjs')) ||
118
+ existsSync(resolve(projectDir, 'next.config.ts'));
119
+
120
+ if (hasNextConfig) {
121
+ return true;
122
+ }
123
+
124
+ // Check for 'next' dependency
125
+ if (packageJson?.dependencies?.next || packageJson?.devDependencies?.next) {
126
+ return true;
127
+ }
128
+
129
+ return false;
130
+ }
131
+
132
+ /**
133
+ * Detect router type for Next.js
134
+ */
135
+ function detectRouter(framework, projectDir) {
136
+ if (framework !== 'nextjs') {
137
+ return null;
138
+ }
139
+
140
+ // Check for /app directory (app router) - must contain actual files
141
+ const appPath = resolve(projectDir, 'app');
142
+ if (existsSync(appPath) && hasRouteFiles(appPath)) {
143
+ return 'app';
144
+ }
145
+
146
+ // Check for /pages directory (pages router)
147
+ if (existsSync(resolve(projectDir, 'pages'))) {
148
+ return 'pages';
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Check if a directory contains route files (not just an empty scaffold)
156
+ */
157
+ function hasRouteFiles(dirPath) {
158
+ try {
159
+ const entries = readdirSync(dirPath);
160
+ return entries.some(entry => {
161
+ // Look for .js, .ts, .jsx, .tsx files (not just directories)
162
+ return /\.(js|ts|jsx|tsx)$/.test(entry);
163
+ });
164
+ } catch (error) {
165
+ return false;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Detect Vite + React
171
+ */
172
+ function hasViteReact(projectDir, packageJson) {
173
+ const hasViteConfig = existsSync(resolve(projectDir, 'vite.config.js')) ||
174
+ existsSync(resolve(projectDir, 'vite.config.ts')) ||
175
+ existsSync(resolve(projectDir, 'vite.config.mjs'));
176
+
177
+ if (!hasViteConfig) {
178
+ return false;
179
+ }
180
+
181
+ // Check for react dependency
182
+ if (packageJson?.dependencies?.react || packageJson?.devDependencies?.react) {
183
+ return true;
184
+ }
185
+
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Detect Create React App
191
+ */
192
+ function hasCreateReactApp(packageJson) {
193
+ return !!(packageJson?.dependencies?.['react-scripts'] ||
194
+ packageJson?.devDependencies?.['react-scripts']);
195
+ }
196
+
197
+ /**
198
+ * Detect static HTML (no framework)
199
+ */
200
+ function hasStaticHtml(projectDir) {
201
+ return existsSync(resolve(projectDir, 'index.html'));
202
+ }
203
+
204
+ /**
205
+ * Detect package manager
206
+ */
207
+ function detectPackageManager(projectDir) {
208
+ // Check for pnpm-lock.yaml
209
+ if (existsSync(resolve(projectDir, 'pnpm-lock.yaml'))) {
210
+ return 'pnpm';
211
+ }
212
+
213
+ // Check for yarn.lock
214
+ if (existsSync(resolve(projectDir, 'yarn.lock'))) {
215
+ return 'yarn';
216
+ }
217
+
218
+ // Check for package-lock.json (npm)
219
+ if (existsSync(resolve(projectDir, 'package-lock.json'))) {
220
+ return 'npm';
221
+ }
222
+
223
+ // Default to npm if none found but package.json exists
224
+ if (existsSync(resolve(projectDir, 'package.json'))) {
225
+ return 'npm';
226
+ }
227
+
228
+ return 'unknown';
229
+ }
230
+
231
+ /**
232
+ * Get human-readable framework name
233
+ */
234
+ export function getFrameworkDisplayName(framework, router) {
235
+ if (framework === 'nextjs') {
236
+ const routerType = router === 'app' ? 'app router' : router === 'pages' ? 'pages router' : 'unknown router';
237
+ return `Next.js (${routerType})`;
238
+ }
239
+
240
+ if (framework === 'react-vite') {
241
+ return 'Vite + React';
242
+ }
243
+
244
+ if (framework === 'react-cra') {
245
+ return 'Create React App';
246
+ }
247
+
248
+ if (framework === 'static-html') {
249
+ return 'Static HTML';
250
+ }
251
+
252
+ return 'Unknown';
253
+ }
254
+
255
+ /**
256
+ * Extract probable port from dev script
257
+ */
258
+ export function extractPortFromScript(script) {
259
+ if (!script) return null;
260
+
261
+ // Common patterns:
262
+ // - --port 3000
263
+ // - -p 3000
264
+ // - PORT=3000
265
+
266
+ const portMatch = script.match(/(?:--port|-p)\s+(\d+)/);
267
+ if (portMatch) {
268
+ return parseInt(portMatch[1], 10);
269
+ }
270
+
271
+ const portEnvMatch = script.match(/PORT=(\d+)/);
272
+ if (portEnvMatch) {
273
+ return parseInt(portEnvMatch[1], 10);
274
+ }
275
+
276
+ return null;
277
+ }