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.
@@ -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, 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 dead = computeDead(graph, sources);
43
- const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets });
44
- const entries = entryFiles(graph, pkg, dynamicTargets);
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 = computeDepFindings({ externalImports, pkg, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
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 pyDep = computePyDepFindings({ externalImports, pyManifest: collectPyManifest(repoPath), configTexts });
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 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;
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
- for (const name of Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) })) {
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 { /* supply-chain layer is best-effort */ }
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
- } catch { /* heuristic scan is best-effort */ }
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: unusedExports.length },
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
- // 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 ----
@@ -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 }); if (!nameToId.has(name)) nameToId.set(name, id);
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) => { if (tgt && tgt !== fileRel) links.push({ source: fileRel, target: tgt, relation: "imports", confidence: "EXTRACTED" }); };
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 = nameToId.get(name); const n = id && nodeById.get(id); if (n) n.exported = true; };
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 walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
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()) walk(full, acc, seen, depth + 1, rootReal);
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 };