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.
- package/README.md +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- 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 {
|
|
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,
|
|
12
|
+
export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
|
|
12
13
|
const entries = new Set();
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 {
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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 =
|
|
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
|
|
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
|
|
105
|
-
//
|
|
106
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
const
|
|
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) @
|
|
7
|
-
(enum_declaration name: (identifier) @
|
|
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) @
|
|
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
|
-
|
|
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 {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
36
|
-
//
|
|
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
|
|
84
|
-
else if (rawSpec) recordUnresolved(rawSpec, "esm",
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
148
|
-
else if (rawSpec) recordUnresolved(rawSpec, "reexport",
|
|
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 ----
|