weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. package/src/security/typosquat.js +1 -1
@@ -3,13 +3,60 @@
3
3
  import { posix } from "node:path";
4
4
  import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
5
5
  import { TEST_FILE_RE } from "./internal-audit.collect.js";
6
+ import { maskJavaNonCode } from "./java-source.js";
6
7
 
7
8
  const isFileNode = (n) => !String(n.id).includes("#");
8
9
 
10
+ const springAnnotation = (name) => new RegExp(`@(?:[A-Za-z_$][\\w$]*\\s*\\.\\s*)*${name}\\b`);
11
+ const SPRING_CONVENTIONS = [
12
+ ["SpringBootApplication", "high", "Spring Boot application entry point is launched externally and starts component scanning"],
13
+ ["RestControllerAdvice", "high", "Spring registers @RestControllerAdvice through application-context scanning"],
14
+ ["ControllerAdvice", "high", "Spring registers @ControllerAdvice through application-context scanning"],
15
+ ["RestController", "high", "Spring registers @RestController through component scanning and routes requests to it"],
16
+ ["Controller", "high", "Spring registers @Controller through component scanning"],
17
+ ["Configuration", "high", "Spring discovers @Configuration and invokes its bean definitions externally"],
18
+ ["Service", "high", "Spring registers @Service through component scanning"],
19
+ ["Repository", "high", "Spring registers @Repository through component scanning or repository proxy creation"],
20
+ ["Component", "high", "Spring registers @Component through component scanning"],
21
+ ["ConfigurationProperties", "high", "Spring binds @ConfigurationProperties outside the static import graph"],
22
+ ["KafkaListener", "high", "Spring Kafka invokes @KafkaListener methods from the message container"],
23
+ ["Scheduled", "high", "Spring invokes @Scheduled methods from its task scheduler"],
24
+ ["EventListener", "high", "Spring invokes @EventListener methods from the application event bus"],
25
+ ];
26
+
27
+ // Framework-managed Java files are externally entered by the Spring container, not by an ordinary
28
+ // source import. Return bounded, explainable evidence so suppressing an orphan is never a silent guess.
29
+ export function springConventionEntries(sources, fileSet = null) {
30
+ const out = [];
31
+ for (const [rawFile, rawText] of sources || []) {
32
+ const file = String(rawFile || "").replace(/\\/g, "/");
33
+ if (!/\.java$/i.test(file) || (fileSet && !fileSet.has(file))) continue;
34
+ const text = maskJavaNonCode(rawText);
35
+ let evidence = null;
36
+ for (const [marker, confidence, reason] of SPRING_CONVENTIONS) {
37
+ if (springAnnotation(marker).test(text)) {
38
+ evidence = { file, framework: "spring", marker: `@${marker}`, confidence, reason };
39
+ break;
40
+ }
41
+ }
42
+ if (!evidence && /\binterface\s+[A-Za-z_$][\w$]*(?:\s*<[^>{}]+>)?\s+extends\s+[^;{]*(?:JpaRepository|MongoRepository|ReactiveCrudRepository|CrudRepository|PagingAndSortingRepository|Repository)\s*</s.test(text)) {
43
+ evidence = {
44
+ file,
45
+ framework: "spring-data",
46
+ marker: "repository interface",
47
+ confidence: "high",
48
+ reason: "Spring Data creates the repository implementation and proxy from the interface at runtime",
49
+ };
50
+ }
51
+ if (evidence) out.push(evidence);
52
+ }
53
+ return out.sort((a, b) => a.file.localeCompare(b.file));
54
+ }
55
+
9
56
  // Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
10
57
  // html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
11
58
  // dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
12
- export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
59
+ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map(), conventionEvidence = [] } = {}) {
13
60
  const entries = new Set();
14
61
  const scopes = Array.isArray(pkgOrScopes)
15
62
  ? pkgOrScopes
@@ -51,6 +98,10 @@ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { dec
51
98
  const f = n.source_file;
52
99
  if (ENTRY_FILE.test(f) || isFrameworkEntryFile(f) || /\.d\.[cm]?ts$/i.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
53
100
  }
101
+ for (const evidence of springConventionEntries(sources, fileSet)) {
102
+ entries.add(evidence.file);
103
+ conventionEvidence.push(evidence);
104
+ }
54
105
  for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
55
106
  const resolved = resolveEntry("", raw);
56
107
  if (resolved && fileSet.has(resolved)) entries.add(resolved);
@@ -16,10 +16,14 @@ import { scanMalware } from "../security/malware-heuristics.js";
16
16
  import { classifyTyposquat } from "../security/typosquat.js";
17
17
  import {
18
18
  readJson, readRepoText, readRepoJson, collectSourceTexts, collectConfigTexts, workspacePkgNames,
19
- collectPackageScopes, collectPyManifest, TEST_FILE_RE,
19
+ collectPackageScopes, collectPyManifest, collectNonRuntimeRoots, TEST_FILE_RE,
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).
@@ -40,19 +44,42 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
40
44
 
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);
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
+ };
43
61
 
62
+ const conventionEvidence = [];
44
63
  const entries = entryFiles(graph, packageScopes, dynamicTargets, {
45
64
  declaredEntries: rules.entrypoints || rules.entries || [],
46
65
  sources,
66
+ conventionEvidence,
47
67
  });
68
+ for (const file of sources.keys()) {
69
+ if (nonRuntimeRoots.some((root) => file === root || file.startsWith(`${root}/`))) entries.add(file);
70
+ if (isNonProductPath(file)) entries.add(file);
71
+ }
48
72
  const dead = computeDead(graph, sources, { entrySet: entries });
49
73
  const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
50
74
  const reachable = computeReachability(graph, entries);
51
75
  const configTexts = collectConfigTexts(repoPath);
52
- const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
76
+ const dep = computeScopedDepFindings({
77
+ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts,
78
+ nonRuntimeRoots, sourceFiles: [...sources.keys()],
79
+ });
53
80
  // non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
54
81
  const goModText = readRepoText(boundary, "go.mod");
55
- const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
82
+ const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null, nonRuntimeRoots });
56
83
  const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
57
84
  const pyRules = rules.python || {};
58
85
  const depRules = rules.dependencies || {};
@@ -66,7 +93,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
66
93
  ])];
67
94
  const pyDep = computePyDepFindings({
68
95
  externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
69
- managedDependencies: managedPython, ignoredDependencies: ignoredPython,
96
+ managedDependencies: managedPython, ignoredDependencies: ignoredPython, nonRuntimeRoots,
70
97
  });
71
98
 
72
99
  // structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
@@ -78,7 +105,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
78
105
  // orphan ∩ dead-file → one finding: keep the stronger unused-file, drop the duplicate orphan
79
106
  const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
80
107
  for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
81
- for (const f of dead.deadFiles) {
108
+ const actionableDeadFiles = dead.deadFiles.filter((f) => !isNonProductPath(f.file));
109
+ for (const f of actionableDeadFiles) {
82
110
  if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
83
111
  findings.push(makeFinding({
84
112
  category: "unused",
@@ -95,7 +123,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
95
123
  }
96
124
  let unusedExportCount = 0;
97
125
  for (const s of unusedExports) {
98
- 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
99
127
  if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
100
128
  findings.push(makeFinding({
101
129
  category: "unused",
@@ -118,7 +146,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
118
146
  let installedCount = 0;
119
147
  let inst = { installed: [], drift: [] };
120
148
  const checks = {
121
- 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." },
122
150
  malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
123
151
  };
124
152
  try {
@@ -136,7 +164,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
136
164
  const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
137
165
  ? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
138
166
  : "";
139
- 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.";
140
168
  checks.osv = {
141
169
  status,
142
170
  detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
@@ -144,16 +172,25 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
144
172
  };
145
173
  for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
146
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}`;
147
180
  findings.push(makeFinding({
148
181
  category: mal ? "malware" : "vulnerability",
149
182
  rule: mal ? "malicious-package" : "known-vuln",
150
- severity: mal ? "critical" : h.adv.severity,
151
- 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",
152
185
  title: `${mal ? "Known-malicious package" : `Known vulnerability (${h.adv.id})`}: ${h.pkg.name}@${h.pkg.version}`,
153
- 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}`,
154
187
  package: h.pkg.name,
155
188
  version: h.pkg.version,
156
- 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
+ ],
157
194
  source: "osv",
158
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",
159
196
  }));
@@ -229,10 +266,26 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
229
266
  malwareStatus: checks.malware.status,
230
267
  packageScopes: packageScopes.length,
231
268
  managedPythonDependencies: managedPython.length,
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,
232
276
  },
233
277
  summary: summarizeFindings(sorted),
234
278
  findings: sorted,
235
- 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
+ },
236
289
  structureReport: structure.stats,
237
290
  checks,
238
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,