preflight-mcp 0.1.2 → 0.1.3

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.
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { extractModuleSyntaxWasm } from '../ast/treeSitter.js';
3
4
  /**
4
5
  * Detect programming languages from file extensions
5
6
  */
@@ -265,6 +266,15 @@ export async function extractBundleFacts(params) {
265
266
  const dependencies = await extractDependencies(allFiles, params.bundleRoot);
266
267
  const fileStructure = analyzeFileStructure(allFiles);
267
268
  const frameworks = detectFrameworks(dependencies, allFiles);
269
+ // Phase 2: Module analysis (optional, more expensive)
270
+ let modules;
271
+ let patterns;
272
+ let techStack;
273
+ if (params.enablePhase2) {
274
+ modules = await analyzeModules(allFiles);
275
+ patterns = detectArchitecturePatterns(allFiles, modules);
276
+ techStack = analyzeTechStack(languages, dependencies, frameworks);
277
+ }
268
278
  return {
269
279
  version: '1.0',
270
280
  timestamp: new Date().toISOString(),
@@ -273,6 +283,9 @@ export async function extractBundleFacts(params) {
273
283
  dependencies,
274
284
  fileStructure,
275
285
  frameworks,
286
+ modules,
287
+ patterns,
288
+ techStack,
276
289
  };
277
290
  }
278
291
  /**
@@ -294,3 +307,819 @@ export async function readFacts(factsPath) {
294
307
  return null;
295
308
  }
296
309
  }
310
+ /**
311
+ * Phase 2: Extract exports from a code file using regex
312
+ */
313
+ function extractExports(content, filePath) {
314
+ const exports = [];
315
+ const lines = content.split('\n');
316
+ // Detect file language
317
+ const ext = path.extname(filePath).toLowerCase();
318
+ const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
319
+ const isPython = ext === '.py';
320
+ const isGo = ext === '.go';
321
+ if (isTS) {
322
+ // TypeScript/JavaScript export patterns
323
+ for (const line of lines) {
324
+ // export function/class/const/let/var/type/interface
325
+ const match1 = line.match(/^\s*export\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum)\s+([a-zA-Z_$][\w$]*)/);
326
+ if (match1?.[1]) {
327
+ exports.push(match1[1]);
328
+ continue;
329
+ }
330
+ // export { xxx, yyy }
331
+ const match2 = line.match(/^\s*export\s*\{\s*([^}]+)\s*\}/);
332
+ if (match2?.[1]) {
333
+ const names = match2[1].split(',').map(n => {
334
+ const parts = n.trim().split(/\s+as\s+/);
335
+ return parts[parts.length - 1]?.trim() || '';
336
+ }).filter(Boolean);
337
+ exports.push(...names);
338
+ continue;
339
+ }
340
+ // export default
341
+ if (line.match(/^\s*export\s+default\s+/)) {
342
+ exports.push('default');
343
+ }
344
+ }
345
+ }
346
+ else if (isPython) {
347
+ // Python: __all__ = [...]
348
+ const allMatch = content.match(/__all__\s*=\s*\[([^\]]+)\]/);
349
+ if (allMatch?.[1]) {
350
+ const names = allMatch[1].split(',').map(n => n.trim().replace(/["']/g, '')).filter(Boolean);
351
+ exports.push(...names);
352
+ }
353
+ // Top-level functions and classes (heuristic)
354
+ for (const line of lines) {
355
+ const funcMatch = line.match(/^def\s+([a-zA-Z_][\w]*)/);
356
+ if (funcMatch?.[1] && !funcMatch[1].startsWith('_')) {
357
+ exports.push(funcMatch[1]);
358
+ }
359
+ const classMatch = line.match(/^class\s+([a-zA-Z_][\w]*)/);
360
+ if (classMatch?.[1] && !classMatch[1].startsWith('_')) {
361
+ exports.push(classMatch[1]);
362
+ }
363
+ }
364
+ }
365
+ else if (isGo) {
366
+ // Go: public functions/types (start with uppercase)
367
+ for (const line of lines) {
368
+ const funcMatch = line.match(/^func\s+([A-Z][\w]*)/);
369
+ if (funcMatch?.[1]) {
370
+ exports.push(funcMatch[1]);
371
+ }
372
+ const typeMatch = line.match(/^type\s+([A-Z][\w]*)/);
373
+ if (typeMatch?.[1]) {
374
+ exports.push(typeMatch[1]);
375
+ }
376
+ }
377
+ }
378
+ return [...new Set(exports)]; // Remove duplicates
379
+ }
380
+ /**
381
+ * Phase 2: Extract imports from a code file using regex
382
+ */
383
+ function extractImports(content, filePath) {
384
+ const imports = [];
385
+ const lines = content.split('\n');
386
+ const ext = path.extname(filePath).toLowerCase();
387
+ const isTS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
388
+ const isPython = ext === '.py';
389
+ const isGo = ext === '.go';
390
+ if (isTS) {
391
+ // import ... from 'xxx'
392
+ for (const line of lines) {
393
+ const match1 = line.match(/from\s+['"]([^'"]+)['"]/);
394
+ if (match1?.[1]) {
395
+ imports.push(match1[1]);
396
+ continue;
397
+ }
398
+ // import 'xxx' or import('xxx')
399
+ const match2 = line.match(/import\s*\(?\s*['"]([^'"]+)['"]/);
400
+ if (match2?.[1]) {
401
+ imports.push(match2[1]);
402
+ continue;
403
+ }
404
+ // require('xxx')
405
+ const match3 = line.match(/require\s*\(\s*['"]([^'"]+)['"]/);
406
+ if (match3?.[1]) {
407
+ imports.push(match3[1]);
408
+ }
409
+ }
410
+ }
411
+ else if (isPython) {
412
+ // import xxx or from xxx import
413
+ for (const line of lines) {
414
+ const match1 = line.match(/^\s*import\s+([a-zA-Z_][\w.]*)/);
415
+ if (match1?.[1]) {
416
+ imports.push(match1[1].split('.')[0]);
417
+ continue;
418
+ }
419
+ const match2 = line.match(/^\s*from\s+([a-zA-Z_][\w.]*)\s+import/);
420
+ if (match2?.[1]) {
421
+ imports.push(match2[1].split('.')[0]);
422
+ }
423
+ }
424
+ }
425
+ else if (isGo) {
426
+ // Go: import statements
427
+ const importBlock = content.match(/import\s*\(([^)]+)\)/);
428
+ if (importBlock?.[1]) {
429
+ const lines = importBlock[1].split('\n');
430
+ for (const line of lines) {
431
+ const match = line.match(/["']([^"']+)["']/);
432
+ if (match?.[1]) {
433
+ imports.push(match[1]);
434
+ }
435
+ }
436
+ }
437
+ // Single import
438
+ for (const line of lines) {
439
+ const match = line.match(/^\s*import\s+["']([^"']+)["']/);
440
+ if (match?.[1]) {
441
+ imports.push(match[1]);
442
+ }
443
+ }
444
+ }
445
+ return [...new Set(imports)]; // Remove duplicates
446
+ }
447
+ /**
448
+ * Phase 2: Determine module role based on path and usage
449
+ */
450
+ function determineModuleRole(file, importedBy) {
451
+ const p = file.repoRelativePath.toLowerCase();
452
+ // Test files
453
+ if (p.includes('/test/') ||
454
+ p.includes('/tests/') ||
455
+ p.includes('/__tests__/') ||
456
+ p.includes('.test.') ||
457
+ p.includes('.spec.')) {
458
+ return 'test';
459
+ }
460
+ // Config files
461
+ if (p.includes('config') ||
462
+ p.endsWith('.config.ts') ||
463
+ p.endsWith('.config.js') ||
464
+ p.includes('/scripts/')) {
465
+ return 'config';
466
+ }
467
+ // Example files
468
+ if (p.includes('/example') || p.includes('/demo')) {
469
+ return 'example';
470
+ }
471
+ // Core: imported by multiple modules (2+)
472
+ if (importedBy.size >= 2) {
473
+ return 'core';
474
+ }
475
+ // Utility: in utils/helpers directory or imported by 1-2 modules
476
+ if (p.includes('/util') || p.includes('/helper') || importedBy.size > 0) {
477
+ return 'utility';
478
+ }
479
+ return 'unknown';
480
+ }
481
+ /**
482
+ * Phase 2: Calculate module complexity
483
+ */
484
+ function calculateComplexity(loc, importCount) {
485
+ // Simple heuristic based on LOC and import count
486
+ const score = loc / 100 + importCount / 5;
487
+ if (score < 2)
488
+ return 'low';
489
+ if (score < 5)
490
+ return 'medium';
491
+ return 'high';
492
+ }
493
+ /**
494
+ * Phase 2: Analyze modules in the repository
495
+ */
496
+ async function analyzeModules(files) {
497
+ const modules = [];
498
+ const eligibleExtensions = new Set([
499
+ '.ts',
500
+ '.tsx',
501
+ '.js',
502
+ '.jsx',
503
+ '.mjs',
504
+ '.cjs',
505
+ '.py',
506
+ '.go',
507
+ '.java',
508
+ '.rs',
509
+ ]);
510
+ const fileKey = (f) => `${f.repoId}:${f.repoRelativePath}`;
511
+ const buildSuffixIndex = (keyByRelPath) => {
512
+ const index = new Map();
513
+ const add = (suffix, key) => {
514
+ const existing = index.get(suffix);
515
+ if (existing === undefined) {
516
+ index.set(suffix, key);
517
+ return;
518
+ }
519
+ if (existing !== key) {
520
+ index.set(suffix, null);
521
+ }
522
+ };
523
+ for (const [relPath, key] of keyByRelPath.entries()) {
524
+ const parts = relPath.split('/').filter(Boolean);
525
+ for (let i = 0; i < parts.length; i++) {
526
+ add(parts.slice(i).join('/'), key);
527
+ }
528
+ }
529
+ return index;
530
+ };
531
+ // Pre-pass: build per-repo lookup tables so we can resolve local imports deterministically.
532
+ const repoIndexes = new Map();
533
+ for (const file of files) {
534
+ if (file.kind !== 'code')
535
+ continue;
536
+ const ext = path.extname(file.repoRelativePath).toLowerCase();
537
+ if (!eligibleExtensions.has(ext))
538
+ continue;
539
+ let idx = repoIndexes.get(file.repoId);
540
+ if (!idx) {
541
+ idx = {
542
+ keyByRelPath: new Map(),
543
+ suffixIndex: new Map(),
544
+ goModules: [],
545
+ goRepByDir: new Map(),
546
+ rustCrateRootDirs: [],
547
+ rustCrateRootFiles: new Set(),
548
+ };
549
+ repoIndexes.set(file.repoId, idx);
550
+ }
551
+ idx.keyByRelPath.set(file.repoRelativePath, fileKey(file));
552
+ }
553
+ for (const idx of repoIndexes.values()) {
554
+ idx.suffixIndex = buildSuffixIndex(idx.keyByRelPath);
555
+ }
556
+ const normalizeDir = (d) => (d === '.' ? '' : d);
557
+ // Go: build package directory representative map + parse go.mod module paths.
558
+ const goModFilesByRepo = new Map();
559
+ for (const file of files) {
560
+ if (file.repoRelativePath === 'go.mod' || file.repoRelativePath.endsWith('/go.mod')) {
561
+ const list = goModFilesByRepo.get(file.repoId) ?? [];
562
+ list.push(file);
563
+ goModFilesByRepo.set(file.repoId, list);
564
+ }
565
+ }
566
+ const parseGoModulePath = (content) => {
567
+ for (const line of content.split('\n')) {
568
+ const t = line.trim();
569
+ if (!t || t.startsWith('//'))
570
+ continue;
571
+ const m = t.match(/^module\s+(\S+)/);
572
+ if (m?.[1])
573
+ return m[1];
574
+ }
575
+ return null;
576
+ };
577
+ for (const [repoId, idx] of Array.from(repoIndexes.entries()).sort(([a], [b]) => a.localeCompare(b))) {
578
+ // go package dir -> representative file
579
+ const goFilesByDir = new Map();
580
+ for (const relPath of idx.keyByRelPath.keys()) {
581
+ if (!relPath.endsWith('.go'))
582
+ continue;
583
+ const dir = normalizeDir(path.posix.dirname(relPath));
584
+ const list = goFilesByDir.get(dir) ?? [];
585
+ list.push(relPath);
586
+ goFilesByDir.set(dir, list);
587
+ }
588
+ for (const [dir, relPaths] of goFilesByDir.entries()) {
589
+ relPaths.sort();
590
+ const preferred = relPaths.find((p) => !p.endsWith('_test.go')) ?? relPaths[0];
591
+ if (preferred) {
592
+ const key = idx.keyByRelPath.get(preferred);
593
+ if (key)
594
+ idx.goRepByDir.set(dir, key);
595
+ }
596
+ }
597
+ // go.mod module path(s)
598
+ const goMods = (goModFilesByRepo.get(repoId) ?? []).slice().sort((a, b) => a.repoRelativePath.localeCompare(b.repoRelativePath));
599
+ for (const goMod of goMods) {
600
+ try {
601
+ const raw = await fs.readFile(goMod.bundleNormAbsPath, 'utf8');
602
+ const content = raw.replace(/\r\n/g, '\n');
603
+ const modulePath = parseGoModulePath(content);
604
+ if (!modulePath)
605
+ continue;
606
+ const moduleRootDir = normalizeDir(path.posix.dirname(goMod.repoRelativePath));
607
+ idx.goModules.push({ moduleRootDir, modulePath });
608
+ }
609
+ catch {
610
+ // ignore
611
+ }
612
+ }
613
+ idx.goModules.sort((a, b) => {
614
+ const len = b.moduleRootDir.length - a.moduleRootDir.length;
615
+ if (len !== 0)
616
+ return len;
617
+ return a.moduleRootDir.localeCompare(b.moduleRootDir) || a.modulePath.localeCompare(b.modulePath);
618
+ });
619
+ // Rust: detect crate roots (lib/main + bin/examples/benches/tests entrypoints)
620
+ const crateRootDirs = new Set();
621
+ const isCrateRootFile = (relPath) => {
622
+ if (!relPath.endsWith('.rs'))
623
+ return false;
624
+ const base = path.posix.basename(relPath);
625
+ if (base === 'lib.rs' || base === 'main.rs')
626
+ return true;
627
+ if (base === 'mod.rs')
628
+ return false;
629
+ const dir = path.posix.dirname(relPath);
630
+ const isEntryDir = dir === 'src/bin' ||
631
+ dir.endsWith('/src/bin') ||
632
+ dir === 'examples' ||
633
+ dir.endsWith('/examples') ||
634
+ dir === 'benches' ||
635
+ dir.endsWith('/benches') ||
636
+ dir === 'tests' ||
637
+ dir.endsWith('/tests');
638
+ return isEntryDir;
639
+ };
640
+ for (const relPath of idx.keyByRelPath.keys()) {
641
+ if (!isCrateRootFile(relPath))
642
+ continue;
643
+ idx.rustCrateRootFiles.add(relPath);
644
+ crateRootDirs.add(normalizeDir(path.posix.dirname(relPath)));
645
+ }
646
+ idx.rustCrateRootDirs = Array.from(crateRootDirs)
647
+ .sort((a, b) => {
648
+ const len = b.length - a.length;
649
+ if (len !== 0)
650
+ return len;
651
+ return a.localeCompare(b);
652
+ });
653
+ }
654
+ const isJsLike = (ext) => ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext);
655
+ const resolveJsLocalImportRelPath = (params) => {
656
+ const cleaned = params.specifier.split(/[?#]/, 1)[0] ?? '';
657
+ if (!cleaned || (!cleaned.startsWith('.') && !cleaned.startsWith('/')))
658
+ return null;
659
+ const base = cleaned.startsWith('/')
660
+ ? path.posix.normalize(cleaned.slice(1))
661
+ : path.posix.normalize(path.posix.join(path.posix.dirname(params.importerRelPath), cleaned));
662
+ const addIfExists = (cand, out) => {
663
+ if (params.keyByRelPath.has(cand))
664
+ out.push(cand);
665
+ };
666
+ const candidates = [];
667
+ const ext = path.posix.extname(base).toLowerCase();
668
+ if (ext) {
669
+ addIfExists(base, candidates);
670
+ // TS projects often import './x.js' but source is './x.ts'
671
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
672
+ const stem = base.slice(0, -ext.length);
673
+ addIfExists(`${stem}.ts`, candidates);
674
+ addIfExists(`${stem}.tsx`, candidates);
675
+ addIfExists(`${stem}.jsx`, candidates);
676
+ }
677
+ if (ext === '.jsx') {
678
+ const stem = base.slice(0, -ext.length);
679
+ addIfExists(`${stem}.tsx`, candidates);
680
+ addIfExists(`${stem}.ts`, candidates);
681
+ }
682
+ }
683
+ else {
684
+ const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'];
685
+ for (const e of exts)
686
+ addIfExists(`${base}${e}`, candidates);
687
+ for (const e of exts)
688
+ addIfExists(path.posix.join(base, `index${e}`), candidates);
689
+ }
690
+ return candidates[0] ?? null;
691
+ };
692
+ const resolvePythonLocalImportKey = (params) => {
693
+ const cleaned = params.specifier.split(/[?#]/, 1)[0]?.trim() ?? '';
694
+ if (!cleaned)
695
+ return null;
696
+ // Relative imports: .foo / ..foo.bar
697
+ if (cleaned.startsWith('.')) {
698
+ const m = cleaned.match(/^(\.+)(.*)$/);
699
+ if (!m)
700
+ return null;
701
+ const dotCount = m[1]?.length ?? 0;
702
+ const rest = (m[2] ?? '').replace(/^\.+/, '');
703
+ let baseDir = normalizeDir(path.posix.dirname(params.importerRelPath));
704
+ for (let i = 1; i < dotCount; i++) {
705
+ baseDir = normalizeDir(path.posix.dirname(baseDir));
706
+ }
707
+ const restPath = rest ? rest.replace(/\./g, '/') : '';
708
+ const candidates = [];
709
+ if (restPath) {
710
+ candidates.push(normalizeDir(path.posix.join(baseDir, `${restPath}.py`)));
711
+ candidates.push(normalizeDir(path.posix.join(baseDir, restPath, '__init__.py')));
712
+ }
713
+ else {
714
+ candidates.push(normalizeDir(path.posix.join(baseDir, '__init__.py')));
715
+ }
716
+ for (const cand of candidates) {
717
+ const direct = params.keyByRelPath.get(cand);
718
+ if (direct)
719
+ return direct;
720
+ }
721
+ return null;
722
+ }
723
+ // If the import is already a path, try to match directly.
724
+ if (cleaned.startsWith('/')) {
725
+ const asPath = cleaned.slice(1);
726
+ const direct = params.keyByRelPath.get(asPath);
727
+ if (direct)
728
+ return direct;
729
+ }
730
+ // Dotted module name -> file path suffix (best-effort, unique-match only).
731
+ const modulePath = cleaned.replace(/\./g, '/');
732
+ const candFile = `${modulePath}.py`;
733
+ const candInit = path.posix.join(modulePath, '__init__.py');
734
+ const directFile = params.keyByRelPath.get(candFile);
735
+ if (directFile)
736
+ return directFile;
737
+ const directInit = params.keyByRelPath.get(candInit);
738
+ if (directInit)
739
+ return directInit;
740
+ const viaSuffixFile = params.suffixIndex.get(candFile);
741
+ if (typeof viaSuffixFile === 'string')
742
+ return viaSuffixFile;
743
+ const viaSuffixInit = params.suffixIndex.get(candInit);
744
+ if (typeof viaSuffixInit === 'string')
745
+ return viaSuffixInit;
746
+ return null;
747
+ };
748
+ const resolveJavaLocalImportKey = (params) => {
749
+ const cleaned = params.specifier.split(/[?#]/, 1)[0] ?? '';
750
+ if (!cleaned || cleaned.endsWith('.*'))
751
+ return null;
752
+ const cand = `${cleaned.replace(/\./g, '/')}.java`;
753
+ const direct = params.keyByRelPath.get(cand);
754
+ if (direct)
755
+ return direct;
756
+ const viaSuffix = params.suffixIndex.get(cand);
757
+ if (typeof viaSuffix === 'string')
758
+ return viaSuffix;
759
+ return null;
760
+ };
761
+ const findGoModuleForFile = (fileRelPath, modules) => {
762
+ for (const m of modules) {
763
+ if (!m.moduleRootDir)
764
+ return m;
765
+ if (fileRelPath.startsWith(`${m.moduleRootDir}/`))
766
+ return m;
767
+ }
768
+ return null;
769
+ };
770
+ const isGoModuleLocalImport = (file, specifier) => {
771
+ const idx = repoIndexes.get(file.repoId);
772
+ if (!idx)
773
+ return false;
774
+ const mod = findGoModuleForFile(file.repoRelativePath, idx.goModules);
775
+ if (!mod)
776
+ return false;
777
+ const cleaned = specifier.split(/[?#]/, 1)[0]?.trim() ?? '';
778
+ return cleaned === mod.modulePath || cleaned.startsWith(`${mod.modulePath}/`);
779
+ };
780
+ const resolveGoLocalImportKey = (params) => {
781
+ const cleaned = params.specifier.split(/[?#]/, 1)[0]?.trim() ?? '';
782
+ if (!cleaned)
783
+ return null;
784
+ const mod = findGoModuleForFile(params.importerRelPath, params.idx.goModules);
785
+ if (!mod)
786
+ return null;
787
+ if (cleaned !== mod.modulePath && !cleaned.startsWith(`${mod.modulePath}/`))
788
+ return null;
789
+ const sub = cleaned === mod.modulePath ? '' : cleaned.slice(mod.modulePath.length + 1);
790
+ const targetDir = normalizeDir(path.posix.join(mod.moduleRootDir, sub));
791
+ return params.idx.goRepByDir.get(targetDir) ?? null;
792
+ };
793
+ const findRustCrateRootDir = (fileRelPath, crateRootDirs) => {
794
+ for (const dir of crateRootDirs) {
795
+ if (!dir)
796
+ return '';
797
+ if (fileRelPath.startsWith(`${dir}/`))
798
+ return dir;
799
+ }
800
+ return null;
801
+ };
802
+ const moduleDirForRustFile = (fileRelPath, crateRootFiles) => {
803
+ const dir = normalizeDir(path.posix.dirname(fileRelPath));
804
+ if (crateRootFiles.has(fileRelPath))
805
+ return dir;
806
+ const base = path.posix.basename(fileRelPath);
807
+ if (base === 'mod.rs')
808
+ return dir;
809
+ const stem = path.posix.basename(fileRelPath, '.rs');
810
+ return normalizeDir(path.posix.join(dir, stem));
811
+ };
812
+ const resolveRustLocalImportKey = (params) => {
813
+ let cleaned = (params.specifier.split(/[?#]/, 1)[0] ?? '').trim();
814
+ cleaned = cleaned.replace(/;$/, '');
815
+ cleaned = cleaned.replace(/^::+/, '');
816
+ if (!cleaned)
817
+ return null;
818
+ const rawSegs = cleaned.split('::').filter(Boolean);
819
+ const segs = [];
820
+ for (const seg of rawSegs) {
821
+ const m = seg.match(/^[A-Za-z_][A-Za-z0-9_]*/);
822
+ if (!m?.[0])
823
+ break;
824
+ segs.push(m[0]);
825
+ }
826
+ if (segs.length === 0)
827
+ return null;
828
+ let baseDir;
829
+ let i = 0;
830
+ if (segs[0] === 'crate') {
831
+ const crateRoot = findRustCrateRootDir(params.importerRelPath, params.idx.rustCrateRootDirs);
832
+ if (crateRoot === null)
833
+ return null;
834
+ baseDir = crateRoot;
835
+ i = 1;
836
+ }
837
+ else if (segs[0] === 'self') {
838
+ baseDir = moduleDirForRustFile(params.importerRelPath, params.idx.rustCrateRootFiles);
839
+ i = 1;
840
+ }
841
+ else if (segs[0] === 'super') {
842
+ baseDir = moduleDirForRustFile(params.importerRelPath, params.idx.rustCrateRootFiles);
843
+ while (i < segs.length && segs[i] === 'super') {
844
+ baseDir = normalizeDir(path.posix.dirname(baseDir));
845
+ i++;
846
+ }
847
+ }
848
+ else {
849
+ return null;
850
+ }
851
+ if (i >= segs.length)
852
+ return null;
853
+ let curDir = baseDir;
854
+ let lastResolvedRelPath = null;
855
+ for (let j = i; j < segs.length; j++) {
856
+ const name = segs[j];
857
+ const cand1 = path.posix.join(curDir, `${name}.rs`);
858
+ if (params.idx.keyByRelPath.has(cand1)) {
859
+ lastResolvedRelPath = cand1;
860
+ curDir = moduleDirForRustFile(cand1, params.idx.rustCrateRootFiles);
861
+ continue;
862
+ }
863
+ const cand2 = path.posix.join(curDir, name, 'mod.rs');
864
+ if (params.idx.keyByRelPath.has(cand2)) {
865
+ lastResolvedRelPath = cand2;
866
+ curDir = moduleDirForRustFile(cand2, params.idx.rustCrateRootFiles);
867
+ continue;
868
+ }
869
+ break;
870
+ }
871
+ if (!lastResolvedRelPath)
872
+ return null;
873
+ return params.idx.keyByRelPath.get(lastResolvedRelPath) ?? null;
874
+ };
875
+ const resolveLocalImportKey = (file, specifier) => {
876
+ const idx = repoIndexes.get(file.repoId);
877
+ if (!idx)
878
+ return null;
879
+ const ext = path.extname(file.repoRelativePath).toLowerCase();
880
+ if (isJsLike(ext)) {
881
+ const rel = resolveJsLocalImportRelPath({
882
+ importerRelPath: file.repoRelativePath,
883
+ specifier,
884
+ keyByRelPath: idx.keyByRelPath,
885
+ });
886
+ if (!rel)
887
+ return null;
888
+ return idx.keyByRelPath.get(rel) ?? null;
889
+ }
890
+ if (ext === '.py') {
891
+ return resolvePythonLocalImportKey({
892
+ importerRelPath: file.repoRelativePath,
893
+ specifier,
894
+ suffixIndex: idx.suffixIndex,
895
+ keyByRelPath: idx.keyByRelPath,
896
+ });
897
+ }
898
+ if (ext === '.java') {
899
+ return resolveJavaLocalImportKey({
900
+ specifier,
901
+ suffixIndex: idx.suffixIndex,
902
+ keyByRelPath: idx.keyByRelPath,
903
+ });
904
+ }
905
+ if (ext === '.go') {
906
+ return resolveGoLocalImportKey({ importerRelPath: file.repoRelativePath, specifier, idx });
907
+ }
908
+ if (ext === '.rs') {
909
+ return resolveRustLocalImportKey({ importerRelPath: file.repoRelativePath, specifier, idx });
910
+ }
911
+ return null;
912
+ };
913
+ const isExternalImportForStandalone = (file, specifier) => {
914
+ const ext = path.extname(file.repoRelativePath).toLowerCase();
915
+ const cleaned = specifier.split(/[?#]/, 1)[0] ?? '';
916
+ // If we can confidently resolve it to a repo file, it's internal.
917
+ if (resolveLocalImportKey(file, cleaned))
918
+ return false;
919
+ // Otherwise, fall back to language syntax heuristics.
920
+ if (isJsLike(ext)) {
921
+ return !(cleaned.startsWith('.') || cleaned.startsWith('/'));
922
+ }
923
+ if (ext === '.go') {
924
+ // In-module Go imports are internal even though they are not relative paths.
925
+ if (isGoModuleLocalImport(file, cleaned))
926
+ return false;
927
+ }
928
+ if (ext === '.rs') {
929
+ // Rust intra-crate paths.
930
+ if (cleaned.startsWith('crate::') ||
931
+ cleaned.startsWith('self::') ||
932
+ cleaned.startsWith('super::') ||
933
+ cleaned.startsWith('::crate::') ||
934
+ cleaned.startsWith('::self::') ||
935
+ cleaned.startsWith('::super::')) {
936
+ return false;
937
+ }
938
+ }
939
+ // For other languages, only treat explicit relative paths as internal.
940
+ if (cleaned.startsWith('.') || cleaned.startsWith('/'))
941
+ return false;
942
+ return true;
943
+ };
944
+ const importGraph = new Map(); // fileKey -> imported fileKeys
945
+ const reverseImportGraph = new Map(); // fileKey -> fileKeys that import it
946
+ // First pass: extract exports and imports
947
+ const fileData = new Map(); // fileKey -> data
948
+ for (const file of files) {
949
+ if (file.kind !== 'code')
950
+ continue;
951
+ const ext = path.extname(file.repoRelativePath).toLowerCase();
952
+ if (!eligibleExtensions.has(ext))
953
+ continue;
954
+ const key = fileKey(file);
955
+ try {
956
+ const raw = await fs.readFile(file.bundleNormAbsPath, 'utf8');
957
+ const content = raw.replace(/\r\n/g, '\n');
958
+ let exports = [];
959
+ let imports = [];
960
+ try {
961
+ const parsed = await extractModuleSyntaxWasm(file.repoRelativePath, content);
962
+ if (parsed) {
963
+ exports = Array.from(new Set(parsed.exports)).sort();
964
+ imports = Array.from(new Set(parsed.imports.map((i) => i.module))).sort();
965
+ }
966
+ else {
967
+ exports = extractExports(content, file.repoRelativePath).sort();
968
+ imports = extractImports(content, file.repoRelativePath).sort();
969
+ }
970
+ }
971
+ catch {
972
+ // Keep Phase2 analysis robust: fall back to regex if parsing fails.
973
+ exports = extractExports(content, file.repoRelativePath).sort();
974
+ imports = extractImports(content, file.repoRelativePath).sort();
975
+ }
976
+ const loc = content
977
+ .split('\n')
978
+ .filter((l) => l.trim() && !l.trim().startsWith('//')).length;
979
+ fileData.set(key, { exports, imports, content, loc });
980
+ // Build local-import graph (resolved to known repo files)
981
+ const localImportKeys = new Set();
982
+ for (const imp of imports) {
983
+ const targetKey = resolveLocalImportKey(file, imp);
984
+ if (targetKey)
985
+ localImportKeys.add(targetKey);
986
+ }
987
+ importGraph.set(key, localImportKeys);
988
+ }
989
+ catch {
990
+ // Skip files that can't be read
991
+ }
992
+ }
993
+ // Build reverse import graph
994
+ for (const [fromKey, toKeys] of importGraph.entries()) {
995
+ for (const toKey of toKeys) {
996
+ if (!reverseImportGraph.has(toKey)) {
997
+ reverseImportGraph.set(toKey, new Set());
998
+ }
999
+ reverseImportGraph.get(toKey).add(fromKey);
1000
+ }
1001
+ }
1002
+ // Second pass: create ModuleInfo
1003
+ for (const file of files) {
1004
+ if (file.kind !== 'code')
1005
+ continue;
1006
+ const ext = path.extname(file.repoRelativePath).toLowerCase();
1007
+ if (!eligibleExtensions.has(ext))
1008
+ continue;
1009
+ const key = fileKey(file);
1010
+ const data = fileData.get(key);
1011
+ if (!data)
1012
+ continue;
1013
+ const importedBy = reverseImportGraph.get(key) || new Set();
1014
+ const role = determineModuleRole(file, importedBy);
1015
+ const complexity = calculateComplexity(data.loc, data.imports.length);
1016
+ const externalImportCount = data.imports.filter((imp) => isExternalImportForStandalone(file, imp)).length;
1017
+ const standalone = externalImportCount <= 3; // Few external deps
1018
+ modules.push({
1019
+ path: file.bundleNormRelativePath,
1020
+ exports: data.exports,
1021
+ imports: data.imports,
1022
+ role,
1023
+ standalone,
1024
+ complexity,
1025
+ loc: data.loc,
1026
+ });
1027
+ }
1028
+ return modules;
1029
+ }
1030
+ /**
1031
+ * Phase 2: Detect architecture patterns
1032
+ */
1033
+ function detectArchitecturePatterns(files, modules) {
1034
+ const patterns = [];
1035
+ const paths = files.map(f => f.repoRelativePath.toLowerCase());
1036
+ const pathSet = new Set(paths);
1037
+ // MVC pattern
1038
+ if (paths.some(p => p.includes('/model')) &&
1039
+ paths.some(p => p.includes('/view')) &&
1040
+ paths.some(p => p.includes('/controller'))) {
1041
+ patterns.push('MVC');
1042
+ }
1043
+ // Plugin architecture
1044
+ if (paths.some(p => p.includes('/plugin')) || paths.some(p => p.includes('/extension'))) {
1045
+ patterns.push('Plugin Architecture');
1046
+ }
1047
+ // Event-driven
1048
+ const hasEvents = modules.some(m => m.exports.some(e => e.toLowerCase().includes('event') || e.toLowerCase().includes('emitter')));
1049
+ if (hasEvents) {
1050
+ patterns.push('Event-Driven');
1051
+ }
1052
+ // Monorepo
1053
+ if (pathSet.has('packages') || pathSet.has('apps') || paths.filter(p => p === 'package.json').length > 1) {
1054
+ patterns.push('Monorepo');
1055
+ }
1056
+ // Layered architecture
1057
+ if (paths.some(p => p.includes('/service')) &&
1058
+ paths.some(p => p.includes('/repository')) ||
1059
+ paths.some(p => p.includes('/dao'))) {
1060
+ patterns.push('Layered Architecture');
1061
+ }
1062
+ // Microservices indicators
1063
+ if (pathSet.has('docker-compose.yml') || paths.filter(p => p.includes('/service/')).length > 3) {
1064
+ patterns.push('Microservices');
1065
+ }
1066
+ // CLI
1067
+ if (paths.some(p => p.includes('/cli/') || p.includes('/command'))) {
1068
+ patterns.push('CLI');
1069
+ }
1070
+ return patterns;
1071
+ }
1072
+ /**
1073
+ * Phase 2: Analyze technology stack
1074
+ */
1075
+ function analyzeTechStack(languages, dependencies, frameworks) {
1076
+ const primaryLang = languages[0]?.language || 'Unknown';
1077
+ let runtime;
1078
+ let packageManager;
1079
+ const buildTools = [];
1080
+ const testFrameworks = [];
1081
+ // Detect runtime
1082
+ if (primaryLang === 'TypeScript' || primaryLang === 'JavaScript') {
1083
+ runtime = 'Node.js';
1084
+ }
1085
+ else if (primaryLang === 'Python') {
1086
+ runtime = 'Python';
1087
+ }
1088
+ else if (primaryLang === 'Go') {
1089
+ runtime = 'Go';
1090
+ }
1091
+ // Package manager
1092
+ packageManager = dependencies.manager !== 'unknown' ? dependencies.manager : undefined;
1093
+ // Build tools
1094
+ const allDeps = [...dependencies.runtime, ...dependencies.dev].map(d => d.name.toLowerCase());
1095
+ if (primaryLang === 'TypeScript')
1096
+ buildTools.push('TypeScript');
1097
+ if (allDeps.includes('webpack'))
1098
+ buildTools.push('Webpack');
1099
+ if (allDeps.includes('vite'))
1100
+ buildTools.push('Vite');
1101
+ if (allDeps.includes('rollup'))
1102
+ buildTools.push('Rollup');
1103
+ if (allDeps.includes('esbuild'))
1104
+ buildTools.push('esbuild');
1105
+ if (allDeps.includes('babel'))
1106
+ buildTools.push('Babel');
1107
+ // Test frameworks
1108
+ if (allDeps.includes('jest'))
1109
+ testFrameworks.push('Jest');
1110
+ if (allDeps.includes('vitest'))
1111
+ testFrameworks.push('Vitest');
1112
+ if (allDeps.includes('mocha'))
1113
+ testFrameworks.push('Mocha');
1114
+ if (allDeps.includes('pytest'))
1115
+ testFrameworks.push('Pytest');
1116
+ if (allDeps.includes('unittest'))
1117
+ testFrameworks.push('unittest');
1118
+ return {
1119
+ language: primaryLang,
1120
+ runtime,
1121
+ packageManager,
1122
+ buildTools: buildTools.length > 0 ? buildTools : undefined,
1123
+ testFrameworks: testFrameworks.length > 0 ? testFrameworks : undefined,
1124
+ };
1125
+ }