codesift-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +66 -21
- package/README.md +346 -56
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +11 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +177 -67
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +157 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/hooks.d.ts +3 -0
- package/dist/cli/hooks.d.ts.map +1 -0
- package/dist/cli/hooks.js +163 -0
- package/dist/cli/hooks.js.map +1 -0
- package/dist/cli/setup.d.ts +25 -0
- package/dist/cli/setup.d.ts.map +1 -0
- package/dist/cli/setup.js +400 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -1
- package/dist/formatters-shortening.d.ts +7 -0
- package/dist/formatters-shortening.d.ts.map +1 -0
- package/dist/formatters-shortening.js +68 -0
- package/dist/formatters-shortening.js.map +1 -0
- package/dist/formatters.d.ts +314 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +396 -0
- package/dist/formatters.js.map +1 -0
- package/dist/instructions.d.ts +6 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +72 -0
- package/dist/instructions.js.map +1 -0
- package/dist/lsp/lsp-client.d.ts +21 -0
- package/dist/lsp/lsp-client.d.ts.map +1 -0
- package/dist/lsp/lsp-client.js +122 -0
- package/dist/lsp/lsp-client.js.map +1 -0
- package/dist/lsp/lsp-manager.d.ts +12 -0
- package/dist/lsp/lsp-manager.d.ts.map +1 -0
- package/dist/lsp/lsp-manager.js +82 -0
- package/dist/lsp/lsp-manager.js.map +1 -0
- package/dist/lsp/lsp-servers.d.ts +13 -0
- package/dist/lsp/lsp-servers.d.ts.map +1 -0
- package/dist/lsp/lsp-servers.js +57 -0
- package/dist/lsp/lsp-servers.js.map +1 -0
- package/dist/lsp/lsp-tools.d.ts +67 -0
- package/dist/lsp/lsp-tools.d.ts.map +1 -0
- package/dist/lsp/lsp-tools.js +359 -0
- package/dist/lsp/lsp-tools.js.map +1 -0
- package/dist/parser/extractors/_shared.d.ts +11 -0
- package/dist/parser/extractors/_shared.d.ts.map +1 -0
- package/dist/parser/extractors/_shared.js +38 -0
- package/dist/parser/extractors/_shared.js.map +1 -0
- package/dist/parser/extractors/astro.d.ts +15 -0
- package/dist/parser/extractors/astro.d.ts.map +1 -0
- package/dist/parser/extractors/astro.js +104 -0
- package/dist/parser/extractors/astro.js.map +1 -0
- package/dist/parser/extractors/conversation.d.ts +16 -0
- package/dist/parser/extractors/conversation.d.ts.map +1 -0
- package/dist/parser/extractors/conversation.js +196 -0
- package/dist/parser/extractors/conversation.js.map +1 -0
- package/dist/parser/extractors/go.d.ts.map +1 -1
- package/dist/parser/extractors/go.js +22 -45
- package/dist/parser/extractors/go.js.map +1 -1
- package/dist/parser/extractors/python.d.ts +1 -1
- package/dist/parser/extractors/python.d.ts.map +1 -1
- package/dist/parser/extractors/python.js +19 -50
- package/dist/parser/extractors/python.js.map +1 -1
- package/dist/parser/extractors/rust.d.ts +1 -1
- package/dist/parser/extractors/rust.d.ts.map +1 -1
- package/dist/parser/extractors/rust.js +7 -34
- package/dist/parser/extractors/rust.js.map +1 -1
- package/dist/parser/extractors/typescript.d.ts +1 -1
- package/dist/parser/extractors/typescript.d.ts.map +1 -1
- package/dist/parser/extractors/typescript.js +99 -68
- package/dist/parser/extractors/typescript.js.map +1 -1
- package/dist/parser/parser-manager.d.ts.map +1 -1
- package/dist/parser/parser-manager.js +12 -2
- package/dist/parser/parser-manager.js.map +1 -1
- package/dist/parser/symbol-extractor.d.ts +2 -0
- package/dist/parser/symbol-extractor.d.ts.map +1 -1
- package/dist/parser/symbol-extractor.js +2 -0
- package/dist/parser/symbol-extractor.js.map +1 -1
- package/dist/register-tools.d.ts +127 -0
- package/dist/register-tools.d.ts.map +1 -0
- package/dist/register-tools.js +1453 -0
- package/dist/register-tools.js.map +1 -0
- package/dist/retrieval/codebase-retrieval.d.ts +4 -26
- package/dist/retrieval/codebase-retrieval.d.ts.map +1 -1
- package/dist/retrieval/codebase-retrieval.js +105 -403
- package/dist/retrieval/codebase-retrieval.js.map +1 -1
- package/dist/retrieval/retrieval-constants.d.ts +27 -0
- package/dist/retrieval/retrieval-constants.d.ts.map +1 -0
- package/dist/retrieval/retrieval-constants.js +27 -0
- package/dist/retrieval/retrieval-constants.js.map +1 -0
- package/dist/retrieval/retrieval-schemas.d.ts +107 -0
- package/dist/retrieval/retrieval-schemas.d.ts.map +1 -0
- package/dist/retrieval/retrieval-schemas.js +102 -0
- package/dist/retrieval/retrieval-schemas.js.map +1 -0
- package/dist/retrieval/retrieval-utils.d.ts +40 -0
- package/dist/retrieval/retrieval-utils.d.ts.map +1 -0
- package/dist/retrieval/retrieval-utils.js +139 -0
- package/dist/retrieval/retrieval-utils.js.map +1 -0
- package/dist/retrieval/semantic-handlers.d.ts +8 -0
- package/dist/retrieval/semantic-handlers.d.ts.map +1 -0
- package/dist/retrieval/semantic-handlers.js +152 -0
- package/dist/retrieval/semantic-handlers.js.map +1 -0
- package/dist/search/bm25.d.ts +6 -1
- package/dist/search/bm25.d.ts.map +1 -1
- package/dist/search/bm25.js +95 -32
- package/dist/search/bm25.js.map +1 -1
- package/dist/search/chunker.d.ts +10 -0
- package/dist/search/chunker.d.ts.map +1 -1
- package/dist/search/chunker.js +63 -11
- package/dist/search/chunker.js.map +1 -1
- package/dist/search/reranker.d.ts +15 -0
- package/dist/search/reranker.d.ts.map +1 -0
- package/dist/search/reranker.js +126 -0
- package/dist/search/reranker.js.map +1 -0
- package/dist/search/semantic.d.ts +1 -1
- package/dist/search/semantic.d.ts.map +1 -1
- package/dist/search/semantic.js +40 -45
- package/dist/search/semantic.js.map +1 -1
- package/dist/server-helpers.d.ts +29 -0
- package/dist/server-helpers.d.ts.map +1 -0
- package/dist/server-helpers.js +312 -0
- package/dist/server-helpers.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +11 -271
- package/dist/server.js.map +1 -1
- package/dist/storage/_shared.d.ts +9 -0
- package/dist/storage/_shared.d.ts.map +1 -0
- package/dist/storage/_shared.js +26 -0
- package/dist/storage/_shared.js.map +1 -0
- package/dist/storage/chunk-store.d.ts.map +1 -1
- package/dist/storage/chunk-store.js +23 -63
- package/dist/storage/chunk-store.js.map +1 -1
- package/dist/storage/embedding-store.d.ts +6 -3
- package/dist/storage/embedding-store.d.ts.map +1 -1
- package/dist/storage/embedding-store.js +54 -30
- package/dist/storage/embedding-store.js.map +1 -1
- package/dist/storage/graph-store.d.ts +48 -0
- package/dist/storage/graph-store.d.ts.map +1 -0
- package/dist/storage/graph-store.js +52 -0
- package/dist/storage/graph-store.js.map +1 -0
- package/dist/storage/index-store.d.ts +5 -0
- package/dist/storage/index-store.d.ts.map +1 -1
- package/dist/storage/index-store.js +28 -16
- package/dist/storage/index-store.js.map +1 -1
- package/dist/storage/registry.d.ts +4 -0
- package/dist/storage/registry.d.ts.map +1 -1
- package/dist/storage/registry.js +16 -16
- package/dist/storage/registry.js.map +1 -1
- package/dist/storage/usage-stats.d.ts +6 -0
- package/dist/storage/usage-stats.d.ts.map +1 -1
- package/dist/storage/usage-stats.js +59 -11
- package/dist/storage/usage-stats.js.map +1 -1
- package/dist/storage/usage-tracker.d.ts +3 -0
- package/dist/storage/usage-tracker.d.ts.map +1 -1
- package/dist/storage/usage-tracker.js +50 -132
- package/dist/storage/usage-tracker.js.map +1 -1
- package/dist/storage/watcher.d.ts +2 -1
- package/dist/storage/watcher.d.ts.map +1 -1
- package/dist/storage/watcher.js +16 -16
- package/dist/storage/watcher.js.map +1 -1
- package/dist/tools/ast-query-tools.d.ts +29 -0
- package/dist/tools/ast-query-tools.d.ts.map +1 -0
- package/dist/tools/ast-query-tools.js +110 -0
- package/dist/tools/ast-query-tools.js.map +1 -0
- package/dist/tools/boundary-tools.d.ts +31 -0
- package/dist/tools/boundary-tools.d.ts.map +1 -0
- package/dist/tools/boundary-tools.js +62 -0
- package/dist/tools/boundary-tools.js.map +1 -0
- package/dist/tools/clone-tools.d.ts +35 -0
- package/dist/tools/clone-tools.d.ts.map +1 -0
- package/dist/tools/clone-tools.js +181 -0
- package/dist/tools/clone-tools.js.map +1 -0
- package/dist/tools/community-tools.d.ts +23 -0
- package/dist/tools/community-tools.d.ts.map +1 -0
- package/dist/tools/community-tools.js +297 -0
- package/dist/tools/community-tools.js.map +1 -0
- package/dist/tools/complexity-tools.d.ts +34 -0
- package/dist/tools/complexity-tools.d.ts.map +1 -0
- package/dist/tools/complexity-tools.js +135 -0
- package/dist/tools/complexity-tools.js.map +1 -0
- package/dist/tools/context-tools.d.ts +44 -3
- package/dist/tools/context-tools.d.ts.map +1 -1
- package/dist/tools/context-tools.js +329 -99
- package/dist/tools/context-tools.js.map +1 -1
- package/dist/tools/conversation-tools.d.ts +107 -0
- package/dist/tools/conversation-tools.d.ts.map +1 -0
- package/dist/tools/conversation-tools.js +419 -0
- package/dist/tools/conversation-tools.js.map +1 -0
- package/dist/tools/coordinator-tools.d.ts +73 -0
- package/dist/tools/coordinator-tools.d.ts.map +1 -0
- package/dist/tools/coordinator-tools.js +153 -0
- package/dist/tools/coordinator-tools.js.map +1 -0
- package/dist/tools/cross-repo-tools.d.ts +43 -0
- package/dist/tools/cross-repo-tools.d.ts.map +1 -0
- package/dist/tools/cross-repo-tools.js +55 -0
- package/dist/tools/cross-repo-tools.js.map +1 -0
- package/dist/tools/diff-tools.d.ts +4 -1
- package/dist/tools/diff-tools.d.ts.map +1 -1
- package/dist/tools/diff-tools.js +23 -5
- package/dist/tools/diff-tools.js.map +1 -1
- package/dist/tools/frequency-tools.d.ts +46 -0
- package/dist/tools/frequency-tools.d.ts.map +1 -0
- package/dist/tools/frequency-tools.js +184 -0
- package/dist/tools/frequency-tools.js.map +1 -0
- package/dist/tools/generate-tools.d.ts.map +1 -1
- package/dist/tools/generate-tools.js +13 -2
- package/dist/tools/generate-tools.js.map +1 -1
- package/dist/tools/graph-tools.d.ts +44 -11
- package/dist/tools/graph-tools.d.ts.map +1 -1
- package/dist/tools/graph-tools.js +147 -104
- package/dist/tools/graph-tools.js.map +1 -1
- package/dist/tools/hotspot-tools.d.ts +24 -0
- package/dist/tools/hotspot-tools.d.ts.map +1 -0
- package/dist/tools/hotspot-tools.js +122 -0
- package/dist/tools/hotspot-tools.js.map +1 -0
- package/dist/tools/impact-tools.d.ts +13 -0
- package/dist/tools/impact-tools.d.ts.map +1 -0
- package/dist/tools/impact-tools.js +238 -0
- package/dist/tools/impact-tools.js.map +1 -0
- package/dist/tools/index-tools.d.ts +44 -3
- package/dist/tools/index-tools.d.ts.map +1 -1
- package/dist/tools/index-tools.js +530 -222
- package/dist/tools/index-tools.js.map +1 -1
- package/dist/tools/memory-tools.d.ts +35 -0
- package/dist/tools/memory-tools.d.ts.map +1 -0
- package/dist/tools/memory-tools.js +229 -0
- package/dist/tools/memory-tools.js.map +1 -0
- package/dist/tools/outline-tools.d.ts +24 -13
- package/dist/tools/outline-tools.d.ts.map +1 -1
- package/dist/tools/outline-tools.js +113 -87
- package/dist/tools/outline-tools.js.map +1 -1
- package/dist/tools/pattern-tools.d.ts +32 -0
- package/dist/tools/pattern-tools.d.ts.map +1 -0
- package/dist/tools/pattern-tools.js +116 -0
- package/dist/tools/pattern-tools.js.map +1 -0
- package/dist/tools/report-tools.d.ts +5 -0
- package/dist/tools/report-tools.d.ts.map +1 -0
- package/dist/tools/report-tools.js +167 -0
- package/dist/tools/report-tools.js.map +1 -0
- package/dist/tools/review-diff-tools.d.ts +148 -0
- package/dist/tools/review-diff-tools.d.ts.map +1 -0
- package/dist/tools/review-diff-tools.js +852 -0
- package/dist/tools/review-diff-tools.js.map +1 -0
- package/dist/tools/route-tools.d.ts +32 -0
- package/dist/tools/route-tools.d.ts.map +1 -0
- package/dist/tools/route-tools.js +276 -0
- package/dist/tools/route-tools.js.map +1 -0
- package/dist/tools/search-ranker.d.ts +5 -0
- package/dist/tools/search-ranker.d.ts.map +1 -0
- package/dist/tools/search-ranker.js +142 -0
- package/dist/tools/search-ranker.js.map +1 -0
- package/dist/tools/search-tools.d.ts +24 -1
- package/dist/tools/search-tools.d.ts.map +1 -1
- package/dist/tools/search-tools.js +459 -225
- package/dist/tools/search-tools.js.map +1 -1
- package/dist/tools/secret-tools.d.ts +104 -0
- package/dist/tools/secret-tools.d.ts.map +1 -0
- package/dist/tools/secret-tools.js +410 -0
- package/dist/tools/secret-tools.js.map +1 -0
- package/dist/tools/symbol-tools.d.ts +90 -2
- package/dist/tools/symbol-tools.d.ts.map +1 -1
- package/dist/tools/symbol-tools.js +576 -42
- package/dist/tools/symbol-tools.js.map +1 -1
- package/dist/types.d.ts +34 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/framework-detect.d.ts +5 -0
- package/dist/utils/framework-detect.d.ts.map +1 -0
- package/dist/utils/framework-detect.js +36 -0
- package/dist/utils/framework-detect.js.map +1 -0
- package/dist/utils/glob.d.ts +19 -0
- package/dist/utils/glob.d.ts.map +1 -0
- package/dist/utils/glob.js +74 -0
- package/dist/utils/glob.js.map +1 -0
- package/dist/utils/import-graph.d.ts +29 -0
- package/dist/utils/import-graph.d.ts.map +1 -0
- package/dist/utils/import-graph.js +125 -0
- package/dist/utils/import-graph.js.map +1 -0
- package/dist/utils/test-file.d.ts.map +1 -1
- package/dist/utils/test-file.js +1 -0
- package/dist/utils/test-file.js.map +1 -1
- package/dist/utils/walk.d.ts +45 -0
- package/dist/utils/walk.d.ts.map +1 -0
- package/dist/utils/walk.js +87 -0
- package/dist/utils/walk.js.map +1 -0
- package/package.json +10 -4
- package/rules/codesift.md +187 -0
- package/rules/codesift.mdc +192 -0
- package/rules/codex.md +187 -0
- package/rules/gemini.md +187 -0
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-diff-tools.ts
|
|
3
|
+
*
|
|
4
|
+
* Types, tier assignment, scoring, verdict, and orchestrator for the review_diff MCP tool.
|
|
5
|
+
* Pure functions + one async orchestrator that fans out sub-checks.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { changedSymbols } from "./diff-tools.js";
|
|
10
|
+
import { getCodeIndex } from "./index-tools.js";
|
|
11
|
+
import { impactAnalysis } from "./impact-tools.js";
|
|
12
|
+
import { scanSecrets } from "./secret-tools.js";
|
|
13
|
+
import { findDeadCode } from "./symbol-tools.js";
|
|
14
|
+
import { searchPatterns, listPatterns } from "./pattern-tools.js";
|
|
15
|
+
import { analyzeHotspots } from "./hotspot-tools.js";
|
|
16
|
+
import { analyzeComplexity } from "./complexity-tools.js";
|
|
17
|
+
import { validateGitRef } from "../utils/git-validation.js";
|
|
18
|
+
import { isTestFile } from "../utils/test-file.js";
|
|
19
|
+
import picomatch from "picomatch";
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Tier assignment
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
/**
|
|
24
|
+
* Returns the tier (1 | 2 | 3) for a given check name.
|
|
25
|
+
*
|
|
26
|
+
* Tier 1 — critical (−20 per finding): secrets, breaking
|
|
27
|
+
* Tier 2 — important (−5 per finding): coupling, complexity, dead-code,
|
|
28
|
+
* blast-radius, bug-patterns
|
|
29
|
+
* Tier 3 — advisory (−1 per finding, only if no T1/T2): test-gaps, hotspots
|
|
30
|
+
*/
|
|
31
|
+
export function findingTier(check) {
|
|
32
|
+
switch (check) {
|
|
33
|
+
case "secrets":
|
|
34
|
+
case "breaking":
|
|
35
|
+
return 1;
|
|
36
|
+
case "coupling":
|
|
37
|
+
case "complexity":
|
|
38
|
+
case "dead-code":
|
|
39
|
+
case "blast-radius":
|
|
40
|
+
case "bug-patterns":
|
|
41
|
+
return 2;
|
|
42
|
+
default:
|
|
43
|
+
return 3;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Score calculation
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* Calculates a 0-100 quality score from findings and check results.
|
|
51
|
+
*
|
|
52
|
+
* Deductions:
|
|
53
|
+
* - T1 findings: −20 each, floor at 0
|
|
54
|
+
* - T2 findings: −5 each, floor at 20 (overridden by T1 floor)
|
|
55
|
+
* - T3 findings: −1 each, floor at 50 (only applied when there are no T1/T2 findings)
|
|
56
|
+
* - Errored checks: −3 each
|
|
57
|
+
* - Final floor: 0
|
|
58
|
+
*/
|
|
59
|
+
export function calculateScore(findings, checks) {
|
|
60
|
+
const t1Count = findings.filter((f) => findingTier(f.check) === 1).length;
|
|
61
|
+
const t2Count = findings.filter((f) => findingTier(f.check) === 2).length;
|
|
62
|
+
const t3Count = findings.filter((f) => findingTier(f.check) === 3).length;
|
|
63
|
+
const errorCount = checks.filter((c) => c.status === "error").length;
|
|
64
|
+
let score = 100;
|
|
65
|
+
// Tier 1 deductions
|
|
66
|
+
score -= t1Count * 20;
|
|
67
|
+
if (score < 0)
|
|
68
|
+
score = 0;
|
|
69
|
+
// Tier 2 deductions (floor 20, but T1 can override below 20)
|
|
70
|
+
const afterT2 = score - t2Count * 5;
|
|
71
|
+
if (t1Count === 0) {
|
|
72
|
+
// T2 floor is 20 only when no T1 findings
|
|
73
|
+
score = Math.max(afterT2, 20);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
// T1 already applied; T2 can further reduce but overall floor is 0
|
|
77
|
+
score = Math.max(afterT2, 0);
|
|
78
|
+
}
|
|
79
|
+
// Tier 3 deductions (floor 50, only when no T1/T2 findings)
|
|
80
|
+
if (t1Count === 0 && t2Count === 0) {
|
|
81
|
+
const afterT3 = score - t3Count * 1;
|
|
82
|
+
score = Math.max(afterT3, 50);
|
|
83
|
+
}
|
|
84
|
+
// Error deductions
|
|
85
|
+
score -= errorCount * 3;
|
|
86
|
+
// Final floor
|
|
87
|
+
return Math.max(score, 0);
|
|
88
|
+
}
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Verdict determination
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* Determines the overall verdict from check statuses.
|
|
94
|
+
*
|
|
95
|
+
* - Any "fail" → "fail"
|
|
96
|
+
* - Any "warn" (and no "fail") → "warn"
|
|
97
|
+
* - Otherwise → "pass"
|
|
98
|
+
* - "timeout" and "error" do not affect verdict direction
|
|
99
|
+
*/
|
|
100
|
+
export function determineVerdict(checks) {
|
|
101
|
+
const hasFail = checks.some((c) => c.status === "fail");
|
|
102
|
+
if (hasFail)
|
|
103
|
+
return "fail";
|
|
104
|
+
const hasWarn = checks.some((c) => c.status === "warn");
|
|
105
|
+
if (hasWarn)
|
|
106
|
+
return "warn";
|
|
107
|
+
return "pass";
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// All known check names
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
const ALL_CHECKS = [
|
|
113
|
+
"secrets",
|
|
114
|
+
"breaking",
|
|
115
|
+
"coupling",
|
|
116
|
+
"complexity",
|
|
117
|
+
"dead-code",
|
|
118
|
+
"blast-radius",
|
|
119
|
+
"bug-patterns",
|
|
120
|
+
"test-gaps",
|
|
121
|
+
"hotspots",
|
|
122
|
+
];
|
|
123
|
+
const DEFAULT_MAX_FILES = 50;
|
|
124
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 30_000;
|
|
125
|
+
const HEAD_TILDE_PATTERN = /^HEAD~\d+$/;
|
|
126
|
+
function withTimeout(promise, ms) {
|
|
127
|
+
return Promise.race([
|
|
128
|
+
promise,
|
|
129
|
+
new Promise((resolve) => setTimeout(() => resolve({ status: "timeout" }), ms)),
|
|
130
|
+
]);
|
|
131
|
+
}
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Check adapters
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
/**
|
|
136
|
+
* Blast-radius check: run impactAnalysis and map affected_symbols to T2 findings.
|
|
137
|
+
*/
|
|
138
|
+
export async function checkBlastRadius(index, since, until) {
|
|
139
|
+
const start = Date.now();
|
|
140
|
+
try {
|
|
141
|
+
const result = await impactAnalysis(index.repo, since, { until });
|
|
142
|
+
const MAX_BLAST_FINDINGS = 10;
|
|
143
|
+
const allFindings = result.affected_symbols.map((sym) => ({
|
|
144
|
+
check: "blast-radius",
|
|
145
|
+
severity: "warn",
|
|
146
|
+
message: `Symbol "${sym.name}" in ${sym.file} is affected by changes`,
|
|
147
|
+
file: sym.file,
|
|
148
|
+
symbol: sym.name,
|
|
149
|
+
}));
|
|
150
|
+
const findings = allFindings.slice(0, MAX_BLAST_FINDINGS);
|
|
151
|
+
const totalCount = allFindings.length;
|
|
152
|
+
return {
|
|
153
|
+
check: "blast-radius",
|
|
154
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
155
|
+
findings,
|
|
156
|
+
duration_ms: Date.now() - start,
|
|
157
|
+
summary: totalCount > 0
|
|
158
|
+
? `${totalCount} affected symbol(s) found${totalCount > MAX_BLAST_FINDINGS ? ` (showing ${MAX_BLAST_FINDINGS})` : ""}`
|
|
159
|
+
: "No blast radius detected",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
return {
|
|
164
|
+
check: "blast-radius",
|
|
165
|
+
status: "error",
|
|
166
|
+
findings: [],
|
|
167
|
+
duration_ms: Date.now() - start,
|
|
168
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Secrets check: run scanSecrets scoped to changedFiles and map findings to T1.
|
|
174
|
+
*/
|
|
175
|
+
export async function checkSecrets(index, changedFiles) {
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
try {
|
|
178
|
+
// Build a glob pattern that matches any of the changed files
|
|
179
|
+
const filePattern = changedFiles.length === 1
|
|
180
|
+
? changedFiles[0]
|
|
181
|
+
: `{${changedFiles.join(",")}}`;
|
|
182
|
+
const result = await scanSecrets(index.repo, { file_pattern: filePattern, min_confidence: "high" });
|
|
183
|
+
const findings = result.findings.map((f) => ({
|
|
184
|
+
check: "secrets",
|
|
185
|
+
severity: "error",
|
|
186
|
+
message: `Secret detected: ${f.rule} (${f.severity}) — ${f.masked_secret}`,
|
|
187
|
+
file: f.file,
|
|
188
|
+
line: f.line,
|
|
189
|
+
}));
|
|
190
|
+
return {
|
|
191
|
+
check: "secrets",
|
|
192
|
+
status: findings.length > 0 ? "fail" : "pass",
|
|
193
|
+
findings,
|
|
194
|
+
duration_ms: Date.now() - start,
|
|
195
|
+
summary: findings.length > 0
|
|
196
|
+
? `${findings.length} secret(s) detected`
|
|
197
|
+
: "No secrets detected",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
return {
|
|
202
|
+
check: "secrets",
|
|
203
|
+
status: "error",
|
|
204
|
+
findings: [],
|
|
205
|
+
duration_ms: Date.now() - start,
|
|
206
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Dead-code check: run findDeadCode scoped to changedFiles and map candidates to T2 findings.
|
|
212
|
+
*/
|
|
213
|
+
export async function checkDeadCode(index, changedFiles) {
|
|
214
|
+
const start = Date.now();
|
|
215
|
+
try {
|
|
216
|
+
// Build a glob pattern that matches any of the changed files
|
|
217
|
+
const filePattern = changedFiles.length === 1
|
|
218
|
+
? changedFiles[0]
|
|
219
|
+
: `{${changedFiles.join(",")}}`;
|
|
220
|
+
const result = await findDeadCode(index.repo, { file_pattern: filePattern });
|
|
221
|
+
const findings = result.candidates.map((c) => ({
|
|
222
|
+
check: "dead-code",
|
|
223
|
+
severity: "warn",
|
|
224
|
+
message: `"${c.name}" appears unused — ${c.reason}`,
|
|
225
|
+
file: c.file,
|
|
226
|
+
line: c.start_line,
|
|
227
|
+
symbol: c.name,
|
|
228
|
+
}));
|
|
229
|
+
return {
|
|
230
|
+
check: "dead-code",
|
|
231
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
232
|
+
findings,
|
|
233
|
+
duration_ms: Date.now() - start,
|
|
234
|
+
summary: findings.length > 0
|
|
235
|
+
? `${findings.length} dead-code candidate(s) found`
|
|
236
|
+
: "No dead code detected",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
check: "dead-code",
|
|
242
|
+
status: "error",
|
|
243
|
+
findings: [],
|
|
244
|
+
duration_ms: Date.now() - start,
|
|
245
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Bug-patterns check: run all BUILTIN_PATTERNS via searchPatterns, merge and
|
|
251
|
+
* deduplicate findings across patterns.
|
|
252
|
+
*/
|
|
253
|
+
export async function checkBugPatterns(index, changedFiles) {
|
|
254
|
+
const start = Date.now();
|
|
255
|
+
try {
|
|
256
|
+
// Build a file_pattern covering all changed files
|
|
257
|
+
const filePattern = changedFiles.length === 1
|
|
258
|
+
? changedFiles[0]
|
|
259
|
+
: `{${changedFiles.join(",")}}`;
|
|
260
|
+
// Get all built-in pattern names
|
|
261
|
+
const patterns = listPatterns().map((p) => p.name);
|
|
262
|
+
// Run all patterns in parallel
|
|
263
|
+
const results = await Promise.all(patterns.map((p) => searchPatterns(index.repo, p, { file_pattern: filePattern })));
|
|
264
|
+
// Merge matches, dedup by (file, start_line, matched_pattern)
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const findings = [];
|
|
267
|
+
for (const result of results) {
|
|
268
|
+
for (const match of result.matches) {
|
|
269
|
+
const key = `${match.file}:${match.start_line}:${match.matched_pattern}`;
|
|
270
|
+
if (seen.has(key))
|
|
271
|
+
continue;
|
|
272
|
+
seen.add(key);
|
|
273
|
+
findings.push({
|
|
274
|
+
check: "bug-patterns",
|
|
275
|
+
severity: "warn",
|
|
276
|
+
message: `Pattern match: ${match.matched_pattern} — "${match.context}"`,
|
|
277
|
+
file: match.file,
|
|
278
|
+
line: match.start_line,
|
|
279
|
+
symbol: match.name,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
check: "bug-patterns",
|
|
285
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
286
|
+
findings,
|
|
287
|
+
duration_ms: Date.now() - start,
|
|
288
|
+
summary: findings.length > 0
|
|
289
|
+
? `${findings.length} bug pattern(s) found`
|
|
290
|
+
: "No bug patterns detected",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
return {
|
|
295
|
+
check: "bug-patterns",
|
|
296
|
+
status: "error",
|
|
297
|
+
findings: [],
|
|
298
|
+
duration_ms: Date.now() - start,
|
|
299
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Hotspots check: run analyzeHotspots and filter to files in changedFiles.
|
|
305
|
+
* Maps high-churn files to T3 advisory findings.
|
|
306
|
+
*/
|
|
307
|
+
export async function checkHotspots(index, changedFiles) {
|
|
308
|
+
const start = Date.now();
|
|
309
|
+
try {
|
|
310
|
+
const changedSet = new Set(changedFiles);
|
|
311
|
+
const result = await analyzeHotspots(index.repo);
|
|
312
|
+
const findings = result.hotspots
|
|
313
|
+
.filter((h) => changedSet.has(h.file))
|
|
314
|
+
.map((h) => ({
|
|
315
|
+
check: "hotspots",
|
|
316
|
+
severity: "warn",
|
|
317
|
+
message: `High churn file: ${h.file} — hotspot_score ${h.hotspot_score} (${h.commits} commits, ${h.lines_changed} lines changed)`,
|
|
318
|
+
file: h.file,
|
|
319
|
+
}));
|
|
320
|
+
return {
|
|
321
|
+
check: "hotspots",
|
|
322
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
323
|
+
findings,
|
|
324
|
+
duration_ms: Date.now() - start,
|
|
325
|
+
summary: findings.length > 0
|
|
326
|
+
? `${findings.length} hotspot file(s) in diff`
|
|
327
|
+
: "No hotspot files in diff",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
return {
|
|
332
|
+
check: "hotspots",
|
|
333
|
+
status: "error",
|
|
334
|
+
findings: [],
|
|
335
|
+
duration_ms: Date.now() - start,
|
|
336
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Complexity delta check: run analyzeComplexity and filter to functions in
|
|
342
|
+
* changedFiles with cyclomatic complexity > 10. Maps to T2 findings.
|
|
343
|
+
*/
|
|
344
|
+
export async function checkComplexityDelta(index, changedFiles) {
|
|
345
|
+
const start = Date.now();
|
|
346
|
+
try {
|
|
347
|
+
const changedSet = new Set(changedFiles);
|
|
348
|
+
const result = await analyzeComplexity(index.repo, { top_n: 50 });
|
|
349
|
+
const findings = result.functions
|
|
350
|
+
.filter((fn) => changedSet.has(fn.file) && fn.cyclomatic_complexity > 10)
|
|
351
|
+
.map((fn) => ({
|
|
352
|
+
check: "complexity",
|
|
353
|
+
severity: "warn",
|
|
354
|
+
message: `High complexity: "${fn.name}" in ${fn.file} — cyclomatic complexity ${fn.cyclomatic_complexity} (>${10})`,
|
|
355
|
+
file: fn.file,
|
|
356
|
+
line: fn.start_line,
|
|
357
|
+
symbol: fn.name,
|
|
358
|
+
}));
|
|
359
|
+
return {
|
|
360
|
+
check: "complexity",
|
|
361
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
362
|
+
findings,
|
|
363
|
+
duration_ms: Date.now() - start,
|
|
364
|
+
summary: findings.length > 0
|
|
365
|
+
? `${findings.length} high-complexity function(s) in diff`
|
|
366
|
+
: "No high-complexity functions in diff",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
return {
|
|
371
|
+
check: "complexity",
|
|
372
|
+
status: "error",
|
|
373
|
+
findings: [],
|
|
374
|
+
duration_ms: Date.now() - start,
|
|
375
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Coupling gaps check: parse git log for co-change pairs, compute Jaccard
|
|
381
|
+
* similarity, and flag coupled files that are missing from the diff.
|
|
382
|
+
*/
|
|
383
|
+
export async function checkCouplingGaps(repoRoot, changedFiles) {
|
|
384
|
+
const start = Date.now();
|
|
385
|
+
const MIN_SUPPORT = 3;
|
|
386
|
+
const MIN_JACCARD = 0.5;
|
|
387
|
+
const MAX_FILES_PER_COMMIT = 50;
|
|
388
|
+
try {
|
|
389
|
+
const raw = execFileSync("git", [
|
|
390
|
+
"log",
|
|
391
|
+
"--name-only",
|
|
392
|
+
"--no-merges",
|
|
393
|
+
"--diff-filter=AMRC",
|
|
394
|
+
"--since=180 days ago",
|
|
395
|
+
"--pretty=format:%H",
|
|
396
|
+
], { cwd: repoRoot, encoding: "utf-8", timeout: 15000 });
|
|
397
|
+
// Parse commits: git log --pretty=format:%H --name-only outputs:
|
|
398
|
+
// SHA\n\nfile1\nfile2\n\nSHA\n\nfile1\nfile2
|
|
399
|
+
// Split by \n\n yields alternating blocks: [SHA, files, SHA, files, ...]
|
|
400
|
+
const blocks = raw.split("\n\n").filter((b) => b.trim().length > 0);
|
|
401
|
+
const fileCommitCounts = new Map();
|
|
402
|
+
const pairCounts = new Map();
|
|
403
|
+
// Process pairs: blocks[i] = SHA, blocks[i+1] = file list
|
|
404
|
+
for (let i = 0; i < blocks.length - 1; i += 2) {
|
|
405
|
+
const fileBlock = blocks[i + 1];
|
|
406
|
+
const files = fileBlock.split("\n").filter((l) => l.trim().length > 0);
|
|
407
|
+
// Skip bulk commits
|
|
408
|
+
if (files.length > MAX_FILES_PER_COMMIT)
|
|
409
|
+
continue;
|
|
410
|
+
// Count file appearances
|
|
411
|
+
for (const file of files) {
|
|
412
|
+
fileCommitCounts.set(file, (fileCommitCounts.get(file) ?? 0) + 1);
|
|
413
|
+
}
|
|
414
|
+
// Count pairs (canonical: sorted alphabetically)
|
|
415
|
+
for (let i = 0; i < files.length; i++) {
|
|
416
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
417
|
+
const pair = [files[i], files[j]].sort().join("\0");
|
|
418
|
+
pairCounts.set(pair, (pairCounts.get(pair) ?? 0) + 1);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// For each changed file, find partners with high Jaccard that are NOT in the diff
|
|
423
|
+
const changedSet = new Set(changedFiles);
|
|
424
|
+
const findings = [];
|
|
425
|
+
for (const changedFile of changedFiles) {
|
|
426
|
+
for (const [pair, coCount] of pairCounts) {
|
|
427
|
+
if (coCount < MIN_SUPPORT)
|
|
428
|
+
continue;
|
|
429
|
+
const [fileA, fileB] = pair.split("\0");
|
|
430
|
+
let partner;
|
|
431
|
+
if (fileA === changedFile)
|
|
432
|
+
partner = fileB;
|
|
433
|
+
else if (fileB === changedFile)
|
|
434
|
+
partner = fileA;
|
|
435
|
+
else
|
|
436
|
+
continue;
|
|
437
|
+
// Skip if partner is already in the diff
|
|
438
|
+
if (changedSet.has(partner))
|
|
439
|
+
continue;
|
|
440
|
+
const countA = fileCommitCounts.get(fileA) ?? 0;
|
|
441
|
+
const countB = fileCommitCounts.get(fileB) ?? 0;
|
|
442
|
+
const jaccard = coCount / (countA + countB - coCount);
|
|
443
|
+
if (jaccard >= MIN_JACCARD) {
|
|
444
|
+
findings.push({
|
|
445
|
+
check: "coupling",
|
|
446
|
+
severity: "warn",
|
|
447
|
+
message: `"${changedFile}" is frequently co-changed with "${partner}" (Jaccard ${jaccard.toFixed(2)}, ${coCount} co-commits) but "${partner}" is not in this diff`,
|
|
448
|
+
file: changedFile,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return {
|
|
454
|
+
check: "coupling",
|
|
455
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
456
|
+
findings,
|
|
457
|
+
duration_ms: Date.now() - start,
|
|
458
|
+
summary: findings.length > 0
|
|
459
|
+
? `${findings.length} coupling gap(s) detected`
|
|
460
|
+
: "No coupling gaps detected",
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
return {
|
|
465
|
+
check: "coupling",
|
|
466
|
+
status: "error",
|
|
467
|
+
findings: [],
|
|
468
|
+
duration_ms: Date.now() - start,
|
|
469
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Breaking changes check: detect exported symbols removed between `since` and
|
|
475
|
+
* current index. For each changed .ts/.js file, `git show` retrieves the old
|
|
476
|
+
* source and a regex extracts export names. These are compared against the
|
|
477
|
+
* current index symbols. Missing exports → T1 "breaking" findings.
|
|
478
|
+
*
|
|
479
|
+
* File-level renames (detected via `git diff --find-renames`) are suppressed
|
|
480
|
+
* because renames naturally lose old export names.
|
|
481
|
+
*/
|
|
482
|
+
export async function checkBreakingChanges(index, repoRoot, changedFiles, since, until) {
|
|
483
|
+
const start = Date.now();
|
|
484
|
+
const TS_JS_RE = /\.(tsx?|jsx?)$/;
|
|
485
|
+
const EXPORT_NAMED_RE = /export\s+(?:async\s+)?(?:function|class|const|let|var|type|interface|enum)\s+(\w+)/g;
|
|
486
|
+
const EXPORT_DEFAULT_RE = /export\s+default/g;
|
|
487
|
+
try {
|
|
488
|
+
// 1. Detect renames so we can suppress findings for renamed files
|
|
489
|
+
let renameRaw = "";
|
|
490
|
+
try {
|
|
491
|
+
renameRaw = execFileSync("git", [
|
|
492
|
+
"diff",
|
|
493
|
+
"--find-renames",
|
|
494
|
+
"--name-status",
|
|
495
|
+
`${since}..${until || "HEAD"}`,
|
|
496
|
+
], { cwd: repoRoot, encoding: "utf-8", timeout: 10_000 });
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// If rename detection fails, proceed without suppression
|
|
500
|
+
}
|
|
501
|
+
const renamedFiles = new Set();
|
|
502
|
+
for (const line of renameRaw.split("\n")) {
|
|
503
|
+
if (line.startsWith("R")) {
|
|
504
|
+
// R100\told-path\tnew-path (tab-separated)
|
|
505
|
+
const parts = line.split("\t");
|
|
506
|
+
if (parts[1])
|
|
507
|
+
renamedFiles.add(parts[1]);
|
|
508
|
+
if (parts[2])
|
|
509
|
+
renamedFiles.add(parts[2]);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// 2. Filter to TS/JS files, exclude renames
|
|
513
|
+
const tsJsFiles = changedFiles.filter((f) => TS_JS_RE.test(f) && !renamedFiles.has(f));
|
|
514
|
+
const findings = [];
|
|
515
|
+
// 3. For each file, compare old exports vs current exports
|
|
516
|
+
for (const file of tsJsFiles) {
|
|
517
|
+
try {
|
|
518
|
+
const oldSource = execFileSync("git", ["show", `${since}:${file}`], { cwd: repoRoot, encoding: "utf-8", timeout: 10_000 });
|
|
519
|
+
// Extract old export names
|
|
520
|
+
const oldExports = new Set();
|
|
521
|
+
let match;
|
|
522
|
+
while ((match = EXPORT_NAMED_RE.exec(oldSource)) !== null) {
|
|
523
|
+
oldExports.add(match[1]);
|
|
524
|
+
}
|
|
525
|
+
while ((match = EXPORT_DEFAULT_RE.exec(oldSource)) !== null) {
|
|
526
|
+
oldExports.add("default");
|
|
527
|
+
}
|
|
528
|
+
if (oldExports.size === 0)
|
|
529
|
+
continue;
|
|
530
|
+
// Get current exports from index: top-level symbols in this file
|
|
531
|
+
const currentExports = new Set(index.symbols
|
|
532
|
+
.filter((s) => s.file === file && !s.parent)
|
|
533
|
+
.map((s) => s.name));
|
|
534
|
+
// Removed = in old but not in current
|
|
535
|
+
for (const name of oldExports) {
|
|
536
|
+
if (!currentExports.has(name)) {
|
|
537
|
+
findings.push({
|
|
538
|
+
check: "breaking",
|
|
539
|
+
severity: "error",
|
|
540
|
+
message: `Removed export "${name}" from ${file} — may break downstream consumers`,
|
|
541
|
+
file,
|
|
542
|
+
symbol: name,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
// git show failed → file didn't exist at `since` (new file), skip
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return {
|
|
552
|
+
check: "breaking",
|
|
553
|
+
status: findings.length > 0 ? "fail" : "pass",
|
|
554
|
+
findings,
|
|
555
|
+
duration_ms: Date.now() - start,
|
|
556
|
+
summary: findings.length > 0
|
|
557
|
+
? `${findings.length} removed export(s) detected`
|
|
558
|
+
: "No breaking changes detected",
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
return {
|
|
563
|
+
check: "breaking",
|
|
564
|
+
status: "error",
|
|
565
|
+
findings: [],
|
|
566
|
+
duration_ms: Date.now() - start,
|
|
567
|
+
summary: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Test-gaps check: for each changed non-test source file, verify that at least
|
|
573
|
+
* one test file covers it — either by naming convention or by import reference.
|
|
574
|
+
*
|
|
575
|
+
* Naming convention candidates:
|
|
576
|
+
* foo.ts → foo.test.ts, foo.spec.ts, __tests__/foo.ts, __tests__/foo.test.ts
|
|
577
|
+
*
|
|
578
|
+
* Import graph: search index symbols from test files whose source imports the
|
|
579
|
+
* source file's base name (without extension).
|
|
580
|
+
*
|
|
581
|
+
* If BOTH pathways find 0 tests → T3 advisory finding.
|
|
582
|
+
*/
|
|
583
|
+
export async function checkTestGaps(index, changedFiles) {
|
|
584
|
+
const start = Date.now();
|
|
585
|
+
const SOURCE_EXTENSIONS = /\.(tsx?|jsx?)$/;
|
|
586
|
+
// Only process non-test source files
|
|
587
|
+
const sourceFiles = changedFiles.filter((f) => SOURCE_EXTENSIONS.test(f) && !isTestFile(f));
|
|
588
|
+
const indexFilePaths = new Set(index.files.map((f) => f.path));
|
|
589
|
+
const findings = [];
|
|
590
|
+
for (const sourceFile of sourceFiles) {
|
|
591
|
+
// -----------------------------------------------------------------------
|
|
592
|
+
// 1. Naming check
|
|
593
|
+
// -----------------------------------------------------------------------
|
|
594
|
+
const dir = path.dirname(sourceFile);
|
|
595
|
+
const base = path.basename(sourceFile).replace(SOURCE_EXTENSIONS, "");
|
|
596
|
+
// Check co-located tests, __tests__/ dir, AND tests/ mirror directory
|
|
597
|
+
// e.g., src/tools/foo.ts → tests/tools/foo.test.ts
|
|
598
|
+
const testsDir = dir.replace(/^src\//, "tests/");
|
|
599
|
+
const candidates = [
|
|
600
|
+
path.join(dir, `${base}.test.ts`),
|
|
601
|
+
path.join(dir, `${base}.spec.ts`),
|
|
602
|
+
path.join(dir, `${base}.test.tsx`),
|
|
603
|
+
path.join(dir, `${base}.spec.tsx`),
|
|
604
|
+
path.join(dir, `${base}.test.js`),
|
|
605
|
+
path.join(dir, `${base}.spec.js`),
|
|
606
|
+
path.join(dir, "__tests__", `${base}.ts`),
|
|
607
|
+
path.join(dir, "__tests__", `${base}.test.ts`),
|
|
608
|
+
// Mirror in tests/ directory (common layout)
|
|
609
|
+
path.join(testsDir, `${base}.test.ts`),
|
|
610
|
+
path.join(testsDir, `${base}.spec.ts`),
|
|
611
|
+
path.join(testsDir, `${base}.test.tsx`),
|
|
612
|
+
path.join(testsDir, `${base}.test.js`),
|
|
613
|
+
];
|
|
614
|
+
const foundByNaming = candidates.some((c) => indexFilePaths.has(c));
|
|
615
|
+
if (foundByNaming)
|
|
616
|
+
continue;
|
|
617
|
+
// -----------------------------------------------------------------------
|
|
618
|
+
// 2. Import graph check: look for test file symbols that import sourceFile
|
|
619
|
+
// -----------------------------------------------------------------------
|
|
620
|
+
const foundByImport = index.symbols.some((sym) => {
|
|
621
|
+
if (!isTestFile(sym.file))
|
|
622
|
+
return false;
|
|
623
|
+
if (!sym.source)
|
|
624
|
+
return false;
|
|
625
|
+
// Check if source mentions the file base name in an import
|
|
626
|
+
return sym.source.includes(base);
|
|
627
|
+
});
|
|
628
|
+
if (foundByImport)
|
|
629
|
+
continue;
|
|
630
|
+
// -----------------------------------------------------------------------
|
|
631
|
+
// 3. Neither pathway found a test → T3 finding
|
|
632
|
+
// -----------------------------------------------------------------------
|
|
633
|
+
findings.push({
|
|
634
|
+
check: "test-gaps",
|
|
635
|
+
severity: "warn",
|
|
636
|
+
message: `No test found for "${sourceFile}" — add a test file matching naming convention or import it from a test`,
|
|
637
|
+
file: sourceFile,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
check: "test-gaps",
|
|
642
|
+
status: findings.length > 0 ? "warn" : "pass",
|
|
643
|
+
findings,
|
|
644
|
+
duration_ms: Date.now() - start,
|
|
645
|
+
summary: findings.length > 0
|
|
646
|
+
? `${findings.length} source file(s) with no test coverage found`
|
|
647
|
+
: "All changed source files have test coverage",
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
// Check runner — dispatches to real adapters or stubs for unimplemented checks
|
|
652
|
+
// ---------------------------------------------------------------------------
|
|
653
|
+
async function runCheck(checkName, _repo, changedFiles, index, since, until) {
|
|
654
|
+
switch (checkName) {
|
|
655
|
+
case "blast-radius":
|
|
656
|
+
return checkBlastRadius(index, since, until);
|
|
657
|
+
case "secrets":
|
|
658
|
+
return checkSecrets(index, changedFiles);
|
|
659
|
+
case "dead-code":
|
|
660
|
+
return checkDeadCode(index, changedFiles);
|
|
661
|
+
case "bug-patterns":
|
|
662
|
+
return checkBugPatterns(index, changedFiles);
|
|
663
|
+
case "hotspots":
|
|
664
|
+
return checkHotspots(index, changedFiles);
|
|
665
|
+
case "complexity":
|
|
666
|
+
return checkComplexityDelta(index, changedFiles);
|
|
667
|
+
case "coupling":
|
|
668
|
+
return checkCouplingGaps(index.root, changedFiles);
|
|
669
|
+
case "breaking":
|
|
670
|
+
return checkBreakingChanges(index, index.root, changedFiles, since, until);
|
|
671
|
+
case "test-gaps":
|
|
672
|
+
return checkTestGaps(index, changedFiles);
|
|
673
|
+
default: {
|
|
674
|
+
const tier = findingTier(checkName);
|
|
675
|
+
return {
|
|
676
|
+
check: checkName,
|
|
677
|
+
status: "pass",
|
|
678
|
+
tier,
|
|
679
|
+
findings: [],
|
|
680
|
+
duration_ms: 0,
|
|
681
|
+
summary: "No findings",
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// Orchestrator
|
|
688
|
+
// ---------------------------------------------------------------------------
|
|
689
|
+
export async function reviewDiff(repo, opts) {
|
|
690
|
+
const startTime = Date.now();
|
|
691
|
+
const since = opts.since ?? "HEAD~1";
|
|
692
|
+
const until = opts.until;
|
|
693
|
+
const maxFiles = opts.max_files ?? DEFAULT_MAX_FILES;
|
|
694
|
+
const checkTimeoutMs = opts.check_timeout_ms ?? DEFAULT_CHECK_TIMEOUT_MS;
|
|
695
|
+
// -----------------------------------------------------------------------
|
|
696
|
+
// Pre-flight: validate refs
|
|
697
|
+
// -----------------------------------------------------------------------
|
|
698
|
+
try {
|
|
699
|
+
validateGitRef(since);
|
|
700
|
+
if (until && until !== "WORKING" && until !== "STAGED") {
|
|
701
|
+
validateGitRef(until);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
catch (err) {
|
|
705
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
706
|
+
return {
|
|
707
|
+
repo,
|
|
708
|
+
since,
|
|
709
|
+
checks: [],
|
|
710
|
+
findings: [],
|
|
711
|
+
score: 0,
|
|
712
|
+
verdict: "fail",
|
|
713
|
+
duration_ms: Date.now() - startTime,
|
|
714
|
+
diff_stats: { files_changed: 0, files_reviewed: 0 },
|
|
715
|
+
metadata: {},
|
|
716
|
+
error: `invalid_ref: ${msg}`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
// -----------------------------------------------------------------------
|
|
720
|
+
// Pre-flight: validate repo exists
|
|
721
|
+
// -----------------------------------------------------------------------
|
|
722
|
+
const index = await getCodeIndex(repo);
|
|
723
|
+
if (!index) {
|
|
724
|
+
return {
|
|
725
|
+
repo,
|
|
726
|
+
since,
|
|
727
|
+
checks: [],
|
|
728
|
+
findings: [],
|
|
729
|
+
score: 0,
|
|
730
|
+
verdict: "fail",
|
|
731
|
+
duration_ms: Date.now() - startTime,
|
|
732
|
+
diff_stats: { files_changed: 0, files_reviewed: 0 },
|
|
733
|
+
metadata: {},
|
|
734
|
+
error: `Repository not found: ${repo}`,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// -----------------------------------------------------------------------
|
|
738
|
+
// Parse diff
|
|
739
|
+
// -----------------------------------------------------------------------
|
|
740
|
+
const diffResult = await changedSymbols(repo, since, until ?? "HEAD", undefined);
|
|
741
|
+
let changedFiles = diffResult.map((f) => f.file);
|
|
742
|
+
// -----------------------------------------------------------------------
|
|
743
|
+
// Exclude patterns
|
|
744
|
+
// -----------------------------------------------------------------------
|
|
745
|
+
if (opts.exclude_patterns && opts.exclude_patterns.length > 0) {
|
|
746
|
+
const isExcluded = picomatch(opts.exclude_patterns);
|
|
747
|
+
changedFiles = changedFiles.filter((f) => !isExcluded(f));
|
|
748
|
+
}
|
|
749
|
+
const totalFilesChanged = changedFiles.length;
|
|
750
|
+
// -----------------------------------------------------------------------
|
|
751
|
+
// Early return: empty diff
|
|
752
|
+
// -----------------------------------------------------------------------
|
|
753
|
+
if (changedFiles.length === 0) {
|
|
754
|
+
return {
|
|
755
|
+
repo,
|
|
756
|
+
since,
|
|
757
|
+
checks: [],
|
|
758
|
+
findings: [],
|
|
759
|
+
score: 100,
|
|
760
|
+
verdict: "pass",
|
|
761
|
+
duration_ms: Date.now() - startTime,
|
|
762
|
+
diff_stats: { files_changed: 0, files_reviewed: 0 },
|
|
763
|
+
metadata: {},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
// -----------------------------------------------------------------------
|
|
767
|
+
// Large diff: cap files and add advisory finding
|
|
768
|
+
// -----------------------------------------------------------------------
|
|
769
|
+
const allFindings = [];
|
|
770
|
+
const metadata = {};
|
|
771
|
+
if (changedFiles.length > maxFiles) {
|
|
772
|
+
metadata.files_capped = true;
|
|
773
|
+
allFindings.push({
|
|
774
|
+
check: "large-diff",
|
|
775
|
+
severity: "info",
|
|
776
|
+
message: `Large diff: ${changedFiles.length} files changed, reviewing first ${maxFiles}. Consider smaller commits.`,
|
|
777
|
+
});
|
|
778
|
+
changedFiles = changedFiles.slice(0, maxFiles);
|
|
779
|
+
}
|
|
780
|
+
// -----------------------------------------------------------------------
|
|
781
|
+
// Index warning: non-HEAD~N ref may mean stale index
|
|
782
|
+
// -----------------------------------------------------------------------
|
|
783
|
+
if (!HEAD_TILDE_PATTERN.test(since)) {
|
|
784
|
+
metadata.index_warning =
|
|
785
|
+
`Ref "${since}" is not a HEAD~N pattern. Index may not reflect this commit range.`;
|
|
786
|
+
}
|
|
787
|
+
// -----------------------------------------------------------------------
|
|
788
|
+
// Check enablement
|
|
789
|
+
// -----------------------------------------------------------------------
|
|
790
|
+
const requestedChecks = opts.checks
|
|
791
|
+
? opts.checks.split(",").map((c) => c.trim())
|
|
792
|
+
: [...ALL_CHECKS];
|
|
793
|
+
const enabledChecks = requestedChecks.filter((c) => ALL_CHECKS.includes(c));
|
|
794
|
+
// -----------------------------------------------------------------------
|
|
795
|
+
// Fan-out: run checks with timeout
|
|
796
|
+
// -----------------------------------------------------------------------
|
|
797
|
+
const checkPromises = enabledChecks.map((checkName) => withTimeout(runCheck(checkName, repo, changedFiles, index, since, until ?? "HEAD"), checkTimeoutMs));
|
|
798
|
+
const settled = await Promise.allSettled(checkPromises);
|
|
799
|
+
const checkResults = [];
|
|
800
|
+
for (let i = 0; i < settled.length; i++) {
|
|
801
|
+
const outcome = settled[i];
|
|
802
|
+
const checkName = enabledChecks[i] ?? `check_${i}`;
|
|
803
|
+
if (!outcome || outcome.status === "rejected") {
|
|
804
|
+
checkResults.push({
|
|
805
|
+
check: checkName,
|
|
806
|
+
status: "error",
|
|
807
|
+
findings: [],
|
|
808
|
+
duration_ms: 0,
|
|
809
|
+
summary: `Error: ${outcome && outcome.status === "rejected" && outcome.reason instanceof Error ? outcome.reason.message : String(outcome?.status === "rejected" ? outcome.reason : "unknown")}`,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
else if (outcome.status === "fulfilled" &&
|
|
813
|
+
outcome.value &&
|
|
814
|
+
typeof outcome.value === "object" &&
|
|
815
|
+
"status" in outcome.value &&
|
|
816
|
+
outcome.value.status === "timeout") {
|
|
817
|
+
checkResults.push({
|
|
818
|
+
check: checkName,
|
|
819
|
+
status: "timeout",
|
|
820
|
+
findings: [],
|
|
821
|
+
duration_ms: checkTimeoutMs,
|
|
822
|
+
summary: `Timed out after ${checkTimeoutMs}ms`,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
else if (outcome.status === "fulfilled") {
|
|
826
|
+
checkResults.push(outcome.value);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// -----------------------------------------------------------------------
|
|
830
|
+
// Assembly: collect findings, score, verdict
|
|
831
|
+
// -----------------------------------------------------------------------
|
|
832
|
+
for (const cr of checkResults) {
|
|
833
|
+
allFindings.push(...cr.findings);
|
|
834
|
+
}
|
|
835
|
+
const score = calculateScore(allFindings, checkResults);
|
|
836
|
+
const verdict = determineVerdict(checkResults);
|
|
837
|
+
return {
|
|
838
|
+
repo,
|
|
839
|
+
since,
|
|
840
|
+
checks: checkResults,
|
|
841
|
+
findings: allFindings,
|
|
842
|
+
score,
|
|
843
|
+
verdict,
|
|
844
|
+
duration_ms: Date.now() - startTime,
|
|
845
|
+
diff_stats: {
|
|
846
|
+
files_changed: totalFilesChanged,
|
|
847
|
+
files_reviewed: changedFiles.length,
|
|
848
|
+
},
|
|
849
|
+
metadata,
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
//# sourceMappingURL=review-diff-tools.js.map
|