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.
- package/README.md +123 -117
- package/dist/anti-pattern-detector.js +22 -0
- package/dist/anti-pattern-detector.js.map +1 -1
- package/dist/architecture-detector.js +33 -29
- package/dist/architecture-detector.js.map +1 -1
- package/dist/ast-parser.js +31 -3
- package/dist/ast-parser.js.map +1 -1
- package/dist/benchmark/scorer.js +3 -1
- package/dist/benchmark/scorer.js.map +1 -1
- package/dist/bin/autodocs-engine.js +7 -0
- package/dist/bin/autodocs-engine.js.map +1 -1
- package/dist/bin/serve.d.ts +1 -0
- package/dist/bin/serve.js +5 -1
- package/dist/bin/serve.js.map +1 -1
- package/dist/bin/setup-hooks.d.ts +1 -0
- package/dist/bin/setup-hooks.js +59 -0
- package/dist/bin/setup-hooks.js.map +1 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/convention-extractor.js +10 -0
- package/dist/convention-extractor.js.map +1 -1
- package/dist/detectors/api-patterns.d.ts +2 -0
- package/dist/detectors/api-patterns.js +101 -0
- package/dist/detectors/api-patterns.js.map +1 -0
- package/dist/detectors/async-patterns.d.ts +2 -0
- package/dist/detectors/async-patterns.js +79 -0
- package/dist/detectors/async-patterns.js.map +1 -0
- package/dist/detectors/error-handling.js +82 -30
- package/dist/detectors/error-handling.js.map +1 -1
- package/dist/detectors/state-management.d.ts +2 -0
- package/dist/detectors/state-management.js +75 -0
- package/dist/detectors/state-management.js.map +1 -0
- package/dist/execution-flow.d.ts +23 -0
- package/dist/execution-flow.js +286 -0
- package/dist/execution-flow.js.map +1 -0
- package/dist/git-history.js +18 -7
- package/dist/git-history.js.map +1 -1
- package/dist/implicit-coupling.d.ts +7 -0
- package/dist/implicit-coupling.js +40 -0
- package/dist/implicit-coupling.js.map +1 -0
- package/dist/import-chain.js +12 -3
- package/dist/import-chain.js.map +1 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/serializer.js +8 -6
- package/dist/llm/serializer.js.map +1 -1
- package/dist/mcp/cache.d.ts +11 -1
- package/dist/mcp/cache.js +49 -7
- package/dist/mcp/cache.js.map +1 -1
- package/dist/mcp/queries.d.ts +19 -3
- package/dist/mcp/queries.js +342 -62
- package/dist/mcp/queries.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +42 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +1 -0
- package/dist/mcp/tools.js +162 -20
- package/dist/mcp/tools.js.map +1 -1
- package/dist/output-validator.js +73 -0
- package/dist/output-validator.js.map +1 -1
- package/dist/pipeline.js +37 -0
- package/dist/pipeline.js.map +1 -1
- package/dist/symbol-graph.js +4 -0
- package/dist/symbol-graph.js.map +1 -1
- package/dist/type-enricher.d.ts +17 -0
- package/dist/type-enricher.js +127 -0
- package/dist/type-enricher.js.map +1 -0
- package/dist/type-resolver.d.ts +13 -0
- package/dist/type-resolver.js +75 -0
- package/dist/type-resolver.js.map +1 -0
- package/dist/types.d.ts +37 -4
- package/dist/types.js +7 -2
- package/dist/types.js.map +1 -1
- package/hooks/autodocs-hook.cjs +227 -0
- package/package.json +3 -2
package/dist/mcp/queries.js
CHANGED
|
@@ -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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
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
|
|
517
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
|
|
538
|
-
|
|
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 (!
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 ?
|
|
581
|
-
coupling:
|
|
582
|
-
dependency: Math.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|