weavatrix 0.1.2 → 0.1.4

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 (37) hide show
  1. package/README.md +92 -17
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +62 -4
  4. package/src/analysis/dead-check.js +87 -6
  5. package/src/analysis/dep-check-ecosystems.js +157 -0
  6. package/src/analysis/dep-check.js +97 -133
  7. package/src/analysis/dep-rules.js +91 -12
  8. package/src/analysis/duplicates.compute.js +12 -18
  9. package/src/analysis/endpoints-rust.js +124 -0
  10. package/src/analysis/endpoints.js +56 -6
  11. package/src/analysis/graph-analysis.aggregate.js +34 -25
  12. package/src/analysis/graph-analysis.edges.js +21 -0
  13. package/src/analysis/graph-analysis.js +1 -1
  14. package/src/analysis/graph-analysis.summaries.js +4 -1
  15. package/src/analysis/internal-audit.collect.js +162 -25
  16. package/src/analysis/internal-audit.reach.js +52 -11
  17. package/src/analysis/internal-audit.run.js +71 -18
  18. package/src/graph/builder/lang-java.js +177 -14
  19. package/src/graph/builder/lang-js.js +66 -23
  20. package/src/graph/builder/lang-rust.js +129 -6
  21. package/src/graph/community.js +17 -0
  22. package/src/graph/internal-builder.build.js +79 -17
  23. package/src/graph/internal-builder.java.js +33 -0
  24. package/src/graph/internal-builder.langs.js +43 -2
  25. package/src/graph/internal-builder.resolvers.js +197 -21
  26. package/src/graph/relations.js +4 -0
  27. package/src/mcp/catalog.mjs +4 -4
  28. package/src/mcp/graph-context.mjs +22 -94
  29. package/src/mcp/graph-diff.mjs +163 -0
  30. package/src/mcp/sync-payload.mjs +36 -6
  31. package/src/mcp/tools-actions.mjs +22 -7
  32. package/src/mcp/tools-graph-hubs.mjs +58 -0
  33. package/src/mcp/tools-graph.mjs +13 -22
  34. package/src/mcp/tools-health.mjs +54 -18
  35. package/src/mcp/tools-impact.mjs +61 -45
  36. package/src/mcp-server.mjs +3 -0
  37. package/src/security/advisory-store.js +51 -7
@@ -1,6 +1,7 @@
1
1
  // internal-audit.reach.js — entry-point discovery + file-level reachability BFS for the internal
2
2
  // audit. Split from internal-audit.js.
3
- import { ENTRY_FILE } from "./dead-check.js";
3
+ import { posix } from "node:path";
4
+ import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
4
5
  import { TEST_FILE_RE } from "./internal-audit.collect.js";
5
6
 
6
7
  const isFileNode = (n) => !String(n.id).includes("#");
@@ -8,20 +9,60 @@ const isFileNode = (n) => !String(n.id).includes("#");
8
9
  // Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
9
10
  // html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
10
11
  // dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
11
- export function entryFiles(graph, pkg, dynamicTargets) {
12
+ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
12
13
  const entries = new Set();
13
- const pkgEntries = [];
14
- for (const k of ["main", "module", "browser"]) if (typeof pkg[k] === "string") pkgEntries.push(pkg[k]);
15
- if (pkg.bin) pkgEntries.push(...(typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin)));
16
- (function walkExports(e) {
17
- if (typeof e === "string") pkgEntries.push(e);
18
- else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
19
- })(pkg.exports);
20
- const pe = new Set(pkgEntries.map((p) => String(p).replace(/^\.\//, "").replace(/\\/g, "/")));
14
+ const scopes = Array.isArray(pkgOrScopes)
15
+ ? pkgOrScopes
16
+ : [{ root: "", manifest: "package.json", pkg: pkgOrScopes || {} }];
17
+ const fileSet = new Set((graph.nodes || []).filter(isFileNode).map((n) => String(n.source_file || n.id).replace(/\\/g, "/")));
18
+ const pe = new Set();
19
+ const resolveEntry = (root, raw) => {
20
+ let p = String(raw || "").trim().replace(/^['"]|['"]$/g, "").replace(/^file:/, "").replace(/\\/g, "/");
21
+ if (!p || p.startsWith("-") || /^https?:/.test(p)) return "";
22
+ p = p.replace(/^\.\//, "");
23
+ const joined = posix.normalize(root ? posix.join(root, p) : p).replace(/^\.\//, "");
24
+ if (fileSet.has(joined)) return joined;
25
+ for (const ext of [".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py"]) if (fileSet.has(joined + ext)) return joined + ext;
26
+ return joined;
27
+ };
28
+ for (const scope of scopes) {
29
+ const pkg = scope.pkg || {};
30
+ const root = String(scope.root || "").replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
31
+ const pkgEntries = [];
32
+ for (const k of ["main", "module", "browser"]) if (typeof pkg[k] === "string") pkgEntries.push(pkg[k]);
33
+ if (pkg.bin) pkgEntries.push(...(typeof pkg.bin === "string" ? [pkg.bin] : Object.values(pkg.bin)));
34
+ (function walkExports(e) {
35
+ if (typeof e === "string") pkgEntries.push(e);
36
+ else if (e && typeof e === "object") Object.values(e).forEach(walkExports);
37
+ })(pkg.exports);
38
+ // Script commands are external entry surfaces. Extract only path-shaped source tokens; package names,
39
+ // flags and shell operators are intentionally ignored.
40
+ for (const script of Object.values(pkg.scripts || {})) {
41
+ const re = /(?:^|[\s'"=])((?:\.?\.?\/)?[\w@./-]+\.(?:[cm]?[jt]sx?|py|go))(?=$|[\s'";,)&|])/gi;
42
+ let m;
43
+ while ((m = re.exec(String(script)))) pkgEntries.push(m[1]);
44
+ const runner = /\b(?:node|bun|deno|tsx|ts-node|python\d*|electron)\s+(?!-)([\w@./\\-]+)/gi;
45
+ while ((m = runner.exec(String(script)))) if (/[./\\]/.test(m[1])) pkgEntries.push(m[1]);
46
+ }
47
+ for (const p of pkgEntries) pe.add(resolveEntry(root, p));
48
+ }
21
49
  for (const n of graph.nodes || []) {
22
50
  if (!isFileNode(n)) continue;
23
51
  const f = n.source_file;
24
- if (ENTRY_FILE.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
52
+ 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
+ }
54
+ for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
55
+ const resolved = resolveEntry("", raw);
56
+ if (resolved && fileSet.has(resolved)) entries.add(resolved);
57
+ }
58
+ // Bundled helper programs are often launched by filename (`resolveResource("worker.py")`) rather than
59
+ // imported. A quoted basename reference from another source file is enough to establish that a code file
60
+ // under resources/ is a runtime entry, without treating arbitrary prose/config files as roots.
61
+ for (const file of fileSet) {
62
+ if (!/(^|\/)resources\/.*\.(?:py|[cm]?[jt]s)$/i.test(file)) continue;
63
+ const base = posix.basename(file).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ const quoted = new RegExp(`["'\x60]${base}["'\x60]`);
65
+ if ([...sources].some(([other, text]) => other !== file && quoted.test(String(text || "")))) entries.add(file);
25
66
  }
26
67
  for (const t of dynamicTargets) entries.add(t);
27
68
  return entries;
@@ -4,19 +4,19 @@
4
4
  import { existsSync } from "node:fs";
5
5
  import { join, basename } from "node:path";
6
6
  import { computeDead, computeUnusedExports } from "./dead-check.js";
7
- import { computeDepFindings, computeGoDepFindings, computePyDepFindings } from "./dep-check.js";
7
+ import { computeScopedDepFindings, computeGoDepFindings, computePyDepFindings } from "./dep-check.js";
8
8
  import { parseGoMod } from "./manifests.js";
9
9
  import { computeStructureFindings } from "./dep-rules.js";
10
10
  import { makeFinding, summarizeFindings, sortFindings } from "./findings.js";
11
11
  import { graphOutDirForRepo } from "../graph/layout.js";
12
12
  import { collectInstalled } from "../security/installed.js";
13
- import { loadStore, queryStore } from "../security/advisory-store.js";
13
+ import { loadStore, queryStore, advisoryQueryFingerprint } from "../security/advisory-store.js";
14
14
  import { matchAdvisories } from "../security/match.js";
15
15
  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
- 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";
@@ -33,26 +33,48 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
33
33
  if (!graph) return { ok: false, error: "Build the graph first (no graph.json)" };
34
34
  }
35
35
  const pkg = readRepoJson(boundary, "package.json") || {};
36
+ const packageScopes = collectPackageScopes(repoPath, pkg);
36
37
  const externalImports = graph.externalImports || [];
37
38
  const dynamicTargets = new Set(externalImports.filter((e) => e.dynamic && e.target).map((e) => e.target));
39
+ const rules = readRepoJson(boundary, ".weavatrix-deps.json") || {};
38
40
 
39
41
  // Graphs can be stale or miss a helper file; text fallbacks must scan the real repo tree too.
40
42
  const sources = collectSourceTexts(repoPath, graph);
43
+ const nonRuntimeRoots = collectNonRuntimeRoots(repoPath, rules);
41
44
 
42
- const dead = computeDead(graph, sources);
43
- const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets });
44
- const entries = entryFiles(graph, pkg, dynamicTargets);
45
+ const entries = entryFiles(graph, packageScopes, dynamicTargets, {
46
+ declaredEntries: rules.entrypoints || rules.entries || [],
47
+ sources,
48
+ });
49
+ for (const file of sources.keys()) {
50
+ if (nonRuntimeRoots.some((root) => file === root || file.startsWith(`${root}/`))) entries.add(file);
51
+ }
52
+ const dead = computeDead(graph, sources, { entrySet: entries });
53
+ const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
45
54
  const reachable = computeReachability(graph, entries);
46
55
  const configTexts = collectConfigTexts(repoPath);
47
- const dep = computeDepFindings({ externalImports, pkg, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
56
+ const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts, nonRuntimeRoots });
48
57
  // non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
49
58
  const goModText = readRepoText(boundary, "go.mod");
50
- const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
51
- const pyDep = computePyDepFindings({ externalImports, pyManifest: collectPyManifest(repoPath), configTexts });
59
+ const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null, nonRuntimeRoots });
60
+ const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
61
+ const pyRules = rules.python || {};
62
+ const depRules = rules.dependencies || {};
63
+ const managedPython = [...new Set([
64
+ ...asList(rules.managedPythonDependencies), ...asList(pyRules.managed), ...asList(pyRules.managedDependencies),
65
+ ...asList(depRules.managedPython),
66
+ ])];
67
+ const ignoredPython = [...new Set([
68
+ ...asList(rules.ignorePythonDependencies), ...asList(pyRules.ignore), ...asList(pyRules.ignoreDependencies),
69
+ ...asList(depRules.ignorePython),
70
+ ])];
71
+ const pyDep = computePyDepFindings({
72
+ externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
73
+ managedDependencies: managedPython, ignoredDependencies: ignoredPython, nonRuntimeRoots,
74
+ });
52
75
 
53
76
  // structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
54
77
  // (the depcruise-config analogue); no bundled default rules — cycles+orphans are always on.
55
- const rules = readRepoJson(boundary, ".weavatrix-deps.json") || {};
56
78
  const externalImportFiles = new Set(externalImports.filter((e) => e.pkg && !e.builtin).map((e) => e.file));
57
79
  const structure = computeStructureFindings(graph, { rules, entrySet: entries, externalImportFiles });
58
80
 
@@ -61,7 +83,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
61
83
  const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
62
84
  for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
63
85
  for (const f of dead.deadFiles) {
64
- if (dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
86
+ if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
65
87
  findings.push(makeFinding({
66
88
  category: "unused",
67
89
  rule: "unused-file",
@@ -75,6 +97,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
75
97
  fixHint: "review, then delete the file",
76
98
  }));
77
99
  }
100
+ let unusedExportCount = 0;
78
101
  for (const s of unusedExports) {
79
102
  if (s.test) continue; // exports from test files are runner-visible noise
80
103
  if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
@@ -90,6 +113,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
90
113
  graphNodeId: s.id,
91
114
  source: "internal",
92
115
  }));
116
+ unusedExportCount++;
93
117
  }
94
118
 
95
119
  // ---- supply-chain: installed packages × cached OSV advisories. 100% OFFLINE here — the cache is
@@ -97,14 +121,31 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
97
121
  let advisoryDbDate = null;
98
122
  let installedCount = 0;
99
123
  let inst = { installed: [], drift: [] };
124
+ 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." },
126
+ malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
127
+ };
100
128
  try {
101
129
  inst = collectInstalled(repoPath);
102
130
  installedCount = inst.installed.length;
103
131
  const store = advisoryStorePath ? loadStore(advisoryStorePath) : loadStore();
104
- // per-repo date when the store tracks it (cache only covers QUERIED packages); legacy stores
105
- // without the repos map keep the old global-date behavior
106
- advisoryDbDate = store.meta?.repos ? store.meta.repos[repoPath] || null : store.meta?.fetched_at || null;
132
+ // Only a per-repo stamp proves that this repository's installed versions were queried. A legacy
133
+ // global fetched_at may belong to another repo and must never certify this one as clean.
134
+ const repoStamp = store.meta?.repos?.[repoPath] || null;
135
+ advisoryDbDate = typeof repoStamp === "string" ? repoStamp : repoStamp?.fetched_at || null;
107
136
  if (advisoryDbDate) {
137
+ let status = typeof repoStamp === "object" && ["OK", "PARTIAL", "ERROR"].includes(repoStamp.status) ? repoStamp.status : "PARTIAL";
138
+ const fingerprintMatches = typeof repoStamp === "object" && repoStamp.query_fingerprint === advisoryQueryFingerprint(inst.installed);
139
+ if (!fingerprintMatches && status === "OK") status = "PARTIAL";
140
+ const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
141
+ ? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
142
+ : "";
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.";
144
+ checks.osv = {
145
+ status,
146
+ detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
147
+ checkedAt: advisoryDbDate,
148
+ };
108
149
  for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
109
150
  const mal = h.adv.kind === "malicious";
110
151
  findings.push(makeFinding({
@@ -123,7 +164,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
123
164
  }
124
165
  }
125
166
  // direct-dependency typosquat (dev-chosen names, small set → low FP): surface quietly even alone.
126
- for (const name of Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) })) {
167
+ const directDependencyNames = new Set(packageScopes.flatMap((s) => Object.keys({ ...(s.pkg?.dependencies || {}), ...(s.pkg?.devDependencies || {}) })));
168
+ for (const name of directDependencyNames) {
127
169
  const sq = classifyTyposquat(name);
128
170
  if (!sq) continue;
129
171
  findings.push(makeFinding({
@@ -152,7 +194,9 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
152
194
  fixHint: "npm ci (clean install from the lockfile)",
153
195
  }));
154
196
  }
155
- } catch { /* supply-chain layer is best-effort */ }
197
+ } catch (error) {
198
+ checks.osv = { status: "ERROR", detail: `Offline advisory matching failed: ${error instanceof Error ? error.message : String(error)}` };
199
+ }
156
200
 
157
201
  // ---- malware heuristics: install-script beacons / miners / exfil / obfuscation across installed libs.
158
202
  // Local + offline (ripgrep or a bounded Node fallback). Scans node_modules, Python venvs, Go vendor/cache.
@@ -163,7 +207,10 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
163
207
  const scan = await scanMalware(repoPath, { installed: inst.installed, importedPkgs, malwareExclusions, rgPath });
164
208
  findings.push(...scan.findings);
165
209
  malwareScan = { scanMode: scan.scanMode, packagesScanned: scan.packagesScanned, findings: scan.findings.length, excludedSignals: scan.excludedSignals || 0 };
166
- } catch { /* heuristic scan is best-effort */ }
210
+ checks.malware = { status: "OK", detail: `Scanned ${scan.packagesScanned} installed package(s) using ${scan.scanMode}.` };
211
+ } catch (error) {
212
+ checks.malware = { status: "ERROR", detail: `Installed-package malware scan failed: ${error instanceof Error ? error.message : String(error)}` };
213
+ }
167
214
  }
168
215
 
169
216
  const sorted = sortFindings(findings);
@@ -181,12 +228,18 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
181
228
  nodeModulesPresent: boundary.resolve("node_modules").ok,
182
229
  installedPackages: installedCount,
183
230
  advisoryDbDate,
231
+ advisoryStatus: checks.osv.status,
184
232
  malwareScanMode: malwareScan?.scanMode || "skipped",
233
+ malwareStatus: checks.malware.status,
234
+ packageScopes: packageScopes.length,
235
+ managedPythonDependencies: managedPython.length,
236
+ nonRuntimeRoots,
185
237
  },
186
238
  summary: summarizeFindings(sorted),
187
239
  findings: sorted,
188
- deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports: unusedExports.length },
240
+ deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports: unusedExportCount },
189
241
  structureReport: structure.stats,
242
+ checks,
190
243
  malwareScan,
191
244
  };
192
245
  }
@@ -1,29 +1,192 @@
1
- // Java extractor. Symbols: class/interface/enum + method/constructor + fields.
2
- // Imports: `import a.b.C;` the C.java file (package path = dir; suffix-matched). Calls: `x.method()` /
3
- // `method()` (name only). Heritage: `extends` + `implements`.
4
- const SYMS = `
1
+ // Java extractor. Keeps file-level imports/calls while modelling the Java ownership and type system:
2
+ // classes/interfaces/enums/records own their methods, heritage distinguishes extends from implements,
3
+ // and project-local type references resolve to the declaration node (never to synthetic type-name nodes).
4
+ const TYPE_CORE = `
5
5
  (class_declaration name: (identifier) @class)
6
- (interface_declaration name: (identifier) @class)
7
- (enum_declaration name: (identifier) @class)
6
+ (interface_declaration name: (identifier) @interface)
7
+ (enum_declaration name: (identifier) @enum)`;
8
+ // Keep grammar-version-dependent declarations separate: one unknown node type invalidates a whole query.
9
+ const TYPE_OPTIONAL = [
10
+ `(record_declaration name: (identifier) @record)`,
11
+ `(annotation_type_declaration name: (identifier) @annotation)`,
12
+ ];
13
+ const MEMBERS = `
8
14
  (method_declaration name: (identifier) @method)
9
- (constructor_declaration name: (identifier) @method)
15
+ (constructor_declaration name: (identifier) @constructor)
10
16
  (field_declaration declarator: (variable_declarator name: (identifier) @field))`;
11
17
 
18
+ const TYPE_DECLARATIONS = new Set([
19
+ "class_declaration", "interface_declaration", "enum_declaration",
20
+ "record_declaration", "annotation_type_declaration",
21
+ ]);
22
+ const FIELD_DECLARATIONS = new Set(["field_declaration"]);
23
+
24
+ const ancestor = (node, accepted) => {
25
+ let current = node?.parent;
26
+ for (let hops = 0; current && hops < 12; hops++, current = current.parent) {
27
+ if (accepted.has(current.type)) return current;
28
+ }
29
+ return null;
30
+ };
31
+
32
+ const visibilityOf = (declaration, owner) => {
33
+ const modifiers = declaration?.namedChildren?.find((node) => node.type === "modifiers")?.text || "";
34
+ if (/\bprivate\b/.test(modifiers)) return "private";
35
+ if (/\bprotected\b/.test(modifiers)) return "protected";
36
+ if (/\bpublic\b/.test(modifiers)) return "public";
37
+ if (["interface_declaration", "annotation_type_declaration"].includes(owner?.type)) return "public";
38
+ return "package";
39
+ };
40
+
41
+ const lineOf = (node) => node.startPosition.row + 1;
42
+ const symbolBaseId = (fileRel, nameNode) => `${fileRel}#${nameNode.text}@${lineOf(nameNode)}`;
43
+ const declarationKey = (node) => `${node?.startIndex ?? -1}:${node?.endIndex ?? -1}`;
44
+ const exactJavaTarget = (resolveJavaImport, parts) => {
45
+ const target = resolveJavaImport(parts);
46
+ const suffix = parts.join("/") + ".java";
47
+ return target && (target === suffix || target.endsWith("/" + suffix)) ? target : null;
48
+ };
49
+
12
50
  export default {
13
51
  family: "java",
14
52
  grammars: ["java"],
15
53
  exts: { ".java": "java" },
16
54
  isWeb: false,
17
55
  calls: `(method_invocation name: (identifier) @callee)`,
18
- heritage: [`(superclass (type_identifier) @super)`, `(super_interfaces (type_list (type_identifier) @super))`],
56
+ // Capturing the base type_identifier (rather than the whole generic/scoped type) gives the shared
57
+ // resolver the imported/local declaration name. Each query is deliberately non-overlapping.
58
+ heritage: [
59
+ { relation: "inherits", query: `(superclass (type_identifier) @super)` },
60
+ { relation: "inherits", query: `(superclass (generic_type (type_identifier) @super))` },
61
+ { relation: "inherits", query: `(superclass (scoped_type_identifier name: (type_identifier) @super))` },
62
+ { relation: "inherits", query: `(superclass (generic_type (scoped_type_identifier name: (type_identifier) @super)))` },
63
+ { relation: "inherits", query: `(extends_interfaces (type_list (type_identifier) @super))` },
64
+ { relation: "inherits", query: `(extends_interfaces (type_list (generic_type (type_identifier) @super)))` },
65
+ { relation: "inherits", query: `(extends_interfaces (type_list (scoped_type_identifier name: (type_identifier) @super)))` },
66
+ { relation: "inherits", query: `(extends_interfaces (type_list (generic_type (scoped_type_identifier name: (type_identifier) @super))))` },
67
+ { relation: "implements", query: `(super_interfaces (type_list (type_identifier) @super))` },
68
+ { relation: "implements", query: `(super_interfaces (type_list (generic_type (type_identifier) @super)))` },
69
+ { relation: "implements", query: `(super_interfaces (type_list (scoped_type_identifier name: (type_identifier) @super)))` },
70
+ { relation: "implements", query: `(super_interfaces (type_list (generic_type (scoped_type_identifier name: (type_identifier) @super))))` },
71
+ ],
19
72
 
20
73
  pass1(ctx) {
21
- const { grammar, tree, caps, addSym, addImportEdge, imports, resolveJavaImport } = ctx;
22
- for (const cap of caps(grammar, SYMS, tree.rootNode)) addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "method", { sourceNode: cap.node.parent });
23
- for (const cap of caps(grammar, `(import_declaration (scoped_identifier) @imp)`, tree.rootNode)) {
24
- const parts = cap.node.text.split("."); const cls = parts[parts.length - 1];
25
- const tgt = resolveJavaImport(parts); if (!tgt) continue;
26
- addImportEdge(tgt); imports.set(cls, { imported: "*", targetFile: tgt });
74
+ const {
75
+ grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports,
76
+ resolveJavaImport, fileSet, links, nodeIds,
77
+ } = ctx;
78
+ const ownerIds = new Map();
79
+ const addJavaSym = (nameNode, callable, extra) => {
80
+ const base = symbolBaseId(fileRel, nameNode);
81
+ // Compact/generated Java can put an owner, constructor and overloads with the same name on one
82
+ // line. Preserve historical IDs normally; disambiguate only an actual collision by source column.
83
+ const id = nodeIds.has(base) ? `${base}:c${nameNode.startPosition.column + 1}` : base;
84
+ addSym(nameNode.text, lineOf(nameNode), callable, {
85
+ ...extra,
86
+ ...(id === base ? {} : { idSuffix: id.slice(base.length) }),
87
+ });
88
+ return nodeIds.has(id) ? id : null;
89
+ };
90
+
91
+ // Add owners first even though tree-sitter captures are source-ordered. This makes ownership edges
92
+ // deterministic for nested types and methods declared before/after other nested declarations.
93
+ for (const src of [TYPE_CORE, ...TYPE_OPTIONAL]) {
94
+ for (const cap of caps(grammar, src, tree.rootNode)) {
95
+ const declaration = cap.node.parent;
96
+ const id = addJavaSym(cap.node, false, {
97
+ sourceNode: cap.node.parent,
98
+ symbolKind: cap.name,
99
+ });
100
+ if (id) ownerIds.set(declarationKey(declaration), id);
101
+ }
102
+ }
103
+
104
+ for (const cap of caps(grammar, MEMBERS, tree.rootNode)) {
105
+ const declaration = cap.name === "field" ? ancestor(cap.node, FIELD_DECLARATIONS) : cap.node.parent;
106
+ const owner = ancestor(cap.node, TYPE_DECLARATIONS);
107
+ const ownerNameNode = owner && field(owner, "name");
108
+ const memberKind = cap.name === "constructor" ? "constructor" : cap.name;
109
+ const memberId = addJavaSym(cap.node, cap.name !== "field", {
110
+ sourceNode: declaration,
111
+ symbolKind: memberKind,
112
+ ...(ownerNameNode ? { memberOf: ownerNameNode.text } : {}),
113
+ visibility: visibilityOf(declaration, owner),
114
+ });
115
+ if (!ownerNameNode || cap.name === "field") continue;
116
+ const ownerId = ownerIds.get(declarationKey(owner));
117
+ if (ownerId && memberId && ownerId !== memberId) {
118
+ links.push({ source: ownerId, target: memberId, relation: "method", confidence: "EXTRACTED" });
119
+ }
120
+ }
121
+
122
+ const wildcardPackages = [];
123
+ const staticWildcardTargets = [];
124
+ const importStatements = caps(grammar, `(import_declaration) @imp`, tree.rootNode);
125
+ for (const cap of importStatements) {
126
+ const match = cap.node.text.match(/^\s*import\s+(static\s+)?([\w.]+?)(\.\*)?\s*;?\s*$/);
127
+ if (!match) continue;
128
+ const isStatic = !!match[1];
129
+ const parts = match[2].split(".").filter(Boolean);
130
+ const wildcard = !!match[3];
131
+ const line = lineOf(cap.node);
132
+ if (!isStatic && wildcard) {
133
+ wildcardPackages.push(parts);
134
+ continue;
135
+ }
136
+ if (!isStatic) {
137
+ const target = exactJavaTarget(resolveJavaImport, parts);
138
+ if (!target) continue;
139
+ const local = parts[parts.length - 1];
140
+ addImportEdge(target, { line, specifier: parts.join("."), compileOnly: true });
141
+ imports.set(local, { imported: local, targetFile: target });
142
+ continue;
143
+ }
144
+
145
+ // Static imports end in a member name. Walk prefixes until the declaring project class resolves.
146
+ let target = null; let classParts = null;
147
+ const max = wildcard ? parts.length : parts.length - 1;
148
+ for (let take = max; take > 0 && !target; take--) {
149
+ const candidate = parts.slice(0, take);
150
+ target = exactJavaTarget(resolveJavaImport, candidate);
151
+ if (target) classParts = candidate;
152
+ }
153
+ if (!target) continue;
154
+ addImportEdge(target, { line, specifier: `${isStatic ? "static " : ""}${parts.join(".")}${wildcard ? ".*" : ""}`, compileOnly: true });
155
+ if (wildcard) staticWildcardTargets.push(target);
156
+ else {
157
+ const member = parts[classParts.length];
158
+ if (member) imports.set(member, { imported: member, targetFile: target });
159
+ }
160
+ }
161
+
162
+ // Seed the shared pass-2 resolver with Java's implicit same-package types and wildcard imports.
163
+ // Exact-path validation is important: the general basename fallback is useful for explicit imports,
164
+ // but must not bind an unimported Foo to an unrelated package's Foo.java.
165
+ const slash = fileRel.lastIndexOf("/");
166
+ const ownDir = slash < 0 ? "" : fileRel.slice(0, slash);
167
+ const bindType = (name) => {
168
+ if (!name || imports.has(name)) return;
169
+ const samePackage = `${ownDir ? ownDir + "/" : ""}${name}.java`;
170
+ if (fileSet.has(samePackage)) {
171
+ imports.set(name, { imported: name, targetFile: samePackage });
172
+ return;
173
+ }
174
+ for (const pkg of wildcardPackages) {
175
+ const target = exactJavaTarget(resolveJavaImport, [...pkg, name]);
176
+ if (target) { imports.set(name, { imported: name, targetFile: target }); return; }
177
+ }
178
+ };
179
+ for (const cap of caps(grammar, `(type_identifier) @type`, tree.rootNode)) bindType(cap.node.text);
180
+ for (const cap of caps(grammar, `(scoped_type_identifier) @type`, tree.rootNode)) {
181
+ const parts = cap.node.text.split(".").filter(Boolean);
182
+ const target = exactJavaTarget(resolveJavaImport, parts);
183
+ const name = parts[parts.length - 1];
184
+ if (target && name && !imports.has(name)) imports.set(name, { imported: name, targetFile: target });
185
+ }
186
+ if (staticWildcardTargets.length === 1) {
187
+ for (const cap of caps(grammar, `(method_invocation name: (identifier) @callee)`, tree.rootNode)) {
188
+ if (!imports.has(cap.node.text)) imports.set(cap.node.text, { imported: cap.node.text, targetFile: staticWildcardTargets[0] });
189
+ }
27
190
  }
28
191
  },
29
192
  };
@@ -32,37 +32,56 @@ export default {
32
32
  // on deeply-nested / minified / bundled JS (regression). An export_statement is always a NEAR ancestor of
33
33
  // the declaration head (≤ ~5 hops), and hitting a statement_block means we're nested inside a function →
34
34
  // not a module-level export → bail immediately. See [[graph-builder-internalization]].
35
- // early-out on statement_block (we're nested inside a function not a module-level export) keeps this
36
- // O(1) for nested symbols; program is the top; the hop cap is the backstop. A method of an EXPORTED class
37
- // is only ~4 hops to its export_statement (method → class_body → class_declaration → export_statement),
38
- // so we do NOT bail at class_body — that preserves the exported flag for such methods.
35
+ // Early-out for nested scopes keeps this O(1); class members are intentionally never module exports even
36
+ // when their owner class is exported. The hop cap remains a backstop for malformed/deep syntax trees.
39
37
  const isExportedDecl = (node) => {
40
38
  let p = node.parent;
41
39
  for (let hops = 0; p && hops < 6; hops++) {
42
40
  if (p.type === "export_statement") return true;
43
- if (p.type === "program" || p.type === "statement_block") return false;
41
+ if (p.type === "program" || p.type === "statement_block" || p.type === "class_body") return false;
42
+ p = p.parent;
43
+ }
44
+ return false;
45
+ };
46
+ const isModuleDeclaration = (node) => {
47
+ let p = node.parent;
48
+ for (let hops = 0; p && hops < 8; hops++) {
49
+ if (p.type === "program") return true;
50
+ if (p.type === "statement_block" || p.type === "class_body") return false;
44
51
  p = p.parent;
45
52
  }
46
53
  return false;
47
54
  };
48
55
  // a bare (package) specifier = non-relative AND not a path alias; alias-that-missed is a broken local, not a dep
49
- const isBareSpec = (spec) => !!spec && !spec.startsWith(".") && resolveAlias(spec) == null;
56
+ const isBareSpec = (spec) => !!spec && !spec.startsWith(".") && resolveAlias(fileRel, spec) == null;
50
57
  // broken local import (relative or alias path resolving to no file) — recorded for the "unresolved-import"
51
58
  // finding. Asset imports (svg/css-modules-adjacent/fonts/…) and ?query-suffixed specs that resolve once
52
59
  // the query is stripped (Vite ?raw/?url/?worker) are NOT code-graph concerns.
53
60
  const ASSET_RE = /\.(svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|mp[34]|webm|wasm|pdf|txt|md|html?)$/i;
54
- const recordUnresolved = (rawSpec, kind, line) => {
61
+ const recordUnresolved = (rawSpec, kind, line, typeOnly = false) => {
55
62
  const clean = String(rawSpec || "").split("?")[0];
56
63
  if (!clean || ASSET_RE.test(clean)) return;
57
64
  if (clean !== rawSpec && resolveJsImport(fileRel, clean)) return; // only the ?query broke resolution
58
- addExternalImport({ spec: rawSpec, kind, line, unresolved: true });
65
+ addExternalImport({ spec: rawSpec, kind, line, unresolved: true, typeOnly });
59
66
  };
60
67
 
61
68
  // ---- symbols (export flag captured at declaration time) ----
69
+ const methodMetadata = (nameNode) => {
70
+ let method = nameNode.parent;
71
+ while (method && method.type !== "method_definition") method = method.parent;
72
+ let owner = method?.parent;
73
+ while (owner && !["class_declaration", "class"].includes(owner.type)) owner = owner.parent;
74
+ const ownerName = owner && field(owner, "name")?.text;
75
+ const visibility = /^\s*private\b/.test(method?.text || "") ? "private"
76
+ : /^\s*protected\b/.test(method?.text || "") ? "protected" : "public";
77
+ return { symbolKind: "method", ...(ownerName ? { memberOf: ownerName } : {}), visibility };
78
+ };
62
79
  for (const cap of caps(grammar, FUNCS, tree.rootNode)) {
80
+ const isMethod = cap.name === "method";
63
81
  addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name !== "class", {
64
82
  sourceNode: cap.node.parent,
65
- ...(isExportedDecl(cap.node) ? { exported: true } : {})
83
+ ...(!isMethod && isExportedDecl(cap.node) ? { exported: true } : {}),
84
+ ...(isMethod ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
66
85
  });
67
86
  }
68
87
  for (const cap of caps(grammar, TOPVARS, tree.rootNode)) {
@@ -70,26 +89,47 @@ export default {
70
89
  const val = field(cap.node, "value");
71
90
  addSym(nameNode.text, nameNode.startPosition.row + 1, !!(val && CALLABLE.test(val.type)), {
72
91
  sourceNode: val || cap.node,
73
- ...(isExportedDecl(cap.node) ? { exported: true } : {})
92
+ ...(isExportedDecl(cap.node) ? { exported: true } : {}),
93
+ symbolKind: "variable",
94
+ moduleDeclaration: true
74
95
  });
75
96
  }
76
97
 
98
+ const importTypeOnly = (node) => {
99
+ if (/^\s*import\s+type\b/.test(node.text)) return true;
100
+ const clause = node.namedChildren.find((c) => c.type === "import_clause");
101
+ if (!clause) return false; // side-effect import executes the target module
102
+ const parts = clause.namedChildren;
103
+ if (parts.some((c) => c.type === "identifier" || c.type === "namespace_import")) return false;
104
+ const named = parts.find((c) => c.type === "named_imports");
105
+ const specs = named?.namedChildren.filter((c) => c.type === "import_specifier") || [];
106
+ return specs.length > 0 && specs.every((s) => /^\s*type\b/.test(s.text));
107
+ };
108
+ const reexportTypeOnly = (node) => {
109
+ if (/^\s*export\s+type\b/.test(node.text)) return true;
110
+ const clause = node.namedChildren.find((c) => c.type === "export_clause");
111
+ const specs = clause?.namedChildren.filter((c) => c.type === "export_specifier") || [];
112
+ return specs.length > 0 && specs.every((s) => /^\s*type\b/.test(s.text));
113
+ };
114
+
77
115
  // ---- ESM imports ----
78
116
  for (const cap of caps(grammar, `(import_statement) @imp`, tree.rootNode)) {
79
117
  const node = cap.node; const srcNode = field(node, "source");
80
118
  const rawSpec = srcNode ? srcNode.text.replace(/^['"`]|['"`]$/g, "") : "";
119
+ const line = node.startPosition.row + 1;
120
+ const typeOnly = importTypeOnly(node);
81
121
  const tgt = resolveJsImport(fileRel, rawSpec);
82
122
  if (!tgt) {
83
- if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "esm", line: node.startPosition.row + 1 });
84
- else if (rawSpec) recordUnresolved(rawSpec, "esm", node.startPosition.row + 1);
123
+ if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "esm", line, typeOnly });
124
+ else if (rawSpec) recordUnresolved(rawSpec, "esm", line, typeOnly);
85
125
  continue;
86
126
  }
87
- addImportEdge(tgt);
127
+ addImportEdge(tgt, { typeOnly, line, specifier: rawSpec });
88
128
  const clause = node.namedChildren.find((c) => c.type === "import_clause"); if (!clause) continue;
89
129
  for (const c of clause.namedChildren) {
90
- if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt });
91
- else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt }); }
92
- else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt }); }
130
+ if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt, typeOnly });
131
+ else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt, typeOnly }); }
132
+ else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt, typeOnly: typeOnly || /^\s*type\b/.test(s.text) }); }
93
133
  }
94
134
  }
95
135
 
@@ -98,7 +138,7 @@ export default {
98
138
  if (cap.name !== "src") continue;
99
139
  let dv = cap.node; while (dv && dv.type !== "variable_declarator") dv = dv.parent;
100
140
  const tgt = resolveJsImport(fileRel, cap.node.text); if (!tgt) continue;
101
- addImportEdge(tgt);
141
+ addImportEdge(tgt, { typeOnly: false, line: cap.node.startPosition.row + 1, specifier: cap.node.text });
102
142
  const lhs = dv && field(dv, "name");
103
143
  if (lhs && lhs.type === "identifier") imports.set(lhs.text, { imported: "*", targetFile: tgt });
104
144
  else if (lhs && lhs.type === "object_pattern") for (const p of lhs.namedChildren) { const key = field(p, "key") || (p.type === "shorthand_property_identifier_pattern" ? p : null); const val = field(p, "value"); const local = (val && val.type === "identifier") ? val : key; if (key && local) imports.set(local.text, { imported: key.text, targetFile: tgt }); }
@@ -139,13 +179,16 @@ export default {
139
179
  }
140
180
 
141
181
  // ---- re-exports (barrel/index files): edge so the real target isn't falsely DEAD; bare source → external ----
142
- for (const cap of caps(grammar, `(export_statement source: (string (string_fragment) @src))`, tree.rootNode)) {
143
- if (cap.name !== "src") continue;
144
- const rawSpec = cap.node.text;
182
+ for (const cap of caps(grammar, `(export_statement) @exp`, tree.rootNode)) {
183
+ const node = cap.node; const srcNode = field(node, "source");
184
+ if (!srcNode) continue;
185
+ const rawSpec = srcNode.text.replace(/^['"`]|['"`]$/g, "");
186
+ const line = node.startPosition.row + 1;
187
+ const typeOnly = reexportTypeOnly(node);
145
188
  const tgt = resolveJsImport(fileRel, rawSpec);
146
- if (tgt) addImportEdge(tgt);
147
- else if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "reexport", line: cap.node.startPosition.row + 1 });
148
- else if (rawSpec) recordUnresolved(rawSpec, "reexport", cap.node.startPosition.row + 1);
189
+ if (tgt) addImportEdge(tgt, { relation: "re_exports", typeOnly, line, specifier: rawSpec });
190
+ else if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "reexport", line, typeOnly });
191
+ else if (rawSpec) recordUnresolved(rawSpec, "reexport", line, typeOnly);
149
192
  }
150
193
 
151
194
  // ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----