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.
Files changed (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. 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({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts, nonRuntimeRoots });
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
- for (const f of dead.deadFiles) {
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; // exports from test files are runner-visible noise
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. The refresh_advisories tool belongs to the optional online capability group: enable that group in the MCP registration, then call the tool explicitly to opt in to sending pinned package names and versions to OSV.dev." },
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 optional online capability group and call refresh_advisories for complete coverage.";
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
- evidence: [{ file: h.adv.url, line: 0, snippet: `installed via ${h.pkg.source}${h.pkg.dev ? " (dev)" : ""}` }],
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: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports: unusedExportCount },
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
+ }
@@ -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, writeFileSync, mkdirSync } from "node:fs";
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
- let graph = await buildInternalGraph(repoPath);
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
- mkdirSync(central, { recursive: true });
62
- writeFileSync(graphJson, JSON.stringify(graph), "utf8");
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 central = outDir || graphOutDirForRepo(repoPath);
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 built = USE_BUILD_WORKER
82
- ? await buildGraphInWorker({ repoPath, mode, scope, graphJson, central }).catch((e) => {
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
- : await buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
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
- log: `built-in builder: ${built.nodes} nodes, ${built.links} links`
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
- mkdirSync(central, { recursive: true });
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,