weavatrix 0.1.4 → 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/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -20,6 +20,10 @@ import {
|
|
|
20
20
|
} from "./internal-audit.collect.js";
|
|
21
21
|
import { entryFiles, computeReachability } from "./internal-audit.reach.js";
|
|
22
22
|
import { createRepoBoundary } from "../repo-path.js";
|
|
23
|
+
import { PATH_CLASS_NAMES, createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
24
|
+
import { packageReachability } from "./package-reachability.js";
|
|
25
|
+
|
|
26
|
+
const LOWER_SEVERITY = { critical: "high", high: "medium", medium: "low", low: "info", info: "info" };
|
|
23
27
|
|
|
24
28
|
// Run the internal audit. graph is optional (loaded from the repo's central graph.json when absent);
|
|
25
29
|
// advisoryStorePath overrides the default ~/.weavatrix/advisories.json (tests use a scratch path).
|
|
@@ -41,19 +45,38 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
41
45
|
// Graphs can be stale or miss a helper file; text fallbacks must scan the real repo tree too.
|
|
42
46
|
const sources = collectSourceTexts(repoPath, graph);
|
|
43
47
|
const nonRuntimeRoots = collectNonRuntimeRoots(repoPath, rules);
|
|
48
|
+
const pathClassifier = createPathClassifier(repoPath);
|
|
49
|
+
const pathClassifications = new Map();
|
|
50
|
+
const classifyPath = (file) => {
|
|
51
|
+
const normalized = String(file || "").replace(/\\/g, "/");
|
|
52
|
+
if (!pathClassifications.has(normalized)) {
|
|
53
|
+
pathClassifications.set(normalized, pathClassifier.explain(normalized, { content: sources.get(normalized) }));
|
|
54
|
+
}
|
|
55
|
+
return pathClassifications.get(normalized);
|
|
56
|
+
};
|
|
57
|
+
const isNonProductPath = (file) => {
|
|
58
|
+
const info = classifyPath(file);
|
|
59
|
+
return info.excluded || hasPathClass(info, "test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp");
|
|
60
|
+
};
|
|
44
61
|
|
|
62
|
+
const conventionEvidence = [];
|
|
45
63
|
const entries = entryFiles(graph, packageScopes, dynamicTargets, {
|
|
46
64
|
declaredEntries: rules.entrypoints || rules.entries || [],
|
|
47
65
|
sources,
|
|
66
|
+
conventionEvidence,
|
|
48
67
|
});
|
|
49
68
|
for (const file of sources.keys()) {
|
|
50
69
|
if (nonRuntimeRoots.some((root) => file === root || file.startsWith(`${root}/`))) entries.add(file);
|
|
70
|
+
if (isNonProductPath(file)) entries.add(file);
|
|
51
71
|
}
|
|
52
72
|
const dead = computeDead(graph, sources, { entrySet: entries });
|
|
53
73
|
const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
|
|
54
74
|
const reachable = computeReachability(graph, entries);
|
|
55
75
|
const configTexts = collectConfigTexts(repoPath);
|
|
56
|
-
const dep = computeScopedDepFindings({
|
|
76
|
+
const dep = computeScopedDepFindings({
|
|
77
|
+
externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts,
|
|
78
|
+
nonRuntimeRoots, sourceFiles: [...sources.keys()],
|
|
79
|
+
});
|
|
57
80
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
58
81
|
const goModText = readRepoText(boundary, "go.mod");
|
|
59
82
|
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null, nonRuntimeRoots });
|
|
@@ -82,7 +105,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
82
105
|
// orphan ∩ dead-file → one finding: keep the stronger unused-file, drop the duplicate orphan
|
|
83
106
|
const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
|
|
84
107
|
for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
|
|
85
|
-
|
|
108
|
+
const actionableDeadFiles = dead.deadFiles.filter((f) => !isNonProductPath(f.file));
|
|
109
|
+
for (const f of actionableDeadFiles) {
|
|
86
110
|
if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
87
111
|
findings.push(makeFinding({
|
|
88
112
|
category: "unused",
|
|
@@ -99,7 +123,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
99
123
|
}
|
|
100
124
|
let unusedExportCount = 0;
|
|
101
125
|
for (const s of unusedExports) {
|
|
102
|
-
if (s.test) continue; //
|
|
126
|
+
if (s.test || isNonProductPath(s.file)) continue; // convention/generated surfaces are externally consumed or non-product noise
|
|
103
127
|
if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
|
|
104
128
|
findings.push(makeFinding({
|
|
105
129
|
category: "unused",
|
|
@@ -122,7 +146,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
122
146
|
let installedCount = 0;
|
|
123
147
|
let inst = { installed: [], drift: [] };
|
|
124
148
|
const checks = {
|
|
125
|
-
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository.
|
|
149
|
+
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. Enable the osv profile (or advisories capability), then call refresh_advisories explicitly to opt in to sending pinned package names and versions to OSV.dev." },
|
|
126
150
|
malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
|
|
127
151
|
};
|
|
128
152
|
try {
|
|
@@ -140,7 +164,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
140
164
|
const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
|
|
141
165
|
? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
|
|
142
166
|
: "";
|
|
143
|
-
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the
|
|
167
|
+
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the osv profile (or advisories capability) and call refresh_advisories for complete coverage.";
|
|
144
168
|
checks.osv = {
|
|
145
169
|
status,
|
|
146
170
|
detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
|
|
@@ -148,16 +172,25 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
148
172
|
};
|
|
149
173
|
for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
|
|
150
174
|
const mal = h.adv.kind === "malicious";
|
|
175
|
+
const reachability = packageReachability(externalImports, h.pkg.name, { isNonProductPath });
|
|
176
|
+
const observedInProduct = reachability.state === "DIRECT_RUNTIME_IMPORT";
|
|
177
|
+
const reachabilityDetail = observedInProduct
|
|
178
|
+
? ` Graph reachability: directly imported by product code in ${reachability.directRuntimeImports} callsite(s) across ${reachability.files.length} file(s).`
|
|
179
|
+
: ` Graph reachability: ${reachability.state}; ${reachability.note}`;
|
|
151
180
|
findings.push(makeFinding({
|
|
152
181
|
category: mal ? "malware" : "vulnerability",
|
|
153
182
|
rule: mal ? "malicious-package" : "known-vuln",
|
|
154
|
-
severity: mal ? "critical" : h.adv.severity,
|
|
155
|
-
confidence: h.confidence,
|
|
183
|
+
severity: mal || observedInProduct ? (mal ? "critical" : h.adv.severity) : LOWER_SEVERITY[h.adv.severity] || h.adv.severity,
|
|
184
|
+
confidence: mal || observedInProduct ? h.confidence : "low",
|
|
156
185
|
title: `${mal ? "Known-malicious package" : `Known vulnerability (${h.adv.id})`}: ${h.pkg.name}@${h.pkg.version}`,
|
|
157
|
-
detail: `${h.adv.summary || h.adv.id}${h.adv.fixedIn.length ? ` Fixed in: ${h.adv.fixedIn.join(", ")}.` : mal ? " Remove this package immediately and rotate any secrets it could reach." : ""} (matched by ${h.matchedBy}${h.adv.aliases.length ? `; aliases ${h.adv.aliases.join(", ")}` : ""})`,
|
|
186
|
+
detail: `${h.adv.summary || h.adv.id}${h.adv.fixedIn.length ? ` Fixed in: ${h.adv.fixedIn.join(", ")}.` : mal ? " Remove this package immediately and rotate any secrets it could reach." : ""} (matched by ${h.matchedBy}${h.adv.aliases.length ? `; aliases ${h.adv.aliases.join(", ")}` : ""}).${reachabilityDetail}`,
|
|
158
187
|
package: h.pkg.name,
|
|
159
188
|
version: h.pkg.version,
|
|
160
|
-
|
|
189
|
+
reachability,
|
|
190
|
+
evidence: [
|
|
191
|
+
{ file: h.adv.url, line: 0, snippet: `installed via ${h.pkg.source}${h.pkg.dev ? " (dev)" : ""}` },
|
|
192
|
+
...reachability.evidence.slice(0, 5).map((item) => ({file: item.file, line: item.line, snippet: `${item.typeOnly ? "type-only " : ""}${item.kind}`})),
|
|
193
|
+
],
|
|
161
194
|
source: "osv",
|
|
162
195
|
fixHint: mal ? `npm uninstall ${h.pkg.name} + audit what it touched` : h.adv.fixedIn.length ? `upgrade ${h.pkg.name} to ${h.adv.fixedIn[h.adv.fixedIn.length - 1]}+` : "no fixed version published — consider replacing the package",
|
|
163
196
|
}));
|
|
@@ -234,10 +267,25 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
234
267
|
packageScopes: packageScopes.length,
|
|
235
268
|
managedPythonDependencies: managedPython.length,
|
|
236
269
|
nonRuntimeRoots,
|
|
270
|
+
pathClassifications: Object.fromEntries(PATH_CLASS_NAMES.map((name) => [
|
|
271
|
+
name,
|
|
272
|
+
[...pathClassifications.values()].filter((info) => info.classes.includes(name)).length,
|
|
273
|
+
])),
|
|
274
|
+
pathClassificationExcluded: [...pathClassifications.values()].filter((info) => info.excluded).length,
|
|
275
|
+
conventionEntrypoints: conventionEvidence.length,
|
|
237
276
|
},
|
|
238
277
|
summary: summarizeFindings(sorted),
|
|
239
278
|
findings: sorted,
|
|
240
|
-
deadReport: {
|
|
279
|
+
deadReport: {
|
|
280
|
+
deadSymbols: dead.deadSymbols.filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
281
|
+
deadFiles: actionableDeadFiles.length,
|
|
282
|
+
unusedExports: unusedExportCount,
|
|
283
|
+
},
|
|
284
|
+
conventionReachability: {
|
|
285
|
+
count: conventionEvidence.length,
|
|
286
|
+
entries: conventionEvidence.slice(0, 100),
|
|
287
|
+
truncated: conventionEvidence.length > 100,
|
|
288
|
+
},
|
|
241
289
|
structureReport: structure.stats,
|
|
242
290
|
checks,
|
|
243
291
|
malwareScan,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Replace Java comments and string/char literal contents with spaces while preserving length and
|
|
2
|
+
// newlines. Regex-based signal extractors can then trust match positions without accepting examples
|
|
3
|
+
// from comments, docs, or string constants as executable annotations.
|
|
4
|
+
export function maskJavaNonCode(source) {
|
|
5
|
+
const text = String(source || "");
|
|
6
|
+
const chars = [...text];
|
|
7
|
+
let state = "code";
|
|
8
|
+
let escaped = false;
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
const ch = text[i];
|
|
11
|
+
const next = text[i + 1];
|
|
12
|
+
if (state === "code") {
|
|
13
|
+
if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; state = "line"; }
|
|
14
|
+
else if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; state = "block"; }
|
|
15
|
+
else if (ch === '"') { chars[i] = " "; state = "string"; escaped = false; }
|
|
16
|
+
else if (ch === "'") { chars[i] = " "; state = "char"; escaped = false; }
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (state === "line") {
|
|
20
|
+
if (ch === "\n" || ch === "\r") state = "code";
|
|
21
|
+
else chars[i] = " ";
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (state === "block") {
|
|
25
|
+
if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; state = "code"; }
|
|
26
|
+
else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === "\n" || ch === "\r") { escaped = false; continue; }
|
|
30
|
+
chars[i] = " ";
|
|
31
|
+
if (escaped) escaped = false;
|
|
32
|
+
else if (ch === "\\") escaped = true;
|
|
33
|
+
else if ((state === "string" && ch === '"') || (state === "char" && ch === "'")) state = "code";
|
|
34
|
+
}
|
|
35
|
+
return chars.join("");
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Package-level reachability: correlate a dependency finding with the graph's external imports.
|
|
2
|
+
// This is intentionally not function-level exploitability analysis. It only states whether product
|
|
3
|
+
// code imports the package and preserves unknown as unknown.
|
|
4
|
+
export const PACKAGE_REACHABILITY_V = 1
|
|
5
|
+
|
|
6
|
+
export function packageReachability(externalImports, packageName, {isNonProductPath = () => false} = {}) {
|
|
7
|
+
const imports = (Array.isArray(externalImports) ? externalImports : [])
|
|
8
|
+
.filter((item) => item?.pkg === packageName && item.builtin !== true)
|
|
9
|
+
.map((item) => ({
|
|
10
|
+
file: String(item.file || '').replace(/\\/g, '/'),
|
|
11
|
+
line: Number.isInteger(item.line) ? item.line : 0,
|
|
12
|
+
kind: String(item.kind || 'import'),
|
|
13
|
+
typeOnly: item.typeOnly === true,
|
|
14
|
+
dynamic: item.dynamic === true,
|
|
15
|
+
}))
|
|
16
|
+
.filter((item) => item.file)
|
|
17
|
+
const product = imports.filter((item) => !isNonProductPath(item.file))
|
|
18
|
+
const runtime = product.filter((item) => !item.typeOnly)
|
|
19
|
+
const state = runtime.length
|
|
20
|
+
? 'DIRECT_RUNTIME_IMPORT'
|
|
21
|
+
: product.length
|
|
22
|
+
? 'TYPE_ONLY_IMPORT'
|
|
23
|
+
: imports.length
|
|
24
|
+
? 'NON_PRODUCT_IMPORT_ONLY'
|
|
25
|
+
: 'NOT_OBSERVED_IN_GRAPH'
|
|
26
|
+
return {
|
|
27
|
+
packageReachabilityV: PACKAGE_REACHABILITY_V,
|
|
28
|
+
level: 'package-import',
|
|
29
|
+
state,
|
|
30
|
+
directRuntimeImports: runtime.length,
|
|
31
|
+
directProductImports: product.length,
|
|
32
|
+
observedImports: imports.length,
|
|
33
|
+
files: [...new Set((runtime.length ? runtime : product.length ? product : imports).map((item) => item.file))].sort().slice(0, 20),
|
|
34
|
+
evidence: (runtime.length ? runtime : product.length ? product : imports).slice(0, 10),
|
|
35
|
+
note: state === 'NOT_OBSERVED_IN_GRAPH'
|
|
36
|
+
? 'No static import was observed; implicit, dynamic, plugin and transitive runtime use remain unknown.'
|
|
37
|
+
: 'Package-level static import evidence; this does not prove vulnerable-function reachability.',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A deliberately weaker fallback than real coverage: follow runtime graph edges out of test files
|
|
2
|
+
// and report nearest test paths. This is evidence of static reachability only; it never claims a line,
|
|
3
|
+
// branch or symbol executed. The traversal is bounded for large repositories.
|
|
4
|
+
import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
5
|
+
import { isStructuralRelation } from "../graph/relations.js";
|
|
6
|
+
|
|
7
|
+
const MAX_TEST_FILES = 250;
|
|
8
|
+
const MAX_NEAREST_TESTS = 3;
|
|
9
|
+
const MAX_DEPTH = 10;
|
|
10
|
+
const MAX_STATES = 100_000;
|
|
11
|
+
|
|
12
|
+
const normalize = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
|
+
const endpoint = (value) => String(value && typeof value === "object" ? value.id : value || "");
|
|
14
|
+
const confidenceScore = (value) => {
|
|
15
|
+
const normalized = String(value || "").toUpperCase();
|
|
16
|
+
if (["EXTRACTED", "EXACT", "HIGH"].includes(normalized)) return 3;
|
|
17
|
+
if (["INFERRED", "MEDIUM"].includes(normalized)) return 2;
|
|
18
|
+
return 1;
|
|
19
|
+
};
|
|
20
|
+
const confidenceLabel = (score) => score >= 3 ? "HIGH" : score >= 2 ? "MEDIUM" : "LOW";
|
|
21
|
+
const pathInScope = (path, prefix) => !prefix || path === prefix || path.startsWith(`${prefix}/`);
|
|
22
|
+
|
|
23
|
+
export function computeStaticTestReachability(graph, {
|
|
24
|
+
repoRoot = null,
|
|
25
|
+
path = "",
|
|
26
|
+
maxDepth = MAX_DEPTH,
|
|
27
|
+
maxStates = MAX_STATES,
|
|
28
|
+
maxTests = MAX_TEST_FILES,
|
|
29
|
+
maxNearestTests = MAX_NEAREST_TESTS,
|
|
30
|
+
} = {}) {
|
|
31
|
+
const nodes = graph?.nodes || [];
|
|
32
|
+
const links = graph?.links || [];
|
|
33
|
+
const classifier = createPathClassifier(repoRoot);
|
|
34
|
+
const classificationCache = new Map();
|
|
35
|
+
const classify = (file) => {
|
|
36
|
+
const normalized = normalize(file);
|
|
37
|
+
if (!classificationCache.has(normalized)) classificationCache.set(normalized, classifier.explain(normalized, { content: "" }));
|
|
38
|
+
return classificationCache.get(normalized);
|
|
39
|
+
};
|
|
40
|
+
const isTest = (file) => hasPathClass(classify(file), "test", "e2e");
|
|
41
|
+
const isProduct = (file) => {
|
|
42
|
+
const info = classify(file);
|
|
43
|
+
return !info.excluded && !hasPathClass(info, "test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const idToFile = new Map();
|
|
47
|
+
const files = new Set();
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
const file = normalize(node?.source_file || (!String(node?.id || "").includes("#") ? node?.id : ""));
|
|
50
|
+
if (!file) continue;
|
|
51
|
+
idToFile.set(String(node.id), file);
|
|
52
|
+
files.add(file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collapse symbol/file edges to directed file dependencies. Multiple symbol edges retain the best
|
|
56
|
+
// confidence for the same hop; compile-time-only and ownership relations never imply test reach.
|
|
57
|
+
const adjacency = new Map();
|
|
58
|
+
for (const link of links) {
|
|
59
|
+
if (!link || link.typeOnly === true || link.compileOnly === true || isStructuralRelation(link.relation)) continue;
|
|
60
|
+
const from = idToFile.get(endpoint(link.source));
|
|
61
|
+
const to = idToFile.get(endpoint(link.target));
|
|
62
|
+
if (!from || !to || from === to) continue;
|
|
63
|
+
if (!adjacency.has(from)) adjacency.set(from, new Map());
|
|
64
|
+
const score = confidenceScore(link.confidence);
|
|
65
|
+
const previous = adjacency.get(from).get(to);
|
|
66
|
+
if (!previous || score > previous.score) adjacency.get(from).set(to, { score, relation: link.relation || "dependency" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const allTests = [...files].filter(isTest).sort((a, b) => a.localeCompare(b));
|
|
70
|
+
const testLimit = Math.max(0, Math.min(MAX_TEST_FILES, Number(maxTests) || MAX_TEST_FILES));
|
|
71
|
+
const tests = allTests.slice(0, testLimit);
|
|
72
|
+
const scope = normalize(path).replace(/\/+$/, "");
|
|
73
|
+
const productFiles = [...files].filter(isProduct).filter((file) => pathInScope(file, scope)).sort((a, b) => a.localeCompare(b));
|
|
74
|
+
const productSet = new Set(productFiles);
|
|
75
|
+
const nearest = new Map();
|
|
76
|
+
const queue = tests.map((test) => ({ file: test, test, distance: 0, score: 3, path: [test] }));
|
|
77
|
+
const seen = new Map(tests.map((test) => [`${test}\0${test}`, 0]));
|
|
78
|
+
let cursor = 0;
|
|
79
|
+
let traversedStates = 0;
|
|
80
|
+
const depthLimit = Math.max(1, Math.min(MAX_DEPTH, Number(maxDepth) || MAX_DEPTH));
|
|
81
|
+
const stateLimit = Math.max(100, Math.min(MAX_STATES, Number(maxStates) || MAX_STATES));
|
|
82
|
+
const nearestLimit = Math.max(1, Math.min(MAX_NEAREST_TESTS, Number(maxNearestTests) || MAX_NEAREST_TESTS));
|
|
83
|
+
|
|
84
|
+
while (cursor < queue.length && traversedStates < stateLimit) {
|
|
85
|
+
const current = queue[cursor++];
|
|
86
|
+
traversedStates++;
|
|
87
|
+
if (current.distance >= depthLimit) continue;
|
|
88
|
+
for (const [next, edge] of adjacency.get(current.file) || []) {
|
|
89
|
+
const distance = current.distance + 1;
|
|
90
|
+
const score = Math.min(current.score, edge.score);
|
|
91
|
+
const key = `${current.test}\0${next}`;
|
|
92
|
+
if ((seen.get(key) ?? Infinity) <= distance) continue;
|
|
93
|
+
seen.set(key, distance);
|
|
94
|
+
const record = { test: current.test, distance, score, confidence: confidenceLabel(score), path: [...current.path, next] };
|
|
95
|
+
if (productSet.has(next)) {
|
|
96
|
+
const records = nearest.get(next) || [];
|
|
97
|
+
records.push(record);
|
|
98
|
+
records.sort((a, b) => a.distance - b.distance || b.score - a.score || a.test.localeCompare(b.test));
|
|
99
|
+
if (records.length > nearestLimit) records.length = nearestLimit;
|
|
100
|
+
nearest.set(next, records);
|
|
101
|
+
}
|
|
102
|
+
// Once enough nearer test paths meet at the same file, a longer test prefix cannot become a
|
|
103
|
+
// nearest path downstream because every continuation from this point is shared.
|
|
104
|
+
const records = nearest.get(next);
|
|
105
|
+
if (records?.length >= nearestLimit && !records.some((item) => item.test === current.test) && distance > records[records.length - 1].distance) continue;
|
|
106
|
+
queue.push({ file: next, test: current.test, distance, score, path: record.path });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const reachable = productFiles
|
|
111
|
+
.filter((file) => nearest.has(file))
|
|
112
|
+
.map((file) => ({ file, nearestTests: nearest.get(file) }))
|
|
113
|
+
.sort((a, b) => a.nearestTests[0].distance - b.nearestTests[0].distance || a.file.localeCompare(b.file));
|
|
114
|
+
const unreachable = productFiles.filter((file) => !nearest.has(file));
|
|
115
|
+
return {
|
|
116
|
+
kind: "staticTestReachability",
|
|
117
|
+
actualCoverage: "NOT_AVAILABLE",
|
|
118
|
+
testFiles: tests.length,
|
|
119
|
+
totalTestFiles: allTests.length,
|
|
120
|
+
productFiles: productFiles.length,
|
|
121
|
+
reachableFiles: reachable.length,
|
|
122
|
+
unreachableFiles: unreachable.length,
|
|
123
|
+
reachable,
|
|
124
|
+
unreachable,
|
|
125
|
+
bounds: {
|
|
126
|
+
maxDepth: depthLimit,
|
|
127
|
+
maxStates: stateLimit,
|
|
128
|
+
traversedStates,
|
|
129
|
+
maxTests: testLimit,
|
|
130
|
+
truncated: allTests.length > tests.length || (cursor < queue.length && traversedStates >= stateLimit),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/build-graph.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
// the ENTIRE app froze on big repos. The worker writes graph.json itself and returns only counts +
|
|
7
7
|
// summaries. If the worker can't even start (exotic packaging), we fall back to the in-process build
|
|
8
8
|
// rather than lose the feature.
|
|
9
|
-
import { existsSync,
|
|
10
|
-
import { join } from "node:path";
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
11
|
import { Worker } from "node:worker_threads";
|
|
12
12
|
import { childProcessEnv } from "./child-env.js";
|
|
13
|
-
import { graphOutDirForRepo, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
13
|
+
import { graphHomeDir, graphOutDirForRepo, graphStorageKey, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
14
|
+
import { registerRepository } from "./graph/repo-registry.js";
|
|
15
|
+
import { refreshGraphIncrementally, snapshotRepository } from "./graph/incremental-refresh.js";
|
|
16
|
+
import { atomicWriteFileSync, withFileLock } from "./graph/file-lock.js";
|
|
17
|
+
import { repositoryFreshnessProbe, stampRepositoryFreshness } from "./graph/freshness-probe.js";
|
|
14
18
|
|
|
15
19
|
// The worker path deadlocks web-tree-sitter's WASM in Electron's worker threads (fine in plain Node) — off
|
|
16
20
|
// until that's Electron-safe. In-process + event-loop yielding keeps the window responsive without it.
|
|
@@ -54,23 +58,66 @@ function buildGraphInWorker(payload) {
|
|
|
54
58
|
|
|
55
59
|
async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central }) {
|
|
56
60
|
const { buildInternalGraph } = await import("./graph/internal-builder.js");
|
|
57
|
-
|
|
61
|
+
// Capture the cheap Git state on both sides of the authoritative build. Only an unchanged pair is
|
|
62
|
+
// safe to persist: if the working tree moves while parsing, the next process must take the slow path.
|
|
63
|
+
const probeBefore = scope ? null : repositoryFreshnessProbe(repoPath);
|
|
64
|
+
let graph;
|
|
65
|
+
let refresh = null;
|
|
66
|
+
if (mode === "full" && !scope && existsSync(graphJson)) {
|
|
67
|
+
try {
|
|
68
|
+
const existing = JSON.parse(readFileSync(graphJson, "utf8"));
|
|
69
|
+
if (existing.graphBuildMode === "full" && !existing.graphBuildScope) {
|
|
70
|
+
refresh = await refreshGraphIncrementally(repoPath, existing, { buildGraph: buildInternalGraph });
|
|
71
|
+
graph = refresh.graph;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* malformed/legacy graph: the safe path below is a full rebuild */ }
|
|
74
|
+
}
|
|
75
|
+
if (!graph && mode !== "full" && !scope && existsSync(graphJson)) {
|
|
76
|
+
try {
|
|
77
|
+
const existing = JSON.parse(readFileSync(graphJson, "utf8"));
|
|
78
|
+
if (existing.graphBuildMode === mode && existing.graphRevision) {
|
|
79
|
+
const snapshot = snapshotRepository(repoPath);
|
|
80
|
+
if (snapshot.revision === existing.graphRevision) {
|
|
81
|
+
graph = existing;
|
|
82
|
+
refresh = { kind: "none", changedFiles: [], reason: "content-unchanged", revision: snapshot.revision };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch { /* malformed/legacy filtered graph: rebuild below */ }
|
|
86
|
+
}
|
|
87
|
+
if (!graph) {
|
|
88
|
+
graph = await buildInternalGraph(repoPath);
|
|
89
|
+
refresh = { kind: "full", changedFiles: [], reason: "full-build-requested", revision: graph.graphRevision };
|
|
90
|
+
}
|
|
58
91
|
// mode (no-tests/tests-only) + path-scope reuse the same pure filters graph-builder used.
|
|
59
|
-
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode);
|
|
92
|
+
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode, { repoRoot: repoPath });
|
|
60
93
|
if (scope) graph = filterGraphByScope(graph, scope);
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
graph.graphBuildMode = mode;
|
|
95
|
+
graph.graphBuildScope = scope || "";
|
|
96
|
+
const probeAfter = scope ? null : repositoryFreshnessProbe(repoPath);
|
|
97
|
+
const stableProbe = probeBefore && probeAfter === probeBefore ? probeAfter : null;
|
|
98
|
+
const freshnessMetadataChanged = stampRepositoryFreshness(graph, stableProbe, mode);
|
|
99
|
+
// Auto-refresh runs before every graph/health call. A no-op must not serialize and rewrite a
|
|
100
|
+
// multi-megabyte graph merely to answer that nothing changed (or manufacture a newer mtime).
|
|
101
|
+
// A one-time metadata-only write is intentional for legacy graphs so the next MCP process can use
|
|
102
|
+
// the persisted probe instead of repeating a full repository snapshot.
|
|
103
|
+
if (refresh?.kind !== "none" || freshnessMetadataChanged) {
|
|
104
|
+
mkdirSync(central, { recursive: true });
|
|
105
|
+
atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
106
|
+
}
|
|
63
107
|
return {
|
|
64
108
|
nodes: graph.nodes.length,
|
|
65
109
|
links: graph.links.length,
|
|
66
110
|
communities: summarizeCommunities(graphJson),
|
|
67
111
|
hotspots: summarizeHotspots(graphJson),
|
|
112
|
+
refresh,
|
|
68
113
|
};
|
|
69
114
|
}
|
|
70
115
|
|
|
71
|
-
export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", outDir } = {}) {
|
|
116
|
+
export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", outDir, graphHome } = {}) {
|
|
72
117
|
if (!existsSync(repoPath)) return { ok: false, error: "Repo path not found", builder: "internal" };
|
|
73
|
-
const
|
|
118
|
+
const registryHome = graphHome || graphHomeDir();
|
|
119
|
+
const canonicalDir = graphHome ? join(registryHome, graphStorageKey(repoPath)) : graphOutDirForRepo(repoPath);
|
|
120
|
+
const central = outDir || canonicalDir;
|
|
74
121
|
const graphJson = join(central, "graph.json");
|
|
75
122
|
try {
|
|
76
123
|
// In-process is the ONLY path now. The worker (buildGraphInWorker) hung web-tree-sitter's WASM inside an
|
|
@@ -78,12 +125,23 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
|
|
|
78
125
|
// in ~3s on the main thread (and in a plain-Node worker). buildInternalGraph now YIELDS the event loop
|
|
79
126
|
// between file chunks, so the main-thread parse no longer freezes the window — the reason the worker was
|
|
80
127
|
// introduced. buildGraphInWorker/USE_BUILD_WORKER are kept for a future Electron-safe re-enable.
|
|
81
|
-
const
|
|
82
|
-
?
|
|
128
|
+
const build = () => USE_BUILD_WORKER
|
|
129
|
+
? buildGraphInWorker({ repoPath, mode, scope, graphJson, central }).catch((e) => {
|
|
83
130
|
if (e && e.workerStartFailed) return buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
|
|
84
131
|
throw e;
|
|
85
132
|
})
|
|
86
|
-
:
|
|
133
|
+
: buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
|
|
134
|
+
// Canonical graphs are shared by all local MCP clients. Serialize the complete read/refresh/write
|
|
135
|
+
// transaction so an older process cannot overwrite a newer incremental result.
|
|
136
|
+
const canonical = !scope && resolve(central) === resolve(canonicalDir);
|
|
137
|
+
const built = canonical
|
|
138
|
+
? await withFileLock(join(central, ".graph.lock"), build, { timeoutMs: 5 * 60_000, staleMs: 10 * 60_000 })
|
|
139
|
+
: await build();
|
|
140
|
+
// Scoped builds are disposable diagnostics under modules/<scope>; registering one would retarget
|
|
141
|
+
// list_known_repos and cross-repo analysis away from the complete canonical graph.
|
|
142
|
+
if (canonical) {
|
|
143
|
+
registerRepository({ repoPath, graphDir: central, graphHome: registryHome });
|
|
144
|
+
}
|
|
87
145
|
return {
|
|
88
146
|
ok: true,
|
|
89
147
|
builder: "internal",
|
|
@@ -95,7 +153,8 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
|
|
|
95
153
|
graphDir: central,
|
|
96
154
|
communities: built.communities,
|
|
97
155
|
hotspots: built.hotspots,
|
|
98
|
-
|
|
156
|
+
refresh: built.refresh,
|
|
157
|
+
log: `built-in builder: ${built.nodes} nodes, ${built.links} links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"})`
|
|
99
158
|
};
|
|
100
159
|
} catch (error) {
|
|
101
160
|
return { ok: false, error: `graph build failed: ${error.message}`, builder: "internal" };
|
|
@@ -5,18 +5,17 @@
|
|
|
5
5
|
// result object crosses back (never the graph itself — structured-cloning a huge graph would stall
|
|
6
6
|
// the main thread again).
|
|
7
7
|
import { parentPort, workerData } from "node:worker_threads";
|
|
8
|
-
import { writeFileSync, mkdirSync } from "node:fs";
|
|
9
8
|
import { buildInternalGraph } from "./internal-builder.js";
|
|
10
9
|
import { filterGraphForMode, filterGraphByScope, summarizeCommunities, summarizeHotspots } from "./layout.js";
|
|
10
|
+
import { atomicWriteFileSync } from "./file-lock.js";
|
|
11
11
|
|
|
12
12
|
(async () => {
|
|
13
13
|
const { repoPath, mode, scope, graphJson, central } = workerData || {};
|
|
14
14
|
try {
|
|
15
15
|
let graph = await buildInternalGraph(repoPath);
|
|
16
|
-
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode);
|
|
16
|
+
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode, { repoRoot: repoPath });
|
|
17
17
|
if (scope) graph = filterGraphByScope(graph, scope);
|
|
18
|
-
|
|
19
|
-
writeFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
18
|
+
atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
20
19
|
parentPort.postMessage({
|
|
21
20
|
ok: true,
|
|
22
21
|
nodes: graph.nodes.length,
|