autodocs-engine 0.9.8 → 0.10.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 (76) hide show
  1. package/README.md +123 -117
  2. package/dist/anti-pattern-detector.js +22 -0
  3. package/dist/anti-pattern-detector.js.map +1 -1
  4. package/dist/architecture-detector.js +33 -29
  5. package/dist/architecture-detector.js.map +1 -1
  6. package/dist/ast-parser.js +31 -3
  7. package/dist/ast-parser.js.map +1 -1
  8. package/dist/benchmark/scorer.js +3 -1
  9. package/dist/benchmark/scorer.js.map +1 -1
  10. package/dist/bin/autodocs-engine.js +7 -0
  11. package/dist/bin/autodocs-engine.js.map +1 -1
  12. package/dist/bin/serve.d.ts +1 -0
  13. package/dist/bin/serve.js +5 -1
  14. package/dist/bin/serve.js.map +1 -1
  15. package/dist/bin/setup-hooks.d.ts +1 -0
  16. package/dist/bin/setup-hooks.js +59 -0
  17. package/dist/bin/setup-hooks.js.map +1 -0
  18. package/dist/config.d.ts +1 -0
  19. package/dist/config.js +4 -0
  20. package/dist/config.js.map +1 -1
  21. package/dist/convention-extractor.js +10 -0
  22. package/dist/convention-extractor.js.map +1 -1
  23. package/dist/detectors/api-patterns.d.ts +2 -0
  24. package/dist/detectors/api-patterns.js +101 -0
  25. package/dist/detectors/api-patterns.js.map +1 -0
  26. package/dist/detectors/async-patterns.d.ts +2 -0
  27. package/dist/detectors/async-patterns.js +79 -0
  28. package/dist/detectors/async-patterns.js.map +1 -0
  29. package/dist/detectors/error-handling.js +82 -30
  30. package/dist/detectors/error-handling.js.map +1 -1
  31. package/dist/detectors/state-management.d.ts +2 -0
  32. package/dist/detectors/state-management.js +75 -0
  33. package/dist/detectors/state-management.js.map +1 -0
  34. package/dist/execution-flow.d.ts +23 -0
  35. package/dist/execution-flow.js +286 -0
  36. package/dist/execution-flow.js.map +1 -0
  37. package/dist/git-history.js +18 -7
  38. package/dist/git-history.js.map +1 -1
  39. package/dist/implicit-coupling.d.ts +7 -0
  40. package/dist/implicit-coupling.js +40 -0
  41. package/dist/implicit-coupling.js.map +1 -0
  42. package/dist/import-chain.js +12 -3
  43. package/dist/import-chain.js.map +1 -1
  44. package/dist/index.js +20 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/llm/serializer.js +8 -6
  47. package/dist/llm/serializer.js.map +1 -1
  48. package/dist/mcp/cache.d.ts +11 -1
  49. package/dist/mcp/cache.js +49 -7
  50. package/dist/mcp/cache.js.map +1 -1
  51. package/dist/mcp/queries.d.ts +19 -3
  52. package/dist/mcp/queries.js +342 -62
  53. package/dist/mcp/queries.js.map +1 -1
  54. package/dist/mcp/server.d.ts +1 -0
  55. package/dist/mcp/server.js +42 -1
  56. package/dist/mcp/server.js.map +1 -1
  57. package/dist/mcp/tools.d.ts +1 -0
  58. package/dist/mcp/tools.js +162 -20
  59. package/dist/mcp/tools.js.map +1 -1
  60. package/dist/output-validator.js +73 -0
  61. package/dist/output-validator.js.map +1 -1
  62. package/dist/pipeline.js +37 -0
  63. package/dist/pipeline.js.map +1 -1
  64. package/dist/symbol-graph.js +4 -0
  65. package/dist/symbol-graph.js.map +1 -1
  66. package/dist/type-enricher.d.ts +17 -0
  67. package/dist/type-enricher.js +127 -0
  68. package/dist/type-enricher.js.map +1 -0
  69. package/dist/type-resolver.d.ts +13 -0
  70. package/dist/type-resolver.js +75 -0
  71. package/dist/type-resolver.js.map +1 -0
  72. package/dist/types.d.ts +37 -4
  73. package/dist/types.js +7 -2
  74. package/dist/types.js.map +1 -1
  75. package/hooks/autodocs-hook.cjs +227 -0
  76. package/package.json +3 -2
@@ -67,6 +67,31 @@ export function getWorkflowRules(analysis, filePath) {
67
67
  return rules;
68
68
  return rules.filter((r) => r.trigger.includes(filePath) || r.action.includes(filePath));
69
69
  }
70
+ export function getImplicitCouplingForFile(analysis, filePath, packagePath) {
71
+ const pkg = resolvePackage(analysis, packagePath);
72
+ const edges = pkg.implicitCoupling ?? [];
73
+ return edges.filter((e) => e.file1 === filePath || e.file2 === filePath).sort((a, b) => b.jaccard - a.jaccard);
74
+ }
75
+ export function getExportedNamesForFile(analysis, filePath, packagePath) {
76
+ const pkg = resolvePackage(analysis, packagePath);
77
+ return pkg.publicAPI.filter((e) => e.sourceFile === filePath && !e.isTypeOnly).map((e) => e.name);
78
+ }
79
+ export function getExecutionFlows(analysis, packagePath) {
80
+ return resolvePackage(analysis, packagePath).executionFlows ?? [];
81
+ }
82
+ export function getFlowsForFiles(analysis, files, packagePath) {
83
+ const flows = getExecutionFlows(analysis, packagePath);
84
+ const fileSet = new Set(files);
85
+ return flows.filter((f) => f.files.some((file) => fileSet.has(file)));
86
+ }
87
+ export function getFlowsForFunction(analysis, fnName, packagePath) {
88
+ const flows = getExecutionFlows(analysis, packagePath);
89
+ return flows.filter((f) => f.steps.includes(fnName));
90
+ }
91
+ export function getImportersOfSymbol(analysis, symbol, sourceFile, packagePath) {
92
+ const pkg = resolvePackage(analysis, packagePath);
93
+ return (pkg.importChain ?? []).filter((e) => e.source === sourceFile && e.symbols.includes(symbol));
94
+ }
70
95
  export function getContributionPatterns(analysis, packagePath, directory) {
71
96
  const pkg = resolvePackage(analysis, packagePath);
72
97
  const patterns = pkg.contributionPatterns ?? [];
@@ -360,11 +385,108 @@ function findLastExportFromLine(content) {
360
385
  return lastLine;
361
386
  }
362
387
  // ─── Diagnose Queries ───────────────────────────────────────────────────────
363
- // Scoring thresholds (validated by adversarial review, 10 models)
364
- const MISSING_CO_CHANGE_MIN_COUNT = 5; // Minimum co-change frequency to activate signal
365
- const MISSING_CO_CHANGE_MIN_JACCARD = 0.4; // Minimum Jaccard coupling for missing co-change
366
- const RECENCY_DECAY_LAMBDA = 0.05; // Exponential decay: half-life ~14 hours
367
- const RECENCY_FLOOR = 0.05; // Minimum recency score (prevents zero for old changes)
388
+ // ─── Scoring Functions (Phase 4: continuous weights, bi-modal decay, sigmoid smoothing) ───
389
+ /** Bi-modal recency decay: fast initial + slow tail for weekend bugs. */
390
+ function recencyScore(hoursAgo) {
391
+ // At 1h: 0.94, 6h: 0.61, 14h: 0.39, 48h: 0.21, 72h: 0.19
392
+ return 0.7 * Math.exp(-0.2 * hoursAgo) + 0.3 * Math.exp(-0.01 * hoursAgo);
393
+ }
394
+ /** Sigmoid with configurable steepness. k=5 gives a meaningfully smooth transition. */
395
+ function sigmoid(value, midpoint, k = 5) {
396
+ return 1 / (1 + Math.exp(-k * (value - midpoint)));
397
+ }
398
+ // Weight interpolation thresholds
399
+ const RECENT_RICHNESS_SATURATION = 10; // ≥10 recent files = full recent signal
400
+ const COCHANGE_RICHNESS_SATURATION = 20; // ≥20 co-change edges = full co-change signal
401
+ /**
402
+ * Continuous weight interpolation based on data richness (replaces 3 discrete configs).
403
+ * Weights sum to ~100 at any data richness level. When recent data is sparse,
404
+ * coupling/dependency absorb the weight. When co-change data is sparse, recency dominates.
405
+ */
406
+ function computeWeights(recentFilesCount, cochangeEdgesCount) {
407
+ const rr = Math.min(recentFilesCount / RECENT_RICHNESS_SATURATION, 1); // 0-1
408
+ const cr = Math.min(cochangeEdgesCount / COCHANGE_RICHNESS_SATURATION, 1); // 0-1
409
+ return {
410
+ missingCoChange: 30 * rr * cr, // only active with both recent changes AND co-change data
411
+ recency: 20 * rr + 10 * (1 - cr) * rr, // absorbs co-change weight when co-change sparse
412
+ coupling: 15 * cr + 20 * (1 - rr) * cr, // absorbs recency weight when recent data sparse
413
+ dependency: 10 + 15 * (1 - rr) * (1 - cr), // baseline + absorbs when both signals sparse
414
+ workflow: 10 + 5 * (1 - cr), // baseline + boost when co-change sparse
415
+ testMapping: 15, // always active — test name convention is high-precision, 0-cost
416
+ directoryLocality: 10, // always active — test/plugins/X → src/plugins/X
417
+ };
418
+ }
419
+ /**
420
+ * Test-to-source mapping using two complementary signals:
421
+ * 1. Naming convention (high precision): test/foo.test.ts → src/foo.ts identifies test SUBJECT
422
+ * 2. Import graph (broader recall): test imports this candidate — tiebreaker for imports
423
+ *
424
+ * Naming match returns 1.0 (strong), import match returns 0.5 (weaker — test may import many files).
425
+ */
426
+ function testToSourceScore(testFile, candidateFile, importByImporter) {
427
+ if (!testFile)
428
+ return 0;
429
+ // Signal 1 (primary): naming convention — "this test is ABOUT this file"
430
+ const stripped = testFile.replace(/\.(test|spec)\.(ts|tsx|js|jsx)$/, ".$2").replace(/^(test|__tests__)\//, "src/");
431
+ if (candidateFile === stripped)
432
+ return 1;
433
+ // Signal 2 (tiebreaker): import graph — "the test uses this file"
434
+ const testImports = importByImporter.get(testFile);
435
+ if (testImports?.some((e) => e.source === candidateFile))
436
+ return 0.5;
437
+ return 0;
438
+ }
439
+ /** Directory locality: test path shares a SPECIFIC component with candidate source path. */
440
+ const GENERIC_PATH_PARTS = new Set([
441
+ "test",
442
+ "tests",
443
+ "__tests__",
444
+ "src",
445
+ "lib",
446
+ "dist",
447
+ "build",
448
+ "fixtures",
449
+ "plugins",
450
+ "detectors",
451
+ "handlers",
452
+ "controllers",
453
+ "routes",
454
+ "utils",
455
+ "helpers",
456
+ "core",
457
+ "common",
458
+ "shared",
459
+ "internal",
460
+ "packages",
461
+ ]);
462
+ function directoryLocalityScore(testFile, candidateFile) {
463
+ if (!testFile)
464
+ return 0;
465
+ // Extract the most specific non-generic path component from the test file
466
+ // For "test/plugins/astro-sharp-image-service.test.ts" → "astro-sharp-image-service" → try "astro"
467
+ const testBase = testFile.replace(/.*\//, "").replace(/\.(test|spec)\.[^.]+$/, "");
468
+ const candParts = candidateFile.split("/").filter((p) => !p.includes("."));
469
+ // Primary: test base name contains or is contained by a candidate directory
470
+ // "astro-sharp-image-service" contains "astro" → matches src/plugins/astro/
471
+ for (const cp of candParts) {
472
+ if (GENERIC_PATH_PARTS.has(cp))
473
+ continue;
474
+ if (testBase.includes(cp) || cp.includes(testBase))
475
+ return 1;
476
+ }
477
+ return 0;
478
+ }
479
+ function classifyErrorType(typeName, message) {
480
+ if (typeName === "TypeError")
481
+ return "type";
482
+ if (typeName === "ReferenceError")
483
+ return "reference";
484
+ if (typeName === "SyntaxError")
485
+ return "syntax";
486
+ if (typeName === "AssertionError" || /assert|expect|toBe|toEqual|toMatch/i.test(message))
487
+ return "assertion";
488
+ return "runtime";
489
+ }
368
490
  /**
369
491
  * Extract file paths, test file, and error message from raw error/stack trace text.
370
492
  * Handles V8 stacks, TypeScript compiler errors, Vitest output, and generic patterns.
@@ -374,10 +496,16 @@ export function parseErrorText(errorText, rootDir) {
374
496
  let testFile = null;
375
497
  let message = null;
376
498
  // Cap input to prevent DoS from pathologically large error strings
377
- const text = errorText.length > 100_000 ? errorText.slice(0, 100_000) : errorText;
378
- const msgMatch = text.match(/(?:TypeError|ReferenceError|Error|SyntaxError):\s*(.+)/);
379
- if (msgMatch)
380
- message = msgMatch[1].trim();
499
+ // Strip ANSI escape codes (terminal colors, bold, etc.)
500
+ const capped = errorText.length > 100_000 ? errorText.slice(0, 100_000) : errorText;
501
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequence stripping requires \x1B
502
+ const text = capped.replace(/\x1B\[[0-9;]*m/g, "");
503
+ let errorType = null;
504
+ const msgMatch = text.match(/(TypeError|ReferenceError|AssertionError|SyntaxError|Error):\s*([^\n]+)/);
505
+ if (msgMatch) {
506
+ errorType = classifyErrorType(msgMatch[1], msgMatch[2]);
507
+ message = msgMatch[2].trim();
508
+ }
381
509
  for (const line of text.split("\n")) {
382
510
  let m;
383
511
  // Vitest FAIL header: "FAIL test/foo.test.ts > ..."
@@ -400,13 +528,27 @@ export function parseErrorText(errorText, rootDir) {
400
528
  addProjectFile(fileSet, m[1], rootDir);
401
529
  continue;
402
530
  }
531
+ // Webpack/Vite build error: "ERROR in ./src/foo.ts" or "[vite] Error: ... src/foo.ts"
532
+ if ((m = line.match(/ERROR\s+in\s+\.?\/?([\w/.-]+\.[jt]sx?)/))) {
533
+ addProjectFile(fileSet, m[1], rootDir);
534
+ continue;
535
+ }
536
+ if ((m = line.match(/\[vite\]\s+.*?([\w/.-]+\.[jt]sx?)/))) {
537
+ addProjectFile(fileSet, m[1], rootDir);
538
+ continue;
539
+ }
540
+ // Nested stack trace: "Caused by: ... at file:line:col"
541
+ if ((m = line.match(/[Cc]aused by:.*?([\w/.-]+\.[jt]sx?):(\d+)/))) {
542
+ addProjectFile(fileSet, m[1], rootDir);
543
+ continue;
544
+ }
403
545
  // Generic: any relative path with a directory separator, ending in .ts/.js:line
404
546
  // Covers app/, pages/, components/, packages/, server/, api/, etc.
405
547
  if ((m = line.match(/\b([a-zA-Z][^\s:]*\/[^\s:]+\.[jt]sx?):(\d+)/))) {
406
548
  addProjectFile(fileSet, m[1], rootDir);
407
549
  }
408
550
  }
409
- return { files: [...fileSet], testFile, message };
551
+ return { files: [...fileSet], testFile, message, errorType };
410
552
  }
411
553
  /**
412
554
  * Query git for recently changed files: uncommitted (hoursAgo=0) + committed (last 7 days).
@@ -497,15 +639,19 @@ export function traceImportChain(analysis, from, to, packagePath) {
497
639
  }
498
640
  /**
499
641
  * Score candidate files using 5 signals with dynamic weights + call graph bonus.
500
- * Returns top 5 suspects sorted by score descending.
642
+ * Returns top 5 suspects with confidence assessment based on signal quality.
501
643
  */
502
- export function buildSuspectList(analysis, errorFiles, recentChanges, packagePath) {
644
+ export function buildSuspectList(analysis, errorFiles, recentChanges, packagePath, testFile) {
503
645
  const pkg = resolvePackage(analysis, packagePath);
504
646
  const errorSet = new Set(errorFiles);
505
647
  const chain = pkg.importChain ?? [];
506
648
  const coChangeEdges = pkg.gitHistory?.coChangeEdges ?? [];
507
649
  const callGraph = pkg.callGraph ?? [];
508
650
  const workflowRules = analysis.crossPackage?.workflowRules ?? [];
651
+ // Detect unselective imports: does the test file import the package entry point?
652
+ // If so, the import graph floods with candidates — downweight dependency signal.
653
+ const entryPoint = pkg.architecture.entryPoint;
654
+ const testImportsEntryPoint = testFile && entryPoint ? chain.some((e) => e.importer === testFile && e.source === entryPoint) : false;
509
655
  // Index recent changes by file
510
656
  const changeMap = new Map();
511
657
  for (const c of recentChanges) {
@@ -513,80 +659,147 @@ export function buildSuspectList(analysis, errorFiles, recentChanges, packagePat
513
659
  changeMap.set(c.file, c);
514
660
  }
515
661
  const changedFiles = new Set(changeMap.keys());
516
- // 1. Collect candidates: import neighbors + co-change partners of error files
517
- const candidateSymbols = new Map(); // file max symbolCount
662
+ // 1. Collect candidates with directional awareness:
663
+ // Upstream (dependencies of error site) are more likely root causes.
664
+ // Downstream (consumers of error site) are more likely needing updates.
665
+ const upstreamSymbols = new Map(); // files error site depends on
666
+ const downstreamSymbols = new Map(); // files that depend on error site
518
667
  const candidateCoupling = new Map(); // file → max Jaccard
519
- for (const errorFile of errorFiles) {
520
- // Files the error file imports from (upstream — likely root cause)
521
- for (const edge of chain) {
522
- if (edge.importer === errorFile) {
523
- setMax(candidateSymbols, edge.source, edge.symbolCount);
524
- }
668
+ // Index import chain by both importer and source for O(1) lookup
669
+ const importByImporter = new Map();
670
+ const importBySource = new Map();
671
+ for (const edge of chain) {
672
+ let byImp = importByImporter.get(edge.importer);
673
+ if (!byImp) {
674
+ byImp = [];
675
+ importByImporter.set(edge.importer, byImp);
525
676
  }
526
- // Files that import from error file (downstream — may need updating)
527
- for (const edge of chain) {
528
- if (edge.source === errorFile) {
529
- setMax(candidateSymbols, edge.importer, edge.symbolCount);
677
+ byImp.push(edge);
678
+ let bySrc = importBySource.get(edge.source);
679
+ if (!bySrc) {
680
+ bySrc = [];
681
+ importBySource.set(edge.source, bySrc);
682
+ }
683
+ bySrc.push(edge);
684
+ }
685
+ // Index co-change edges by both files
686
+ const coChangeByFile = new Map();
687
+ for (const edge of coChangeEdges) {
688
+ for (const f of [edge.file1, edge.file2]) {
689
+ let arr = coChangeByFile.get(f);
690
+ if (!arr) {
691
+ arr = [];
692
+ coChangeByFile.set(f, arr);
530
693
  }
694
+ arr.push(edge);
531
695
  }
532
- // Co-change partners
533
- for (const edge of coChangeEdges) {
534
- if (edge.file1 === errorFile) {
535
- setMax(candidateCoupling, edge.file2, edge.jaccard);
696
+ }
697
+ // Multi-hop upstream traversal: BFS through imports up to depth 2
698
+ // Depth 1 = direct dependency (full score), depth 2 = transitive (half score)
699
+ // Depth 3+ floods candidate pool on large repos — diminishing returns
700
+ const MAX_CANDIDATE_DEPTH = 2;
701
+ const visited = new Set(errorFiles);
702
+ let frontier = new Set(errorFiles);
703
+ for (let depth = 1; depth <= MAX_CANDIDATE_DEPTH; depth++) {
704
+ const depthFactor = 1 / depth; // 1.0, 0.5, 0.33
705
+ const nextFrontier = new Set();
706
+ for (const file of frontier) {
707
+ // Upstream: files this node imports FROM
708
+ for (const edge of importByImporter.get(file) ?? []) {
709
+ if (!visited.has(edge.source)) {
710
+ setMax(upstreamSymbols, edge.source, edge.symbolCount * depthFactor);
711
+ nextFrontier.add(edge.source);
712
+ }
536
713
  }
537
- else if (edge.file2 === errorFile) {
538
- setMax(candidateCoupling, edge.file1, edge.jaccard);
714
+ // Downstream: only at depth 1 (direct consumers of error site)
715
+ if (depth === 1) {
716
+ for (const edge of importBySource.get(file) ?? []) {
717
+ if (!visited.has(edge.importer)) {
718
+ setMax(downstreamSymbols, edge.importer, edge.symbolCount);
719
+ }
720
+ }
721
+ }
722
+ // Co-change partners (only at depth 1)
723
+ if (depth === 1) {
724
+ for (const edge of coChangeByFile.get(file) ?? []) {
725
+ const partner = edge.file1 === file ? edge.file2 : edge.file1;
726
+ setMax(candidateCoupling, partner, edge.jaccard);
727
+ }
539
728
  }
540
729
  }
730
+ for (const f of nextFrontier)
731
+ visited.add(f);
732
+ frontier = nextFrontier;
541
733
  }
542
- // Include error files as candidates (they may have been recently changed)
543
734
  for (const f of errorFiles) {
544
- if (!candidateSymbols.has(f) && !candidateCoupling.has(f)) {
545
- candidateSymbols.set(f, 0);
735
+ if (!upstreamSymbols.has(f) && !downstreamSymbols.has(f) && !candidateCoupling.has(f)) {
736
+ upstreamSymbols.set(f, 0);
737
+ }
738
+ }
739
+ const allCandidates = new Set([...upstreamSymbols.keys(), ...downstreamSymbols.keys(), ...candidateCoupling.keys()]);
740
+ // Directory locality candidate discovery: when test imports entry point (unselective),
741
+ // scan all known files for directory-name matches with the test file.
742
+ // This adds candidates that the import graph can't reach.
743
+ if (testImportsEntryPoint && testFile) {
744
+ const allKnownFiles = new Set();
745
+ for (const edge of chain) {
746
+ allKnownFiles.add(edge.importer);
747
+ allKnownFiles.add(edge.source);
748
+ }
749
+ for (const file of allKnownFiles) {
750
+ if (allCandidates.has(file) || errorSet.has(file))
751
+ continue;
752
+ if (directoryLocalityScore(testFile, file) > 0) {
753
+ allCandidates.add(file);
754
+ }
546
755
  }
547
756
  }
548
- const allCandidates = new Set([...candidateSymbols.keys(), ...candidateCoupling.keys()]);
549
- // 2. Missing co-change: for each recently-changed relevant file,
550
- // find high-coupling partners that weren't updated
757
+ // 2. Missing co-change: joint sigmoid on both Jaccard AND count
551
758
  const missingCoChange = new Map();
552
759
  const relevant = [...changedFiles].filter((f) => errorSet.has(f) || allCandidates.has(f));
553
760
  for (const changedFile of relevant) {
554
761
  for (const edge of coChangeEdges) {
555
762
  const partner = edge.file1 === changedFile ? edge.file2 : edge.file2 === changedFile ? edge.file1 : null;
556
- if (!partner)
557
- continue;
558
- if (edge.coChangeCount < MISSING_CO_CHANGE_MIN_COUNT || edge.jaccard <= MISSING_CO_CHANGE_MIN_JACCARD)
559
- continue;
560
- if (changedFiles.has(partner))
763
+ if (!partner || changedFiles.has(partner))
561
764
  continue;
562
- setMax(missingCoChange, partner, edge.jaccard);
563
- allCandidates.add(partner);
564
- }
565
- }
566
- // 3. Dynamic weights — adapt to available signals
567
- const hasRecentChanges = recentChanges.some((c) => c.hoursAgo < 24);
568
- const hasCoChangeData = coChangeEdges.length > 0;
569
- const w = hasRecentChanges
570
- ? hasCoChangeData
571
- ? { missingCoChange: 35, recency: 25, coupling: 20, dependency: 10, workflow: 10 }
572
- : { missingCoChange: 0, recency: 40, dependency: 35, coupling: 0, workflow: 25 }
573
- : { missingCoChange: 0, recency: 0, coupling: 50, dependency: 35, workflow: 15 };
765
+ // Joint sigmoid: both Jaccard and count must be strong
766
+ const score = sigmoid(edge.jaccard, 0.4, 5) * sigmoid(Math.log(edge.coChangeCount), Math.log(5), 3);
767
+ if (score > 0.05) {
768
+ setMax(missingCoChange, partner, score);
769
+ allCandidates.add(partner);
770
+ }
771
+ }
772
+ }
773
+ // 3. Continuous weight interpolation
774
+ const recentCount = recentChanges.filter((c) => c.hoursAgo < 48).length;
775
+ const w = computeWeights(recentCount, coChangeEdges.length);
574
776
  // 4. Score each candidate
575
777
  const suspects = [];
576
778
  for (const file of allCandidates) {
577
779
  const change = changeMap.get(file);
780
+ const rawCoupling = candidateCoupling.get(file) ?? 0;
781
+ // Directional dependency: upstream gets mild boost only when no recency data
782
+ const upScore = Math.min((upstreamSymbols.get(file) ?? 0) / 10, 1);
783
+ const downScore = Math.min((downstreamSymbols.get(file) ?? 0) / 10, 1);
784
+ const upstreamBoost = recentChanges.length === 0 ? 1.3 : 1.0;
785
+ // When test imports entry point, import graph is unselective — reduce dependency weight
786
+ const selectivityFactor = testImportsEntryPoint ? 0.5 : 1.0;
578
787
  const signals = {
579
788
  missingCoChange: missingCoChange.get(file) ?? 0,
580
- recency: change ? Math.max(RECENCY_FLOOR, Math.exp(-RECENCY_DECAY_LAMBDA * change.hoursAgo)) : 0,
581
- coupling: candidateCoupling.get(file) ?? 0,
582
- dependency: Math.min((candidateSymbols.get(file) ?? 0) / 10, 1),
789
+ recency: change ? recencyScore(change.hoursAgo) : 0,
790
+ coupling: sigmoid(rawCoupling, 0.2, 5),
791
+ dependency: Math.max(upScore * upstreamBoost, downScore) * selectivityFactor,
583
792
  workflow: workflowRules.some((r) => r.trigger.includes(file) || r.action.includes(file)) ? 1.0 : 0,
793
+ testMapping: testToSourceScore(testFile ?? null, file, importByImporter),
794
+ directoryLocality: directoryLocalityScore(testFile ?? null, file),
584
795
  };
585
796
  let score = w.missingCoChange * signals.missingCoChange +
586
797
  w.recency * signals.recency +
587
798
  w.coupling * signals.coupling +
588
799
  w.dependency * signals.dependency +
589
- w.workflow * signals.workflow;
800
+ w.workflow * signals.workflow +
801
+ w.testMapping * signals.testMapping +
802
+ w.directoryLocality * signals.directoryLocality;
590
803
  // Call graph bonus: 1.5x if call edge exists, but NOT for the error site itself
591
804
  const callGraphBonus = !errorSet.has(file) &&
592
805
  callGraph.some((e) => (e.fromFile === file && errorSet.has(e.toFile)) || (e.toFile === file && errorSet.has(e.fromFile)));
@@ -594,18 +807,26 @@ export function buildSuspectList(analysis, errorFiles, recentChanges, packagePat
594
807
  score *= 1.5;
595
808
  // Build human-readable reason
596
809
  const reasons = [];
810
+ if (signals.testMapping > 0) {
811
+ reasons.push("directly imported by failing test");
812
+ }
597
813
  if (signals.missingCoChange > 0) {
598
- reasons.push(`Missing co-change: expected to change (${Math.round(signals.missingCoChange * 100)}% coupling) but wasn't updated`);
814
+ reasons.push(`Missing co-change: expected to change but wasn't updated`);
599
815
  }
600
816
  if (signals.recency > 0.1 && change) {
601
817
  const ago = change.isUncommitted ? "uncommitted changes" : `changed ${formatHoursAgo(change.hoursAgo)}`;
602
818
  reasons.push(ago + (change.commitMessage ? `: "${change.commitMessage}"` : ""));
603
819
  }
604
- if (signals.coupling > 0) {
605
- reasons.push(`${Math.round(signals.coupling * 100)}% co-change coupling`);
820
+ if (signals.coupling > 0.1) {
821
+ reasons.push(`${Math.round(rawCoupling * 100)}% co-change coupling`);
606
822
  }
607
823
  if (signals.dependency > 0) {
608
- reasons.push(`${candidateSymbols.get(file) ?? 0} symbols shared with error site`);
824
+ const isUpstream = (upstreamSymbols.get(file) ?? 0) > 0;
825
+ const symCount = Math.max(upstreamSymbols.get(file) ?? 0, downstreamSymbols.get(file) ?? 0);
826
+ reasons.push(`${symCount} symbols ${isUpstream ? "(dependency of" : "(depends on"} error site)`);
827
+ }
828
+ if (signals.directoryLocality > 0) {
829
+ reasons.push("directory matches test name");
609
830
  }
610
831
  if (callGraphBonus) {
611
832
  reasons.push("call graph connection (1.5x)");
@@ -620,10 +841,69 @@ export function buildSuspectList(analysis, errorFiles, recentChanges, packagePat
620
841
  reason: reasons.join("; "),
621
842
  });
622
843
  }
623
- return suspects
844
+ const ranked = suspects
624
845
  .filter((s) => s.score > 0)
625
846
  .sort((a, b) => b.score - a.score)
626
847
  .slice(0, 5);
848
+ return assessConfidence(ranked, {
849
+ hasRecentChanges: recentChanges.length > 0,
850
+ hasCoChangeData: coChangeEdges.length > 0,
851
+ hasCallGraph: callGraph.length > 0,
852
+ testHasDirectImport: testFile ? (importByImporter.get(testFile)?.length ?? 0) > 0 : false,
853
+ testImportsEntryPoint,
854
+ candidatePoolSize: allCandidates.size,
855
+ });
856
+ }
857
+ /**
858
+ * Assess confidence based on signal quality — not the suspects themselves.
859
+ * High: multiple independent signals available, top suspect strongly differentiated.
860
+ * Medium: some signals available, moderate differentiation.
861
+ * Low: thin signal (no co-change, test doesn't import sources, large candidate pool).
862
+ */
863
+ function assessConfidence(suspects, signals) {
864
+ if (suspects.length === 0) {
865
+ return { suspects, confidence: "low", confidenceReason: "No suspects found" };
866
+ }
867
+ // Count how many independent signal sources are available
868
+ let signalCount = 0;
869
+ if (signals.hasRecentChanges)
870
+ signalCount++;
871
+ if (signals.hasCoChangeData)
872
+ signalCount++;
873
+ if (signals.hasCallGraph)
874
+ signalCount++;
875
+ if (signals.testHasDirectImport)
876
+ signalCount++;
877
+ // Score discrimination: how much does #1 stand out from #2?
878
+ const topScore = suspects[0].score;
879
+ const secondScore = suspects.length > 1 ? suspects[1].score : 0;
880
+ const discrimination = secondScore > 0 ? topScore / secondScore : topScore > 0 ? 10 : 0;
881
+ // Large candidate pool with low discrimination = noisy
882
+ const isNoisy = signals.candidatePoolSize > 20 && discrimination < 1.5;
883
+ let confidence;
884
+ let confidenceReason;
885
+ if (signalCount >= 3 && discrimination >= 1.5 && !isNoisy) {
886
+ confidence = "high";
887
+ confidenceReason = `${signalCount} independent signals, clear top suspect`;
888
+ }
889
+ else if (signalCount >= 2 || (signalCount >= 1 && discrimination >= 2)) {
890
+ confidence = "medium";
891
+ confidenceReason = `${signalCount} signal${signalCount === 1 ? "" : "s"}, ${discrimination >= 1.5 ? "moderate" : "weak"} differentiation`;
892
+ }
893
+ else {
894
+ confidence = "low";
895
+ const reasons = [];
896
+ if (signals.testImportsEntryPoint)
897
+ reasons.push("test imports package entry point (integration test pattern)");
898
+ else if (!signals.testHasDirectImport)
899
+ reasons.push("test doesn't directly import source modules");
900
+ if (!signals.hasCoChangeData)
901
+ reasons.push("no co-change history available");
902
+ if (isNoisy)
903
+ reasons.push(`${signals.candidatePoolSize} candidates with similar scores`);
904
+ confidenceReason = reasons.length > 0 ? reasons.join("; ") : "limited signal available";
905
+ }
906
+ return { suspects, confidence, confidenceReason };
627
907
  }
628
908
  // ─── Diagnose Helpers ───────────────────────────────────────────────────────
629
909
  function normalizePath(raw, rootDir) {