weavatrix 0.1.2 → 0.1.3
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 +66 -11
- package/package.json +1 -1
- package/skill/SKILL.md +54 -3
- package/src/analysis/dead-check.js +80 -4
- package/src/analysis/dep-check.js +84 -8
- package/src/analysis/dep-rules.js +74 -8
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints.js +45 -2
- package/src/analysis/graph-analysis.aggregate.js +11 -3
- package/src/analysis/internal-audit.collect.js +122 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +65 -17
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/internal-builder.build.js +48 -10
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +88 -19
- package/src/mcp/catalog.mjs +2 -2
- package/src/mcp/graph-context.mjs +100 -13
- package/src/mcp/sync-payload.mjs +9 -1
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph.mjs +46 -12
- package/src/mcp/tools-health.mjs +42 -18
- package/src/mcp/tools-impact.mjs +52 -41
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
|
@@ -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, 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,44 @@ 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);
|
|
41
43
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
const entries = entryFiles(graph, packageScopes, dynamicTargets, {
|
|
45
|
+
declaredEntries: rules.entrypoints || rules.entries || [],
|
|
46
|
+
sources,
|
|
47
|
+
});
|
|
48
|
+
const dead = computeDead(graph, sources, { entrySet: entries });
|
|
49
|
+
const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
|
|
45
50
|
const reachable = computeReachability(graph, entries);
|
|
46
51
|
const configTexts = collectConfigTexts(repoPath);
|
|
47
|
-
const dep =
|
|
52
|
+
const dep = computeScopedDepFindings({ externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
|
|
48
53
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
49
54
|
const goModText = readRepoText(boundary, "go.mod");
|
|
50
55
|
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
|
|
51
|
-
const
|
|
56
|
+
const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
57
|
+
const pyRules = rules.python || {};
|
|
58
|
+
const depRules = rules.dependencies || {};
|
|
59
|
+
const managedPython = [...new Set([
|
|
60
|
+
...asList(rules.managedPythonDependencies), ...asList(pyRules.managed), ...asList(pyRules.managedDependencies),
|
|
61
|
+
...asList(depRules.managedPython),
|
|
62
|
+
])];
|
|
63
|
+
const ignoredPython = [...new Set([
|
|
64
|
+
...asList(rules.ignorePythonDependencies), ...asList(pyRules.ignore), ...asList(pyRules.ignoreDependencies),
|
|
65
|
+
...asList(depRules.ignorePython),
|
|
66
|
+
])];
|
|
67
|
+
const pyDep = computePyDepFindings({
|
|
68
|
+
externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
|
|
69
|
+
managedDependencies: managedPython, ignoredDependencies: ignoredPython,
|
|
70
|
+
});
|
|
52
71
|
|
|
53
72
|
// structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
|
|
54
73
|
// (the depcruise-config analogue); no bundled default rules — cycles+orphans are always on.
|
|
55
|
-
const rules = readRepoJson(boundary, ".weavatrix-deps.json") || {};
|
|
56
74
|
const externalImportFiles = new Set(externalImports.filter((e) => e.pkg && !e.builtin).map((e) => e.file));
|
|
57
75
|
const structure = computeStructureFindings(graph, { rules, entrySet: entries, externalImportFiles });
|
|
58
76
|
|
|
@@ -61,7 +79,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
61
79
|
const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
|
|
62
80
|
for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
|
|
63
81
|
for (const f of dead.deadFiles) {
|
|
64
|
-
if (dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
82
|
+
if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
65
83
|
findings.push(makeFinding({
|
|
66
84
|
category: "unused",
|
|
67
85
|
rule: "unused-file",
|
|
@@ -75,6 +93,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
75
93
|
fixHint: "review, then delete the file",
|
|
76
94
|
}));
|
|
77
95
|
}
|
|
96
|
+
let unusedExportCount = 0;
|
|
78
97
|
for (const s of unusedExports) {
|
|
79
98
|
if (s.test) continue; // exports from test files are runner-visible noise
|
|
80
99
|
if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
|
|
@@ -90,6 +109,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
90
109
|
graphNodeId: s.id,
|
|
91
110
|
source: "internal",
|
|
92
111
|
}));
|
|
112
|
+
unusedExportCount++;
|
|
93
113
|
}
|
|
94
114
|
|
|
95
115
|
// ---- supply-chain: installed packages × cached OSV advisories. 100% OFFLINE here — the cache is
|
|
@@ -97,14 +117,31 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
97
117
|
let advisoryDbDate = null;
|
|
98
118
|
let installedCount = 0;
|
|
99
119
|
let inst = { installed: [], drift: [] };
|
|
120
|
+
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." },
|
|
122
|
+
malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
|
|
123
|
+
};
|
|
100
124
|
try {
|
|
101
125
|
inst = collectInstalled(repoPath);
|
|
102
126
|
installedCount = inst.installed.length;
|
|
103
127
|
const store = advisoryStorePath ? loadStore(advisoryStorePath) : loadStore();
|
|
104
|
-
// per-repo
|
|
105
|
-
//
|
|
106
|
-
|
|
128
|
+
// Only a per-repo stamp proves that this repository's installed versions were queried. A legacy
|
|
129
|
+
// global fetched_at may belong to another repo and must never certify this one as clean.
|
|
130
|
+
const repoStamp = store.meta?.repos?.[repoPath] || null;
|
|
131
|
+
advisoryDbDate = typeof repoStamp === "string" ? repoStamp : repoStamp?.fetched_at || null;
|
|
107
132
|
if (advisoryDbDate) {
|
|
133
|
+
let status = typeof repoStamp === "object" && ["OK", "PARTIAL", "ERROR"].includes(repoStamp.status) ? repoStamp.status : "PARTIAL";
|
|
134
|
+
const fingerprintMatches = typeof repoStamp === "object" && repoStamp.query_fingerprint === advisoryQueryFingerprint(inst.installed);
|
|
135
|
+
if (!fingerprintMatches && status === "OK") status = "PARTIAL";
|
|
136
|
+
const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
|
|
137
|
+
? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
|
|
138
|
+
: "";
|
|
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.";
|
|
140
|
+
checks.osv = {
|
|
141
|
+
status,
|
|
142
|
+
detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
|
|
143
|
+
checkedAt: advisoryDbDate,
|
|
144
|
+
};
|
|
108
145
|
for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
|
|
109
146
|
const mal = h.adv.kind === "malicious";
|
|
110
147
|
findings.push(makeFinding({
|
|
@@ -123,7 +160,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
123
160
|
}
|
|
124
161
|
}
|
|
125
162
|
// direct-dependency typosquat (dev-chosen names, small set → low FP): surface quietly even alone.
|
|
126
|
-
|
|
163
|
+
const directDependencyNames = new Set(packageScopes.flatMap((s) => Object.keys({ ...(s.pkg?.dependencies || {}), ...(s.pkg?.devDependencies || {}) })));
|
|
164
|
+
for (const name of directDependencyNames) {
|
|
127
165
|
const sq = classifyTyposquat(name);
|
|
128
166
|
if (!sq) continue;
|
|
129
167
|
findings.push(makeFinding({
|
|
@@ -152,7 +190,9 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
152
190
|
fixHint: "npm ci (clean install from the lockfile)",
|
|
153
191
|
}));
|
|
154
192
|
}
|
|
155
|
-
} catch {
|
|
193
|
+
} catch (error) {
|
|
194
|
+
checks.osv = { status: "ERROR", detail: `Offline advisory matching failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
195
|
+
}
|
|
156
196
|
|
|
157
197
|
// ---- malware heuristics: install-script beacons / miners / exfil / obfuscation across installed libs.
|
|
158
198
|
// Local + offline (ripgrep or a bounded Node fallback). Scans node_modules, Python venvs, Go vendor/cache.
|
|
@@ -163,7 +203,10 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
163
203
|
const scan = await scanMalware(repoPath, { installed: inst.installed, importedPkgs, malwareExclusions, rgPath });
|
|
164
204
|
findings.push(...scan.findings);
|
|
165
205
|
malwareScan = { scanMode: scan.scanMode, packagesScanned: scan.packagesScanned, findings: scan.findings.length, excludedSignals: scan.excludedSignals || 0 };
|
|
166
|
-
|
|
206
|
+
checks.malware = { status: "OK", detail: `Scanned ${scan.packagesScanned} installed package(s) using ${scan.scanMode}.` };
|
|
207
|
+
} catch (error) {
|
|
208
|
+
checks.malware = { status: "ERROR", detail: `Installed-package malware scan failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
209
|
+
}
|
|
167
210
|
}
|
|
168
211
|
|
|
169
212
|
const sorted = sortFindings(findings);
|
|
@@ -181,12 +224,17 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
181
224
|
nodeModulesPresent: boundary.resolve("node_modules").ok,
|
|
182
225
|
installedPackages: installedCount,
|
|
183
226
|
advisoryDbDate,
|
|
227
|
+
advisoryStatus: checks.osv.status,
|
|
184
228
|
malwareScanMode: malwareScan?.scanMode || "skipped",
|
|
229
|
+
malwareStatus: checks.malware.status,
|
|
230
|
+
packageScopes: packageScopes.length,
|
|
231
|
+
managedPythonDependencies: managedPython.length,
|
|
185
232
|
},
|
|
186
233
|
summary: summarizeFindings(sorted),
|
|
187
234
|
findings: sorted,
|
|
188
|
-
deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports:
|
|
235
|
+
deadReport: { deadSymbols: dead.deadSymbols.length, deadFiles: dead.deadFiles.length, unusedExports: unusedExportCount },
|
|
189
236
|
structureReport: structure.stats,
|
|
237
|
+
checks,
|
|
190
238
|
malwareScan,
|
|
191
239
|
};
|
|
192
240
|
}
|
|
@@ -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 ----
|
|
@@ -53,7 +53,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
53
53
|
const parser = new Parser(); parser.setLanguage(langs[grammar]);
|
|
54
54
|
let tree; try { tree = parser.parse(code); } catch { continue; }
|
|
55
55
|
|
|
56
|
-
const syms = []; const nameToId = new Map();
|
|
56
|
+
const syms = []; const nameToId = new Map(); const moduleNameToId = new Map();
|
|
57
57
|
const addSym = (name, line, callable, extra) => {
|
|
58
58
|
if (!name || !/^[A-Za-z_$][\w$]*$/.test(name)) return;
|
|
59
59
|
const id = `${fileRel}#${name}@${line}`; if (nodeIds.has(id)) return;
|
|
@@ -73,28 +73,44 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
73
73
|
...(endLine >= line ? { source_end: `L${endLine}` } : {}),
|
|
74
74
|
...(complexity ? { complexity } : {}),
|
|
75
75
|
...(extra && extra.exported ? { exported: true } : {}),
|
|
76
|
-
...(extra && extra.decorated ? { decorated: true } : {})
|
|
76
|
+
...(extra && extra.decorated ? { decorated: true } : {}),
|
|
77
|
+
...(extra && extra.symbolKind ? { symbol_kind: extra.symbolKind } : {}),
|
|
78
|
+
...(extra && extra.memberOf ? { member_of: extra.memberOf } : {}),
|
|
79
|
+
...(extra && extra.visibility ? { visibility: extra.visibility } : {})
|
|
77
80
|
});
|
|
78
81
|
links.push({ source: fileRel, target: id, relation: "contains", confidence: "EXTRACTED" });
|
|
79
|
-
syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
|
|
82
|
+
syms.push({ id, name, start: line, end: endLine >= line ? endLine : 0 });
|
|
83
|
+
if (!nameToId.has(name)) nameToId.set(name, id);
|
|
84
|
+
if (extra?.moduleDeclaration && !moduleNameToId.has(name)) moduleNameToId.set(name, id);
|
|
80
85
|
};
|
|
81
86
|
const imports = new Map(); importedLocals.set(fileRel, imports);
|
|
82
|
-
const addImportEdge = (tgt
|
|
87
|
+
const addImportEdge = (tgt, meta = {}) => {
|
|
88
|
+
if (!tgt || tgt === fileRel) return;
|
|
89
|
+
links.push({
|
|
90
|
+
source: fileRel,
|
|
91
|
+
target: tgt,
|
|
92
|
+
relation: meta.relation || "imports",
|
|
93
|
+
confidence: "EXTRACTED",
|
|
94
|
+
...(typeof meta.typeOnly === "boolean" ? { typeOnly: meta.typeOnly } : {}),
|
|
95
|
+
...(meta.line ? { line: meta.line } : {}),
|
|
96
|
+
...(meta.specifier ? { specifier: meta.specifier } : {}),
|
|
97
|
+
});
|
|
98
|
+
};
|
|
83
99
|
// rec: {spec, kind, line} bare-pkg import · {dynamic:true, spec?, target?} dynamic import marker
|
|
84
100
|
// (target = internally-resolved dynamic import; suppresses false "unused file" in dep analysis) ·
|
|
85
101
|
// {unresolved:true, spec} broken local import (relative or alias path that resolves to no file).
|
|
86
102
|
const addExternalImport = (rec) => {
|
|
87
103
|
if (!rec) return;
|
|
88
|
-
if (rec.dynamic) { externalImports.push({ file: fileRel, spec: rec.spec || null, kind: rec.kind || "dynamic", dynamic: true, line: rec.line || 0, ...(rec.target ? { target: rec.target } : {}) }); return; }
|
|
89
|
-
if (rec.unresolved) { externalImports.push({ file: fileRel, spec: rec.spec, kind: rec.kind || "esm", unresolved: true, line: rec.line || 0 }); return; }
|
|
104
|
+
if (rec.dynamic) { externalImports.push({ file: fileRel, spec: rec.spec || null, kind: rec.kind || "dynamic", dynamic: true, line: rec.line || 0, ...(rec.target ? { target: rec.target } : {}), ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
|
|
105
|
+
if (rec.unresolved) { externalImports.push({ file: fileRel, spec: rec.spec, kind: rec.kind || "esm", unresolved: true, line: rec.line || 0, ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
|
|
90
106
|
// non-npm extractors (go/python) classify their own specs and pass pkg/builtin/ecosystem precomputed
|
|
91
|
-
if (rec.pkg) { externalImports.push({ file: fileRel, spec: rec.spec, pkg: rec.pkg, builtin: !!rec.builtin, kind: rec.kind || "import", line: rec.line || 0, ...(rec.ecosystem ? { ecosystem: rec.ecosystem } : {}) }); return; }
|
|
107
|
+
if (rec.pkg) { externalImports.push({ file: fileRel, spec: rec.spec, pkg: rec.pkg, builtin: !!rec.builtin, kind: rec.kind || "import", line: rec.line || 0, ...(rec.ecosystem ? { ecosystem: rec.ecosystem } : {}), ...(rec.typeOnly ? { typeOnly: true } : {}) }); return; }
|
|
92
108
|
const r = specToPkg(rec.spec);
|
|
93
109
|
if (!r) return;
|
|
94
|
-
externalImports.push({ file: fileRel, spec: rec.spec, pkg: r.pkg, builtin: !!r.builtin, kind: rec.kind || "esm", line: rec.line || 0 });
|
|
110
|
+
externalImports.push({ file: fileRel, spec: rec.spec, pkg: r.pkg, builtin: !!r.builtin, kind: rec.kind || "esm", line: rec.line || 0, ...(rec.typeOnly ? { typeOnly: true } : {}) });
|
|
95
111
|
};
|
|
96
112
|
// Post-hoc export flag (export {a}, export default X, CJS module.exports) — declarations are flagged at addSym time.
|
|
97
|
-
const markExported = (name) => { const id =
|
|
113
|
+
const markExported = (name) => { const id = moduleNameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
|
|
98
114
|
|
|
99
115
|
try { lang.pass1({ grammar, tree, fileRel, code, caps, field, addSym, addNode, links, nodeIds, syms, nameToId, imports, addImportEdge, addExternalImport, markExported, fileSet, ...resolvers }); }
|
|
100
116
|
catch (e) { /* one bad file never sinks the whole build */ void e; }
|
|
@@ -164,6 +180,28 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
164
180
|
const target = resolveCall(cap.node.text, fileRel);
|
|
165
181
|
if (target && target !== cls.id) links.push({ source: cls.id, target, relation: "inherits", confidence: "INFERRED" });
|
|
166
182
|
}
|
|
183
|
+
// JSX is a real symbol use even though it is not a call_expression. Resolve imported components to their
|
|
184
|
+
// declaration so component fan-in and unused-export checks do not claim `<SettingsView />` is unreferenced.
|
|
185
|
+
if (FAMILY[grammar] === "js") {
|
|
186
|
+
for (const cap of caps(grammar, `[
|
|
187
|
+
(jsx_opening_element name: (_) @jsx)
|
|
188
|
+
(jsx_self_closing_element name: (_) @jsx)
|
|
189
|
+
]`, tree.rootNode)) {
|
|
190
|
+
const jsxName = cap.node.text;
|
|
191
|
+
const parts = jsxName.split(".");
|
|
192
|
+
const localName = parts[0];
|
|
193
|
+
if (parts.length === 1 && !/^[A-Z_$]/.test(localName)) continue; // undotted lowercase tags are platform/intrinsic elements
|
|
194
|
+
const imp = importedLocals.get(fileRel)?.get(localName);
|
|
195
|
+
if (!imp || !imp.targetFile || imp.typeOnly) continue;
|
|
196
|
+
const targetSymbols = symByFileName.get(imp.targetFile);
|
|
197
|
+
if (!targetSymbols) continue;
|
|
198
|
+
const importedName = imp.imported === "*" && parts.length > 1 ? parts[parts.length - 1] : imp.imported;
|
|
199
|
+
const target = targetSymbols.get(importedName) || targetSymbols.get(localName);
|
|
200
|
+
if (!target) continue;
|
|
201
|
+
const owner = enclosing(fileRel, cap.node.startPosition.row + 1);
|
|
202
|
+
links.push({ source: owner?.id || fileRel, target, relation: "references", confidence: "INFERRED", usage: "jsx", line: cap.node.startPosition.row + 1 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
167
205
|
// Go value references: a top-level const/var/type/func used BY NAME (bare `X`, or cross-package `pkg.X`) in
|
|
168
206
|
// another file/scope → a `references` edge, so used-but-never-called symbols (message-type consts, etc.) are
|
|
169
207
|
// not falsely DEAD. (Same-file usage is already covered by graph-builder-analysis localRefs.) Go-only for now.
|
|
@@ -206,7 +244,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
206
244
|
|
|
207
245
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
208
246
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
209
|
-
return { nodes, links, externalImports, extImportsV: 2, complexityV: 1, repoBoundaryV: 1 };
|
|
247
|
+
return { nodes, links, externalImports, extImportsV: 2, edgeTypesV: 1, complexityV: 1, repoBoundaryV: 1 };
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
// Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
// build works — and Electron main runs Node, so this needs no external runtime.
|
|
6
6
|
import { readdirSync, statSync, realpathSync } from "node:fs";
|
|
7
7
|
import { join, extname, dirname } from "node:path";
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
8
9
|
import { createRequire } from "node:module";
|
|
9
10
|
import { isPathInside } from "../repo-path.js";
|
|
11
|
+
import { childProcessEnv } from "../child-env.js";
|
|
10
12
|
import LANG_JS from "./builder/lang-js.js";
|
|
11
13
|
import LANG_PY from "./builder/lang-python.js";
|
|
12
14
|
import LANG_GO from "./builder/lang-go.js";
|
|
@@ -68,7 +70,7 @@ async function ensureParser(opts = {}, wanted = null) {
|
|
|
68
70
|
// Cycle-safe directory walk. statSync FOLLOWS symlinks/junctions, so a link pointing at an ancestor would
|
|
69
71
|
// otherwise recurse forever (a/b/link/b/link/…). We dedupe by REAL path (a visited dir is never re-entered)
|
|
70
72
|
// and cap depth as a backstop, so a symlink loop can't wedge the build.
|
|
71
|
-
function
|
|
73
|
+
function walkFallback(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
|
|
72
74
|
if (depth > 40) return acc;
|
|
73
75
|
let real; try { real = realpathSync.native(dir); } catch { return acc; }
|
|
74
76
|
if (rootReal == null) rootReal = real;
|
|
@@ -86,7 +88,7 @@ function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
|
|
|
86
88
|
let entryReal; try { entryReal = realpathSync.native(full); } catch { continue; }
|
|
87
89
|
if (!isPathInside(rootReal, entryReal)) continue;
|
|
88
90
|
let st; try { st = statSync(full); } catch { continue; }
|
|
89
|
-
if (st.isDirectory())
|
|
91
|
+
if (st.isDirectory()) walkFallback(full, acc, seen, depth + 1, rootReal);
|
|
90
92
|
// include by KNOWN extension, not by loaded grammar — grammars now load lazily AFTER the walk
|
|
91
93
|
// (the parse passes skip files whose grammar failed to load, so the guarantee is unchanged)
|
|
92
94
|
else { const e = extname(name); if (EXT_LANG[e] || isDataFile(name) || isDocFile(name)) acc.push(full); }
|
|
@@ -94,4 +96,43 @@ function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
|
|
|
94
96
|
return acc;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// Git already owns the repository's file-universe rules. Asking it for tracked files plus untracked,
|
|
100
|
+
// non-ignored files prevents generated outputs (Electron release/, custom cache dirs, ignored agent files,
|
|
101
|
+
// etc.) from contaminating graph/duplicate/audit results while still indexing new source before it is staged.
|
|
102
|
+
// A repository may be opened without Git (or Git may be unavailable), so failure is deliberately a signal to
|
|
103
|
+
// use the boundary-safe walker above rather than a build failure.
|
|
104
|
+
function gitFileUniverse(dir) {
|
|
105
|
+
let raw;
|
|
106
|
+
try {
|
|
107
|
+
raw = execFileSync("git", ["-C", dir, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
111
|
+
timeout: 15_000,
|
|
112
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
113
|
+
env: childProcessEnv(),
|
|
114
|
+
});
|
|
115
|
+
} catch { return null; }
|
|
116
|
+
|
|
117
|
+
let rootReal;
|
|
118
|
+
try { rootReal = realpathSync.native(dir); } catch { return null; }
|
|
119
|
+
const files = [];
|
|
120
|
+
for (const rel of raw.split("\0")) {
|
|
121
|
+
if (!rel) continue;
|
|
122
|
+
const full = join(dir, rel);
|
|
123
|
+
let real; try { real = realpathSync.native(full); } catch { continue; } // deleted index entry
|
|
124
|
+
if (!isPathInside(rootReal, real)) continue; // tracked symlink/junction escaping the repo
|
|
125
|
+
let st; try { st = statSync(full); } catch { continue; }
|
|
126
|
+
if (!st.isFile()) continue; // includes neither submodule dirs nor directory-like junctions
|
|
127
|
+
const name = rel.split(/[\\/]/).pop() || "";
|
|
128
|
+
const ext = extname(name);
|
|
129
|
+
if (EXT_LANG[ext] || isDataFile(name) || isDocFile(name)) files.push(full);
|
|
130
|
+
}
|
|
131
|
+
return files;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function walk(dir) {
|
|
135
|
+
return gitFileUniverse(dir) ?? walkFallback(dir);
|
|
136
|
+
}
|
|
137
|
+
|
|
97
138
|
export { Parser, Query, GRAMMARS, LANGS, EXT_LANG, FAMILY, isDataFile, isDocFile, MAX_PARSE_BYTES, ensureParser, walk };
|