popeye-cli 1.8.0 → 1.9.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 (68) hide show
  1. package/README.md +47 -3
  2. package/cheatsheet.md +33 -0
  3. package/dist/cli/commands/index.d.ts +1 -0
  4. package/dist/cli/commands/index.d.ts.map +1 -1
  5. package/dist/cli/commands/index.js +1 -0
  6. package/dist/cli/commands/index.js.map +1 -1
  7. package/dist/cli/commands/review.d.ts +31 -0
  8. package/dist/cli/commands/review.d.ts.map +1 -0
  9. package/dist/cli/commands/review.js +156 -0
  10. package/dist/cli/commands/review.js.map +1 -0
  11. package/dist/cli/index.d.ts.map +1 -1
  12. package/dist/cli/index.js +2 -1
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/cli/interactive.d.ts.map +1 -1
  15. package/dist/cli/interactive.js +122 -61
  16. package/dist/cli/interactive.js.map +1 -1
  17. package/dist/types/audit.d.ts +623 -0
  18. package/dist/types/audit.d.ts.map +1 -0
  19. package/dist/types/audit.js +240 -0
  20. package/dist/types/audit.js.map +1 -0
  21. package/dist/types/workflow.d.ts +15 -0
  22. package/dist/types/workflow.d.ts.map +1 -1
  23. package/dist/types/workflow.js +5 -0
  24. package/dist/types/workflow.js.map +1 -1
  25. package/dist/workflow/audit-analyzer.d.ts +58 -0
  26. package/dist/workflow/audit-analyzer.d.ts.map +1 -0
  27. package/dist/workflow/audit-analyzer.js +438 -0
  28. package/dist/workflow/audit-analyzer.js.map +1 -0
  29. package/dist/workflow/audit-mode.d.ts +28 -0
  30. package/dist/workflow/audit-mode.d.ts.map +1 -0
  31. package/dist/workflow/audit-mode.js +169 -0
  32. package/dist/workflow/audit-mode.js.map +1 -0
  33. package/dist/workflow/audit-recovery.d.ts +61 -0
  34. package/dist/workflow/audit-recovery.d.ts.map +1 -0
  35. package/dist/workflow/audit-recovery.js +242 -0
  36. package/dist/workflow/audit-recovery.js.map +1 -0
  37. package/dist/workflow/audit-reporter.d.ts +65 -0
  38. package/dist/workflow/audit-reporter.d.ts.map +1 -0
  39. package/dist/workflow/audit-reporter.js +301 -0
  40. package/dist/workflow/audit-reporter.js.map +1 -0
  41. package/dist/workflow/audit-scanner.d.ts +87 -0
  42. package/dist/workflow/audit-scanner.d.ts.map +1 -0
  43. package/dist/workflow/audit-scanner.js +768 -0
  44. package/dist/workflow/audit-scanner.js.map +1 -0
  45. package/dist/workflow/index.d.ts +5 -0
  46. package/dist/workflow/index.d.ts.map +1 -1
  47. package/dist/workflow/index.js +5 -0
  48. package/dist/workflow/index.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/cli/commands/index.ts +1 -0
  51. package/src/cli/commands/review.ts +187 -0
  52. package/src/cli/index.ts +2 -0
  53. package/src/cli/interactive.ts +72 -4
  54. package/src/types/audit.ts +294 -0
  55. package/src/types/workflow.ts +15 -0
  56. package/src/workflow/audit-analyzer.ts +510 -0
  57. package/src/workflow/audit-mode.ts +240 -0
  58. package/src/workflow/audit-recovery.ts +284 -0
  59. package/src/workflow/audit-reporter.ts +370 -0
  60. package/src/workflow/audit-scanner.ts +873 -0
  61. package/src/workflow/index.ts +5 -0
  62. package/tests/cli/commands/review.test.ts +52 -0
  63. package/tests/types/audit.test.ts +250 -0
  64. package/tests/workflow/audit-analyzer.test.ts +281 -0
  65. package/tests/workflow/audit-mode.test.ts +114 -0
  66. package/tests/workflow/audit-recovery.test.ts +237 -0
  67. package/tests/workflow/audit-reporter.test.ts +254 -0
  68. package/tests/workflow/audit-scanner.test.ts +270 -0
@@ -0,0 +1,873 @@
1
+ /**
2
+ * Deterministic project scanner for the audit system.
3
+ *
4
+ * Scans the filesystem to detect workspace composition, per-component structure,
5
+ * dependency manifests, route files, LOC, and FE<->BE wiring mismatches.
6
+ * Reads docs in priority order: CLAUDE.md -> README.md -> other docs.
7
+ */
8
+
9
+ import { promises as fs } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { isWorkspace, type OutputLanguage } from '../types/project.js';
12
+ import type {
13
+ ComponentKind,
14
+ ComponentScan,
15
+ DependencyManifest,
16
+ FileEntry,
17
+ FileExcerpt,
18
+ ProjectScanResult,
19
+ WiringMatrix,
20
+ WiringMismatch,
21
+ } from '../types/audit.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Constants (mirrors project-structure.ts patterns)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ const SKIP_DIRS = new Set([
28
+ 'node_modules', 'dist', 'build', '.git', '__pycache__', '.venv', 'venv',
29
+ '.next', '.turbo', '.cache', 'coverage', 'out', '.vercel', '.popeye',
30
+ ]);
31
+
32
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py']);
33
+
34
+ const TEST_PATTERNS = [
35
+ /\.test\.[jt]sx?$/,
36
+ /\.spec\.[jt]sx?$/,
37
+ /test_.*\.py$/,
38
+ /.*_test\.py$/,
39
+ /tests?\/.*\.[jt]sx?$/,
40
+ /tests?\/.*\.py$/,
41
+ ];
42
+
43
+ const CONFIG_FILES = new Set([
44
+ 'package.json', 'tsconfig.json', 'vite.config.ts', 'vite.config.js',
45
+ 'next.config.js', 'next.config.mjs', 'next.config.ts',
46
+ 'tailwind.config.js', 'tailwind.config.ts', 'postcss.config.js',
47
+ 'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt',
48
+ 'docker-compose.yml', 'docker-compose.yaml', 'Dockerfile',
49
+ '.env.example', '.env.local.example', '.eslintrc.json', '.eslintrc.js',
50
+ 'jest.config.ts', 'jest.config.js', 'vitest.config.ts',
51
+ ]);
52
+
53
+ const FE_API_ENV_PATTERNS = [
54
+ /^VITE_API_URL$/i,
55
+ /^NEXT_PUBLIC_API_URL$/i,
56
+ /^REACT_APP_API_URL$/i,
57
+ /^VITE_API_BASE_URL$/i,
58
+ /^NEXT_PUBLIC_API_BASE_URL$/i,
59
+ /^VITE_BACKEND_URL$/i,
60
+ /^NEXT_PUBLIC_BACKEND_URL$/i,
61
+ ];
62
+
63
+ const MAX_TREE_ENTRIES = 50;
64
+ const MAX_FILE_EXCERPT = 2000;
65
+ const MAX_DOC_READ = 8000;
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // File utilities
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Check if a path exists on the filesystem.
73
+ *
74
+ * @param p - Path to check.
75
+ * @returns True if the path exists.
76
+ */
77
+ async function pathExists(p: string): Promise<boolean> {
78
+ try {
79
+ await fs.access(p);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Read a file and return its content, truncated to maxLen.
88
+ *
89
+ * @param filePath - Absolute file path.
90
+ * @param maxLen - Maximum characters to return.
91
+ * @returns File content string, or undefined if unreadable.
92
+ */
93
+ async function safeRead(filePath: string, maxLen = MAX_DOC_READ): Promise<string | undefined> {
94
+ try {
95
+ const content = await fs.readFile(filePath, 'utf-8');
96
+ return content.length > maxLen ? content.slice(0, maxLen) + '\n... (truncated)' : content;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Count lines in a file.
104
+ *
105
+ * @param filePath - Absolute file path.
106
+ * @returns Number of lines, or 0 on error.
107
+ */
108
+ async function countFileLines(filePath: string): Promise<number> {
109
+ try {
110
+ const content = await fs.readFile(filePath, 'utf-8');
111
+ return content.split('\n').length;
112
+ } catch {
113
+ return 0;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Check if a relative path matches test file patterns.
119
+ *
120
+ * @param relPath - Relative file path.
121
+ * @returns True if the path looks like a test file.
122
+ */
123
+ function isTestFile(relPath: string): boolean {
124
+ return TEST_PATTERNS.some((p) => p.test(relPath));
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Recursive directory walker
129
+ // ---------------------------------------------------------------------------
130
+
131
+ interface WalkEntry {
132
+ relativePath: string;
133
+ absolutePath: string;
134
+ isDir: boolean;
135
+ }
136
+
137
+ /**
138
+ * Recursively walk a directory, yielding files and subdirectories.
139
+ *
140
+ * @param rootDir - Root directory to walk.
141
+ * @param maxDepth - Maximum recursion depth.
142
+ * @returns Array of WalkEntry objects.
143
+ */
144
+ async function walkDir(rootDir: string, maxDepth = 8): Promise<WalkEntry[]> {
145
+ const results: WalkEntry[] = [];
146
+
147
+ async function recurse(dir: string, depth: number): Promise<void> {
148
+ if (depth > maxDepth) return;
149
+ let entries;
150
+ try {
151
+ entries = await fs.readdir(dir, { withFileTypes: true });
152
+ } catch {
153
+ return;
154
+ }
155
+ for (const entry of entries) {
156
+ if (SKIP_DIRS.has(entry.name)) continue;
157
+ const abs = path.join(dir, entry.name);
158
+ const rel = path.relative(rootDir, abs);
159
+ results.push({ relativePath: rel, absolutePath: abs, isDir: entry.isDirectory() });
160
+ if (entry.isDirectory()) {
161
+ await recurse(abs, depth + 1);
162
+ }
163
+ }
164
+ }
165
+
166
+ await recurse(rootDir, 0);
167
+ return results;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Workspace composition detection
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Detect workspace composition from the filesystem.
176
+ *
177
+ * Examines directory structure for signals of frontend, backend, website,
178
+ * shared, and infra components. Does NOT trust state.language — derives
179
+ * composition purely from filesystem evidence.
180
+ *
181
+ * @param projectDir - Project root directory.
182
+ * @returns Array of detected ComponentKind values.
183
+ */
184
+ export async function detectWorkspaceComposition(
185
+ projectDir: string
186
+ ): Promise<ComponentKind[]> {
187
+ const kinds: ComponentKind[] = [];
188
+
189
+ // Reason: Check apps/ subdirectories as primary workspace signal
190
+ const appsDir = path.join(projectDir, 'apps');
191
+ const appsExists = await pathExists(appsDir);
192
+
193
+ if (appsExists) {
194
+ // Frontend signals
195
+ const feDirs = ['apps/frontend', 'apps/web', 'apps/client'];
196
+ for (const d of feDirs) {
197
+ if (await pathExists(path.join(projectDir, d, 'package.json'))) {
198
+ kinds.push('frontend');
199
+ break;
200
+ }
201
+ }
202
+
203
+ // Backend signals
204
+ const beDirs = ['apps/backend', 'apps/api', 'apps/server'];
205
+ for (const d of beDirs) {
206
+ const hasPy = await pathExists(path.join(projectDir, d, 'requirements.txt'))
207
+ || await pathExists(path.join(projectDir, d, 'pyproject.toml'));
208
+ const hasNode = await pathExists(path.join(projectDir, d, 'package.json'));
209
+ if (hasPy || hasNode) {
210
+ kinds.push('backend');
211
+ break;
212
+ }
213
+ }
214
+
215
+ // Website signals
216
+ const webDirs = ['apps/website', 'apps/landing'];
217
+ for (const d of webDirs) {
218
+ if (await pathExists(path.join(projectDir, d))) {
219
+ kinds.push('website');
220
+ break;
221
+ }
222
+ }
223
+ } else {
224
+ // Non-workspace: single-component project
225
+ const hasPkgJson = await pathExists(path.join(projectDir, 'package.json'));
226
+ const hasPyProject = await pathExists(path.join(projectDir, 'requirements.txt'))
227
+ || await pathExists(path.join(projectDir, 'pyproject.toml'));
228
+
229
+ if (hasPkgJson && hasPyProject) {
230
+ kinds.push('frontend', 'backend');
231
+ } else if (hasPkgJson) {
232
+ kinds.push('frontend');
233
+ } else if (hasPyProject) {
234
+ kinds.push('backend');
235
+ }
236
+ }
237
+
238
+ // Shared directory
239
+ if (await pathExists(path.join(projectDir, 'packages'))
240
+ || await pathExists(path.join(projectDir, 'libs'))
241
+ || await pathExists(path.join(projectDir, 'shared'))) {
242
+ kinds.push('shared');
243
+ }
244
+
245
+ // Infra signals
246
+ if (await pathExists(path.join(projectDir, 'infra'))
247
+ || await pathExists(path.join(projectDir, 'docker-compose.yml'))
248
+ || await pathExists(path.join(projectDir, 'docker-compose.yaml'))
249
+ || await pathExists(path.join(projectDir, 'Dockerfile'))) {
250
+ kinds.push('infra');
251
+ }
252
+
253
+ return kinds;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Per-component scanning
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Detect the likely framework from a package.json or file structure.
262
+ *
263
+ * @param componentDir - Component root directory.
264
+ * @returns Framework name or undefined.
265
+ */
266
+ async function detectFramework(componentDir: string): Promise<string | undefined> {
267
+ try {
268
+ const pkgPath = path.join(componentDir, 'package.json');
269
+ const content = await fs.readFile(pkgPath, 'utf-8');
270
+ const pkg = JSON.parse(content);
271
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
272
+
273
+ if (allDeps['next']) return 'next';
274
+ if (allDeps['@remix-run/react']) return 'remix';
275
+ if (allDeps['react']) return 'react';
276
+ if (allDeps['vue']) return 'vue';
277
+ if (allDeps['svelte']) return 'svelte';
278
+ if (allDeps['express']) return 'express';
279
+ if (allDeps['fastify']) return 'fastify';
280
+ if (allDeps['hono']) return 'hono';
281
+ } catch {
282
+ // No package.json or unparseable
283
+ }
284
+
285
+ // Python framework detection
286
+ try {
287
+ const reqPath = path.join(componentDir, 'requirements.txt');
288
+ const content = await fs.readFile(reqPath, 'utf-8');
289
+ if (/fastapi/i.test(content)) return 'fastapi';
290
+ if (/django/i.test(content)) return 'django';
291
+ if (/flask/i.test(content)) return 'flask';
292
+ } catch {
293
+ // No requirements.txt
294
+ }
295
+
296
+ return undefined;
297
+ }
298
+
299
+ /**
300
+ * Scan a single component directory for files, routes, entry points, and deps.
301
+ *
302
+ * @param componentDir - Absolute path to component root.
303
+ * @param kind - Component kind.
304
+ * @param language - Language hint (e.g., 'typescript', 'python').
305
+ * @returns ComponentScan result.
306
+ */
307
+ export async function scanComponent(
308
+ componentDir: string,
309
+ kind: ComponentKind,
310
+ language: 'typescript' | 'python' | 'mixed',
311
+ projectDir?: string
312
+ ): Promise<ComponentScan> {
313
+ const entries = await walkDir(componentDir);
314
+ const sourceFiles: FileEntry[] = [];
315
+ const testFiles: FileEntry[] = [];
316
+ const entryPoints: string[] = [];
317
+ const routeFiles: string[] = [];
318
+ const depManifests: DependencyManifest[] = [];
319
+
320
+ for (const entry of entries) {
321
+ if (entry.isDir) continue;
322
+ const ext = path.extname(entry.relativePath);
323
+
324
+ if (SOURCE_EXTENSIONS.has(ext)) {
325
+ const fe: FileEntry = { path: entry.relativePath, extension: ext };
326
+
327
+ if (isTestFile(entry.relativePath)) {
328
+ testFiles.push(fe);
329
+ } else {
330
+ sourceFiles.push(fe);
331
+ }
332
+
333
+ // Entry point detection
334
+ const base = path.basename(entry.relativePath);
335
+ if (['main.ts', 'main.tsx', 'index.ts', 'index.tsx', 'app.ts', 'app.tsx',
336
+ 'main.py', 'app.py', 'server.ts', 'server.js'].includes(base)) {
337
+ entryPoints.push(entry.relativePath);
338
+ }
339
+
340
+ // Route file detection
341
+ if (/route[rs]?\.[jt]sx?$/i.test(base)
342
+ || /router\.[jt]sx?$/i.test(base)
343
+ || entry.relativePath.includes('/routes/')
344
+ || entry.relativePath.includes('/api/')
345
+ || /urls\.py$/.test(base)
346
+ || /routes\.py$/.test(base)
347
+ || /router\.py$/.test(base)) {
348
+ routeFiles.push(entry.relativePath);
349
+ }
350
+ }
351
+
352
+ // Dependency manifests
353
+ const baseName = path.basename(entry.relativePath);
354
+ if (baseName === 'package.json' && !entry.relativePath.includes('node_modules')) {
355
+ depManifests.push(await parseDependencyFile(entry.absolutePath, 'package.json'));
356
+ } else if (baseName === 'requirements.txt') {
357
+ depManifests.push(await parseDependencyFile(entry.absolutePath, 'requirements.txt'));
358
+ } else if (baseName === 'pyproject.toml') {
359
+ depManifests.push(await parseDependencyFile(entry.absolutePath, 'pyproject.toml'));
360
+ }
361
+ }
362
+
363
+ // Reason: rootDir must be relative to the project root for correct LOC path resolution.
364
+ // Using parent dir gives just "frontend" instead of "apps/frontend".
365
+ const rootDir = projectDir
366
+ ? (path.relative(projectDir, componentDir) || '.')
367
+ : (path.relative(path.dirname(componentDir), componentDir) || '.');
368
+ const framework = await detectFramework(componentDir);
369
+
370
+ return {
371
+ kind,
372
+ rootDir,
373
+ language,
374
+ framework,
375
+ entryPoints,
376
+ routeFiles,
377
+ testFiles,
378
+ sourceFiles,
379
+ dependencyManifests: depManifests,
380
+ };
381
+ }
382
+
383
+ // ---------------------------------------------------------------------------
384
+ // Dependency parsing
385
+ // ---------------------------------------------------------------------------
386
+
387
+ /**
388
+ * Parse a single dependency manifest file.
389
+ *
390
+ * @param filePath - Absolute path to the file.
391
+ * @param type - Manifest type.
392
+ * @returns DependencyManifest with parsed dependencies.
393
+ */
394
+ async function parseDependencyFile(
395
+ filePath: string,
396
+ type: DependencyManifest['type']
397
+ ): Promise<DependencyManifest> {
398
+ const relPath = path.basename(filePath);
399
+ try {
400
+ const content = await fs.readFile(filePath, 'utf-8');
401
+ if (type === 'package.json') {
402
+ const pkg = JSON.parse(content);
403
+ return {
404
+ file: relPath,
405
+ type,
406
+ dependencies: pkg.dependencies ?? {},
407
+ devDependencies: pkg.devDependencies ?? {},
408
+ };
409
+ }
410
+ if (type === 'requirements.txt') {
411
+ const deps: Record<string, string> = {};
412
+ for (const line of content.split('\n')) {
413
+ const trimmed = line.trim();
414
+ if (!trimmed || trimmed.startsWith('#')) continue;
415
+ const match = trimmed.match(/^([a-zA-Z0-9_-]+)([=<>!~].+)?$/);
416
+ if (match) {
417
+ deps[match[1]] = match[2] ?? '*';
418
+ }
419
+ }
420
+ return { file: relPath, type, dependencies: deps };
421
+ }
422
+ // pyproject.toml — simplified parsing
423
+ return { file: relPath, type: 'pyproject.toml' };
424
+ } catch {
425
+ return { file: relPath, type };
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Parse all dependency manifests found in the project.
431
+ *
432
+ * @param projectDir - Project root directory.
433
+ * @param language - Project language.
434
+ * @returns Array of dependency manifests.
435
+ */
436
+ export async function parseDependencies(
437
+ projectDir: string,
438
+ _language: string
439
+ ): Promise<DependencyManifest[]> {
440
+ const manifests: DependencyManifest[] = [];
441
+ const entries = await walkDir(projectDir, 3);
442
+
443
+ for (const entry of entries) {
444
+ if (entry.isDir) continue;
445
+ const base = path.basename(entry.relativePath);
446
+ if (base === 'package.json' && !entry.relativePath.includes('node_modules')) {
447
+ manifests.push(await parseDependencyFile(entry.absolutePath, 'package.json'));
448
+ } else if (base === 'requirements.txt') {
449
+ manifests.push(await parseDependencyFile(entry.absolutePath, 'requirements.txt'));
450
+ } else if (base === 'pyproject.toml') {
451
+ manifests.push(await parseDependencyFile(entry.absolutePath, 'pyproject.toml'));
452
+ }
453
+ }
454
+ return manifests;
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Route file detection
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /**
462
+ * Find all route-like files in the project.
463
+ *
464
+ * @param projectDir - Project root directory.
465
+ * @param language - Project language.
466
+ * @returns Array of relative paths to route files.
467
+ */
468
+ export async function findRouteFiles(
469
+ projectDir: string,
470
+ _language: string
471
+ ): Promise<string[]> {
472
+ const routes: string[] = [];
473
+ const entries = await walkDir(projectDir);
474
+
475
+ for (const entry of entries) {
476
+ if (entry.isDir) continue;
477
+ const base = path.basename(entry.relativePath);
478
+ const rel = entry.relativePath;
479
+
480
+ if (/route[rs]?\.[jt]sx?$/i.test(base)
481
+ || /router\.[jt]sx?$/i.test(base)
482
+ || rel.includes('/routes/')
483
+ || rel.includes('/api/')
484
+ || /urls\.py$/.test(base)
485
+ || /routes\.py$/.test(base)
486
+ || /router\.py$/.test(base)) {
487
+ routes.push(rel);
488
+ }
489
+ }
490
+ return routes;
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Priority doc reads
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /**
498
+ * Read project documentation in priority order: CLAUDE.md -> README.md -> docs.
499
+ *
500
+ * @param projectDir - Project root directory.
501
+ * @returns Priority doc contents and docs index.
502
+ */
503
+ export async function readPriorityDocs(projectDir: string): Promise<{
504
+ claudeMd?: string;
505
+ readme?: string;
506
+ docsIndex: string[];
507
+ keyFiles: FileExcerpt[];
508
+ }> {
509
+ const docsIndex: string[] = [];
510
+ const keyFiles: FileExcerpt[] = [];
511
+
512
+ // 1. CLAUDE.md (highest priority)
513
+ const claudeMdPath = path.join(projectDir, 'CLAUDE.md');
514
+ const claudeMd = await safeRead(claudeMdPath);
515
+ if (claudeMd) docsIndex.push('CLAUDE.md');
516
+
517
+ // 2. README.md
518
+ const readmePath = path.join(projectDir, 'README.md');
519
+ const readme = await safeRead(readmePath);
520
+ if (readme) docsIndex.push('README.md');
521
+
522
+ // 3. Other root-level .md files (excluding README and CLAUDE)
523
+ try {
524
+ const rootEntries = await fs.readdir(projectDir, { withFileTypes: true });
525
+ for (const entry of rootEntries) {
526
+ if (entry.isFile()
527
+ && entry.name.endsWith('.md')
528
+ && entry.name !== 'README.md'
529
+ && entry.name !== 'CLAUDE.md') {
530
+ docsIndex.push(entry.name);
531
+ const content = await safeRead(path.join(projectDir, entry.name), MAX_FILE_EXCERPT);
532
+ if (content) {
533
+ keyFiles.push({ path: entry.name, content });
534
+ }
535
+ }
536
+ }
537
+ } catch {
538
+ // Root dir read error
539
+ }
540
+
541
+ // 4. docs/ directory
542
+ const docsDir = path.join(projectDir, 'docs');
543
+ if (await pathExists(docsDir)) {
544
+ const docEntries = await walkDir(docsDir, 3);
545
+ for (const entry of docEntries) {
546
+ if (!entry.isDir && entry.relativePath.endsWith('.md')) {
547
+ const relFromRoot = path.join('docs', entry.relativePath);
548
+ docsIndex.push(relFromRoot);
549
+ const content = await safeRead(entry.absolutePath, MAX_FILE_EXCERPT);
550
+ if (content) {
551
+ keyFiles.push({ path: relFromRoot, content });
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ return { claudeMd, readme, docsIndex, keyFiles };
558
+ }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // Wiring matrix (deterministic FE<->BE check)
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * Build a wiring matrix from the project, checking FE<->BE env keys,
566
+ * CORS origins, and API prefixes for mismatches.
567
+ *
568
+ * @param projectDir - Project root directory.
569
+ * @param components - Already-scanned component list.
570
+ * @returns WiringMatrix with detected mismatches.
571
+ */
572
+ export async function buildWiringMatrix(
573
+ projectDir: string,
574
+ components: ComponentScan[]
575
+ ): Promise<WiringMatrix> {
576
+ const feApiKeys: string[] = [];
577
+ let feApiResolved: string | undefined;
578
+ let beCorsOrigins: string[] | undefined;
579
+ let beApiPrefix: string | undefined;
580
+ const mismatches: WiringMismatch[] = [];
581
+
582
+ // Scan .env.example for FE API env keys
583
+ const envFiles = ['.env.example', '.env.local.example', '.env'];
584
+ for (const envFile of envFiles) {
585
+ const envPath = path.join(projectDir, envFile);
586
+ const content = await safeRead(envPath, 4000);
587
+ if (!content) continue;
588
+
589
+ for (const line of content.split('\n')) {
590
+ const match = line.match(/^([A-Z_]+)=(.*)$/);
591
+ if (!match) continue;
592
+ const [, key, value] = match;
593
+ if (FE_API_ENV_PATTERNS.some((p) => p.test(key))) {
594
+ feApiKeys.push(key);
595
+ if (value && !feApiResolved) {
596
+ feApiResolved = value.replace(/["']/g, '').trim();
597
+ }
598
+ }
599
+ }
600
+ }
601
+
602
+ // Scan backend component files for CORS origins
603
+ const beComponent = components.find((c) => c.kind === 'backend');
604
+ if (beComponent) {
605
+ const beDir = path.join(projectDir, beComponent.rootDir === '.' ? '' : beComponent.rootDir);
606
+ const beEntries = await walkDir(beDir, 4);
607
+ for (const entry of beEntries) {
608
+ if (entry.isDir) continue;
609
+ const ext = path.extname(entry.relativePath);
610
+ if (!['.ts', '.js', '.py'].includes(ext)) continue;
611
+
612
+ const content = await safeRead(entry.absolutePath, 6000);
613
+ if (!content) continue;
614
+
615
+ // CORS origins extraction
616
+ const corsMatch = content.match(/cors.*origins?\s*[=:]\s*\[([^\]]+)\]/is);
617
+ if (corsMatch) {
618
+ beCorsOrigins = corsMatch[1]
619
+ .split(',')
620
+ .map((s) => s.replace(/["'`\s]/g, ''))
621
+ .filter(Boolean);
622
+ }
623
+
624
+ // API prefix extraction
625
+ const prefixMatch = content.match(/(?:prefix|api_prefix|apiPrefix)\s*[=:]\s*["'`]([^"'`]+)["'`]/i);
626
+ if (prefixMatch) {
627
+ beApiPrefix = prefixMatch[1];
628
+ }
629
+ }
630
+ }
631
+
632
+ // Detect mismatches
633
+ if (feApiResolved && beCorsOrigins && beCorsOrigins.length > 0) {
634
+ try {
635
+ const feUrl = new URL(feApiResolved);
636
+ const feOrigin = feUrl.origin;
637
+ // Reason: Check if the frontend's expected API origin is in the backend's CORS list
638
+ const corsHasFe = beCorsOrigins.some(
639
+ (o) => o === '*' || o === feOrigin
640
+ );
641
+ if (!corsHasFe) {
642
+ mismatches.push({
643
+ type: 'cors-origin-mismatch',
644
+ details: `Frontend expects API at ${feApiResolved} but backend CORS does not include origin ${feOrigin}`,
645
+ evidence: [
646
+ { file: '.env.example', snippet: `${feApiKeys[0]}=${feApiResolved}` },
647
+ ],
648
+ });
649
+ }
650
+ } catch {
651
+ // Invalid URL in env — not a wiring mismatch
652
+ }
653
+ }
654
+
655
+ return {
656
+ frontendApiBaseEnvKeys: feApiKeys,
657
+ frontendApiBaseResolved: feApiResolved,
658
+ backendCorsOrigins: beCorsOrigins,
659
+ backendApiPrefix: beApiPrefix,
660
+ potentialMismatches: mismatches,
661
+ };
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // LOC counting
666
+ // ---------------------------------------------------------------------------
667
+
668
+ /**
669
+ * Count lines of code and test code in the given file lists.
670
+ *
671
+ * @param sourceFiles - Array of source file entries.
672
+ * @param testFiles - Array of test file entries.
673
+ * @param projectDir - Project root for resolving paths.
674
+ * @returns Total lines of code and test code.
675
+ */
676
+ export async function countLines(
677
+ sourceFiles: FileEntry[],
678
+ testFiles: FileEntry[],
679
+ projectDir: string
680
+ ): Promise<{ code: number; tests: number }> {
681
+ let code = 0;
682
+ let tests = 0;
683
+
684
+ const countBatch = async (files: FileEntry[], baseDir: string): Promise<number> => {
685
+ let total = 0;
686
+ for (const f of files) {
687
+ total += await countFileLines(path.join(baseDir, f.path));
688
+ }
689
+ return total;
690
+ };
691
+
692
+ code = await countBatch(sourceFiles, projectDir);
693
+ tests = await countBatch(testFiles, projectDir);
694
+ return { code, tests };
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Tree builder
699
+ // ---------------------------------------------------------------------------
700
+
701
+ /**
702
+ * Build a truncated tree string from the project directory.
703
+ *
704
+ * @param projectDir - Project root directory.
705
+ * @returns Indented tree string.
706
+ */
707
+ async function buildTree(projectDir: string): Promise<string> {
708
+ const entries = await walkDir(projectDir, 3);
709
+ const lines: string[] = [];
710
+
711
+ for (const entry of entries) {
712
+ if (lines.length >= MAX_TREE_ENTRIES) {
713
+ lines.push(`... (+${entries.length - MAX_TREE_ENTRIES} more entries)`);
714
+ break;
715
+ }
716
+ const depth = entry.relativePath.split(path.sep).length - 1;
717
+ const indent = ' '.repeat(depth);
718
+ const name = path.basename(entry.relativePath);
719
+ lines.push(`${indent}${name}${entry.isDir ? '/' : ''}`);
720
+ }
721
+
722
+ return lines.join('\n');
723
+ }
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // Main scanner
727
+ // ---------------------------------------------------------------------------
728
+
729
+ /**
730
+ * Scan the entire project and produce a structured ProjectScanResult.
731
+ *
732
+ * @param projectDir - Project root directory.
733
+ * @param language - Language from state.json.
734
+ * @param onProgress - Optional progress callback.
735
+ * @returns ProjectScanResult with all scan data.
736
+ */
737
+ export async function scanProject(
738
+ projectDir: string,
739
+ language: string,
740
+ onProgress?: (message: string) => void
741
+ ): Promise<ProjectScanResult> {
742
+ onProgress?.('Detecting workspace composition...');
743
+ const detectedComposition = await detectWorkspaceComposition(projectDir);
744
+
745
+ // Determine if state.language and detected composition agree
746
+ const isWs = isWorkspace(language as OutputLanguage);
747
+ const hasMultipleComponents = detectedComposition.filter(
748
+ (k) => k !== 'shared' && k !== 'infra'
749
+ ).length > 1;
750
+ const compositionMismatch = isWs !== hasMultipleComponents;
751
+
752
+ onProgress?.('Reading priority documentation...');
753
+ const docs = await readPriorityDocs(projectDir);
754
+
755
+ onProgress?.('Scanning components...');
756
+ const components: ComponentScan[] = [];
757
+
758
+ // Determine component language hint
759
+ const langHint = (kind: ComponentKind): 'typescript' | 'python' | 'mixed' => {
760
+ if (kind === 'backend' && ['python', 'fullstack', 'all'].includes(language)) return 'python';
761
+ if (kind === 'frontend' || kind === 'website') return 'typescript';
762
+ return 'mixed';
763
+ };
764
+
765
+ if (isWs) {
766
+ // Workspace: scan each apps/ subdirectory
767
+ const appDirMap: Record<string, string[]> = {
768
+ frontend: ['apps/frontend', 'apps/web', 'apps/client'],
769
+ backend: ['apps/backend', 'apps/api', 'apps/server'],
770
+ website: ['apps/website', 'apps/landing'],
771
+ };
772
+
773
+ for (const kind of detectedComposition) {
774
+ if (kind === 'shared' || kind === 'infra') continue;
775
+ const candidates = appDirMap[kind] ?? [];
776
+ for (const candidate of candidates) {
777
+ const absCandidate = path.join(projectDir, candidate);
778
+ if (await pathExists(absCandidate)) {
779
+ components.push(
780
+ await scanComponent(absCandidate, kind, langHint(kind), projectDir)
781
+ );
782
+ break;
783
+ }
784
+ }
785
+ }
786
+ } else {
787
+ // Single-component project: scan root
788
+ const kind = detectedComposition[0] ?? 'frontend';
789
+ components.push(await scanComponent(projectDir, kind, langHint(kind), projectDir));
790
+ }
791
+
792
+ // Aggregate files from all components
793
+ const allSource: FileEntry[] = [];
794
+ const allTest: FileEntry[] = [];
795
+ const allEntryPoints: string[] = [];
796
+ const allRouteFiles: string[] = [];
797
+ const allDeps: DependencyManifest[] = [];
798
+
799
+ for (const comp of components) {
800
+ // Reason: Component file paths are relative to the component dir.
801
+ // For LOC counting, we need them relative to the project root.
802
+ const prefix = comp.rootDir === '.' ? '' : comp.rootDir;
803
+ const prefixPath = (p: string) => prefix ? path.join(prefix, p) : p;
804
+
805
+ allSource.push(...comp.sourceFiles.map((f) => ({ ...f, path: prefixPath(f.path) })));
806
+ allTest.push(...comp.testFiles.map((f) => ({ ...f, path: prefixPath(f.path) })));
807
+ allEntryPoints.push(...comp.entryPoints.map(prefixPath));
808
+ allRouteFiles.push(...comp.routeFiles.map(prefixPath));
809
+ allDeps.push(...comp.dependencyManifests);
810
+ }
811
+
812
+ // Config files detection
813
+ onProgress?.('Scanning config files...');
814
+ const configFiles: string[] = [];
815
+ try {
816
+ const rootEntries = await fs.readdir(projectDir, { withFileTypes: true });
817
+ for (const entry of rootEntries) {
818
+ if (entry.isFile() && CONFIG_FILES.has(entry.name)) {
819
+ configFiles.push(entry.name);
820
+ }
821
+ }
822
+ } catch {
823
+ // Root dir error
824
+ }
825
+
826
+ // LOC counting
827
+ onProgress?.('Counting lines of code...');
828
+ const locResult = await countLines(allSource, allTest, projectDir);
829
+
830
+ // Tree
831
+ const tree = await buildTree(projectDir);
832
+
833
+ // Config content reads
834
+ const envExampleContent = await safeRead(path.join(projectDir, '.env.example'), 4000);
835
+ const dockerComposeContent = await safeRead(
836
+ path.join(projectDir, 'docker-compose.yml'),
837
+ 4000
838
+ ) ?? await safeRead(path.join(projectDir, 'docker-compose.yaml'), 4000);
839
+
840
+ // Wiring matrix
841
+ onProgress?.('Building wiring matrix...');
842
+ const hasFe = components.some((c) => c.kind === 'frontend');
843
+ const hasBe = components.some((c) => c.kind === 'backend');
844
+ const wiring = (hasFe && hasBe) ? await buildWiringMatrix(projectDir, components) : undefined;
845
+
846
+ onProgress?.(`Scan complete: ${allSource.length} source files, ${locResult.code} LOC`);
847
+
848
+ return {
849
+ tree,
850
+ components,
851
+ detectedComposition,
852
+ stateLanguage: language,
853
+ compositionMismatch,
854
+ sourceFiles: allSource,
855
+ testFiles: allTest,
856
+ configFiles,
857
+ entryPoints: allEntryPoints,
858
+ routeFiles: allRouteFiles,
859
+ dependencies: allDeps,
860
+ totalSourceFiles: allSource.length,
861
+ totalTestFiles: allTest.length,
862
+ totalLinesOfCode: locResult.code,
863
+ totalLinesOfTests: locResult.tests,
864
+ language,
865
+ claudeMdContent: docs.claudeMd,
866
+ readmeContent: docs.readme,
867
+ docsIndex: docs.docsIndex,
868
+ keyFileSnippets: docs.keyFiles,
869
+ wiring,
870
+ envExampleContent,
871
+ dockerComposeContent,
872
+ };
873
+ }