weavatrix 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -3,13 +3,60 @@
|
|
|
3
3
|
import { posix } from "node:path";
|
|
4
4
|
import { ENTRY_FILE, isFrameworkEntryFile } from "./dead-check.js";
|
|
5
5
|
import { TEST_FILE_RE } from "./internal-audit.collect.js";
|
|
6
|
+
import { maskJavaNonCode } from "./java-source.js";
|
|
6
7
|
|
|
7
8
|
const isFileNode = (n) => !String(n.id).includes("#");
|
|
8
9
|
|
|
10
|
+
const springAnnotation = (name) => new RegExp(`@(?:[A-Za-z_$][\\w$]*\\s*\\.\\s*)*${name}\\b`);
|
|
11
|
+
const SPRING_CONVENTIONS = [
|
|
12
|
+
["SpringBootApplication", "high", "Spring Boot application entry point is launched externally and starts component scanning"],
|
|
13
|
+
["RestControllerAdvice", "high", "Spring registers @RestControllerAdvice through application-context scanning"],
|
|
14
|
+
["ControllerAdvice", "high", "Spring registers @ControllerAdvice through application-context scanning"],
|
|
15
|
+
["RestController", "high", "Spring registers @RestController through component scanning and routes requests to it"],
|
|
16
|
+
["Controller", "high", "Spring registers @Controller through component scanning"],
|
|
17
|
+
["Configuration", "high", "Spring discovers @Configuration and invokes its bean definitions externally"],
|
|
18
|
+
["Service", "high", "Spring registers @Service through component scanning"],
|
|
19
|
+
["Repository", "high", "Spring registers @Repository through component scanning or repository proxy creation"],
|
|
20
|
+
["Component", "high", "Spring registers @Component through component scanning"],
|
|
21
|
+
["ConfigurationProperties", "high", "Spring binds @ConfigurationProperties outside the static import graph"],
|
|
22
|
+
["KafkaListener", "high", "Spring Kafka invokes @KafkaListener methods from the message container"],
|
|
23
|
+
["Scheduled", "high", "Spring invokes @Scheduled methods from its task scheduler"],
|
|
24
|
+
["EventListener", "high", "Spring invokes @EventListener methods from the application event bus"],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
// Framework-managed Java files are externally entered by the Spring container, not by an ordinary
|
|
28
|
+
// source import. Return bounded, explainable evidence so suppressing an orphan is never a silent guess.
|
|
29
|
+
export function springConventionEntries(sources, fileSet = null) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const [rawFile, rawText] of sources || []) {
|
|
32
|
+
const file = String(rawFile || "").replace(/\\/g, "/");
|
|
33
|
+
if (!/\.java$/i.test(file) || (fileSet && !fileSet.has(file))) continue;
|
|
34
|
+
const text = maskJavaNonCode(rawText);
|
|
35
|
+
let evidence = null;
|
|
36
|
+
for (const [marker, confidence, reason] of SPRING_CONVENTIONS) {
|
|
37
|
+
if (springAnnotation(marker).test(text)) {
|
|
38
|
+
evidence = { file, framework: "spring", marker: `@${marker}`, confidence, reason };
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!evidence && /\binterface\s+[A-Za-z_$][\w$]*(?:\s*<[^>{}]+>)?\s+extends\s+[^;{]*(?:JpaRepository|MongoRepository|ReactiveCrudRepository|CrudRepository|PagingAndSortingRepository|Repository)\s*</s.test(text)) {
|
|
43
|
+
evidence = {
|
|
44
|
+
file,
|
|
45
|
+
framework: "spring-data",
|
|
46
|
+
marker: "repository interface",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
reason: "Spring Data creates the repository implementation and proxy from the interface at runtime",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (evidence) out.push(evidence);
|
|
52
|
+
}
|
|
53
|
+
return out.sort((a, b) => a.file.localeCompare(b.file));
|
|
54
|
+
}
|
|
55
|
+
|
|
9
56
|
// Entry set for reachability: conventional entry names + package.json main/module/browser/bin/exports +
|
|
10
57
|
// html pages (they root classic-script apps) + test files (the runner enters them) + root config files +
|
|
11
58
|
// dynamic-import targets. Anything reachable from here is "used"; the rest corroborates unused-file.
|
|
12
|
-
export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map() } = {}) {
|
|
59
|
+
export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { declaredEntries = [], sources = new Map(), conventionEvidence = [] } = {}) {
|
|
13
60
|
const entries = new Set();
|
|
14
61
|
const scopes = Array.isArray(pkgOrScopes)
|
|
15
62
|
? pkgOrScopes
|
|
@@ -51,6 +98,10 @@ export function entryFiles(graph, pkgOrScopes, dynamicTargets = new Set(), { dec
|
|
|
51
98
|
const f = n.source_file;
|
|
52
99
|
if (ENTRY_FILE.test(f) || isFrameworkEntryFile(f) || /\.d\.[cm]?ts$/i.test(f) || TEST_FILE_RE.test(f) || /\.html?$/i.test(f) || pe.has(f) || /(^|\/)[^/]*\.config\.[a-z]+$/i.test(f)) entries.add(f);
|
|
53
100
|
}
|
|
101
|
+
for (const evidence of springConventionEntries(sources, fileSet)) {
|
|
102
|
+
entries.add(evidence.file);
|
|
103
|
+
conventionEvidence.push(evidence);
|
|
104
|
+
}
|
|
54
105
|
for (const raw of Array.isArray(declaredEntries) ? declaredEntries : [declaredEntries]) {
|
|
55
106
|
const resolved = resolveEntry("", raw);
|
|
56
107
|
if (resolved && fileSet.has(resolved)) entries.add(resolved);
|
|
@@ -16,10 +16,14 @@ import { scanMalware } from "../security/malware-heuristics.js";
|
|
|
16
16
|
import { classifyTyposquat } from "../security/typosquat.js";
|
|
17
17
|
import {
|
|
18
18
|
readJson, readRepoText, readRepoJson, collectSourceTexts, collectConfigTexts, workspacePkgNames,
|
|
19
|
-
collectPackageScopes, collectPyManifest, TEST_FILE_RE,
|
|
19
|
+
collectPackageScopes, collectPyManifest, collectNonRuntimeRoots, TEST_FILE_RE,
|
|
20
20
|
} from "./internal-audit.collect.js";
|
|
21
21
|
import { entryFiles, computeReachability } from "./internal-audit.reach.js";
|
|
22
22
|
import { createRepoBoundary } from "../repo-path.js";
|
|
23
|
+
import { PATH_CLASS_NAMES, createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
24
|
+
import { packageReachability } from "./package-reachability.js";
|
|
25
|
+
|
|
26
|
+
const LOWER_SEVERITY = { critical: "high", high: "medium", medium: "low", low: "info", info: "info" };
|
|
23
27
|
|
|
24
28
|
// Run the internal audit. graph is optional (loaded from the repo's central graph.json when absent);
|
|
25
29
|
// advisoryStorePath overrides the default ~/.weavatrix/advisories.json (tests use a scratch path).
|
|
@@ -40,19 +44,42 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
40
44
|
|
|
41
45
|
// Graphs can be stale or miss a helper file; text fallbacks must scan the real repo tree too.
|
|
42
46
|
const sources = collectSourceTexts(repoPath, graph);
|
|
47
|
+
const nonRuntimeRoots = collectNonRuntimeRoots(repoPath, rules);
|
|
48
|
+
const pathClassifier = createPathClassifier(repoPath);
|
|
49
|
+
const pathClassifications = new Map();
|
|
50
|
+
const classifyPath = (file) => {
|
|
51
|
+
const normalized = String(file || "").replace(/\\/g, "/");
|
|
52
|
+
if (!pathClassifications.has(normalized)) {
|
|
53
|
+
pathClassifications.set(normalized, pathClassifier.explain(normalized, { content: sources.get(normalized) }));
|
|
54
|
+
}
|
|
55
|
+
return pathClassifications.get(normalized);
|
|
56
|
+
};
|
|
57
|
+
const isNonProductPath = (file) => {
|
|
58
|
+
const info = classifyPath(file);
|
|
59
|
+
return info.excluded || hasPathClass(info, "test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp");
|
|
60
|
+
};
|
|
43
61
|
|
|
62
|
+
const conventionEvidence = [];
|
|
44
63
|
const entries = entryFiles(graph, packageScopes, dynamicTargets, {
|
|
45
64
|
declaredEntries: rules.entrypoints || rules.entries || [],
|
|
46
65
|
sources,
|
|
66
|
+
conventionEvidence,
|
|
47
67
|
});
|
|
68
|
+
for (const file of sources.keys()) {
|
|
69
|
+
if (nonRuntimeRoots.some((root) => file === root || file.startsWith(`${root}/`))) entries.add(file);
|
|
70
|
+
if (isNonProductPath(file)) entries.add(file);
|
|
71
|
+
}
|
|
48
72
|
const dead = computeDead(graph, sources, { entrySet: entries });
|
|
49
73
|
const unusedExports = computeUnusedExports(graph, sources, { dynamicTargets, entrySet: entries });
|
|
50
74
|
const reachable = computeReachability(graph, entries);
|
|
51
75
|
const configTexts = collectConfigTexts(repoPath);
|
|
52
|
-
const dep = computeScopedDepFindings({
|
|
76
|
+
const dep = computeScopedDepFindings({
|
|
77
|
+
externalImports, packageScopes, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts,
|
|
78
|
+
nonRuntimeRoots, sourceFiles: [...sources.keys()],
|
|
79
|
+
});
|
|
53
80
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
54
81
|
const goModText = readRepoText(boundary, "go.mod");
|
|
55
|
-
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
|
|
82
|
+
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null, nonRuntimeRoots });
|
|
56
83
|
const asList = (v) => Array.isArray(v) ? v : typeof v === "string" ? [v] : [];
|
|
57
84
|
const pyRules = rules.python || {};
|
|
58
85
|
const depRules = rules.dependencies || {};
|
|
@@ -66,7 +93,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
66
93
|
])];
|
|
67
94
|
const pyDep = computePyDepFindings({
|
|
68
95
|
externalImports, pyManifest: collectPyManifest(repoPath), configTexts,
|
|
69
|
-
managedDependencies: managedPython, ignoredDependencies: ignoredPython,
|
|
96
|
+
managedDependencies: managedPython, ignoredDependencies: ignoredPython, nonRuntimeRoots,
|
|
70
97
|
});
|
|
71
98
|
|
|
72
99
|
// structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
|
|
@@ -78,7 +105,8 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
78
105
|
// orphan ∩ dead-file → one finding: keep the stronger unused-file, drop the duplicate orphan
|
|
79
106
|
const deadFileSet = new Set(dead.deadFiles.map((f) => f.file));
|
|
80
107
|
for (const f of structure.findings) if (!(f.rule === "orphan-file" && deadFileSet.has(f.file))) findings.push(f);
|
|
81
|
-
|
|
108
|
+
const actionableDeadFiles = dead.deadFiles.filter((f) => !isNonProductPath(f.file));
|
|
109
|
+
for (const f of actionableDeadFiles) {
|
|
82
110
|
if (entries.has(f.file) || dynamicTargets.has(f.file) || TEST_FILE_RE.test(f.file)) continue;
|
|
83
111
|
findings.push(makeFinding({
|
|
84
112
|
category: "unused",
|
|
@@ -95,7 +123,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
95
123
|
}
|
|
96
124
|
let unusedExportCount = 0;
|
|
97
125
|
for (const s of unusedExports) {
|
|
98
|
-
if (s.test) continue; //
|
|
126
|
+
if (s.test || isNonProductPath(s.file)) continue; // convention/generated surfaces are externally consumed or non-product noise
|
|
99
127
|
if (/(^|\/)[^/]*\.config\.[a-z0-9]+$|(^|\/)\.[^/]+rc(\.[a-z]+)?$/i.test(s.file)) continue; // config exports are consumed by their tool
|
|
100
128
|
findings.push(makeFinding({
|
|
101
129
|
category: "unused",
|
|
@@ -118,7 +146,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
118
146
|
let installedCount = 0;
|
|
119
147
|
let inst = { installed: [], drift: [] };
|
|
120
148
|
const checks = {
|
|
121
|
-
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository.
|
|
149
|
+
osv: { status: "NOT_CHECKED", detail: "Advisory cache was never refreshed for this repository. Enable the osv profile (or advisories capability), then call refresh_advisories explicitly to opt in to sending pinned package names and versions to OSV.dev." },
|
|
122
150
|
malware: { status: skipMalwareScan ? "NOT_CHECKED" : "PENDING", detail: skipMalwareScan ? "Installed-package malware scan is opt-in and was not requested." : "" },
|
|
123
151
|
};
|
|
124
152
|
try {
|
|
@@ -136,7 +164,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
136
164
|
const coverage = typeof repoStamp === "object" && Number.isFinite(repoStamp.queried)
|
|
137
165
|
? ` (${repoStamp.queried_ok ?? repoStamp.queried}/${repoStamp.queried} package versions queried successfully)`
|
|
138
166
|
: "";
|
|
139
|
-
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the
|
|
167
|
+
const drift = fingerprintMatches ? "" : " Dependency versions changed, or this is a legacy stamp without a package fingerprint; enable the osv profile (or advisories capability) and call refresh_advisories for complete coverage.";
|
|
140
168
|
checks.osv = {
|
|
141
169
|
status,
|
|
142
170
|
detail: `${status === "PARTIAL" ? "Partially matched" : "Matched"} installed packages against the cached OSV snapshot from ${advisoryDbDate}${coverage}.${drift}`,
|
|
@@ -144,16 +172,25 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
144
172
|
};
|
|
145
173
|
for (const h of matchAdvisories(inst.installed, (eco, name) => queryStore(store, eco, name))) {
|
|
146
174
|
const mal = h.adv.kind === "malicious";
|
|
175
|
+
const reachability = packageReachability(externalImports, h.pkg.name, { isNonProductPath });
|
|
176
|
+
const observedInProduct = reachability.state === "DIRECT_RUNTIME_IMPORT";
|
|
177
|
+
const reachabilityDetail = observedInProduct
|
|
178
|
+
? ` Graph reachability: directly imported by product code in ${reachability.directRuntimeImports} callsite(s) across ${reachability.files.length} file(s).`
|
|
179
|
+
: ` Graph reachability: ${reachability.state}; ${reachability.note}`;
|
|
147
180
|
findings.push(makeFinding({
|
|
148
181
|
category: mal ? "malware" : "vulnerability",
|
|
149
182
|
rule: mal ? "malicious-package" : "known-vuln",
|
|
150
|
-
severity: mal ? "critical" : h.adv.severity,
|
|
151
|
-
confidence: h.confidence,
|
|
183
|
+
severity: mal || observedInProduct ? (mal ? "critical" : h.adv.severity) : LOWER_SEVERITY[h.adv.severity] || h.adv.severity,
|
|
184
|
+
confidence: mal || observedInProduct ? h.confidence : "low",
|
|
152
185
|
title: `${mal ? "Known-malicious package" : `Known vulnerability (${h.adv.id})`}: ${h.pkg.name}@${h.pkg.version}`,
|
|
153
|
-
detail: `${h.adv.summary || h.adv.id}${h.adv.fixedIn.length ? ` Fixed in: ${h.adv.fixedIn.join(", ")}.` : mal ? " Remove this package immediately and rotate any secrets it could reach." : ""} (matched by ${h.matchedBy}${h.adv.aliases.length ? `; aliases ${h.adv.aliases.join(", ")}` : ""})`,
|
|
186
|
+
detail: `${h.adv.summary || h.adv.id}${h.adv.fixedIn.length ? ` Fixed in: ${h.adv.fixedIn.join(", ")}.` : mal ? " Remove this package immediately and rotate any secrets it could reach." : ""} (matched by ${h.matchedBy}${h.adv.aliases.length ? `; aliases ${h.adv.aliases.join(", ")}` : ""}).${reachabilityDetail}`,
|
|
154
187
|
package: h.pkg.name,
|
|
155
188
|
version: h.pkg.version,
|
|
156
|
-
|
|
189
|
+
reachability,
|
|
190
|
+
evidence: [
|
|
191
|
+
{ file: h.adv.url, line: 0, snippet: `installed via ${h.pkg.source}${h.pkg.dev ? " (dev)" : ""}` },
|
|
192
|
+
...reachability.evidence.slice(0, 5).map((item) => ({file: item.file, line: item.line, snippet: `${item.typeOnly ? "type-only " : ""}${item.kind}`})),
|
|
193
|
+
],
|
|
157
194
|
source: "osv",
|
|
158
195
|
fixHint: mal ? `npm uninstall ${h.pkg.name} + audit what it touched` : h.adv.fixedIn.length ? `upgrade ${h.pkg.name} to ${h.adv.fixedIn[h.adv.fixedIn.length - 1]}+` : "no fixed version published — consider replacing the package",
|
|
159
196
|
}));
|
|
@@ -229,10 +266,26 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
229
266
|
malwareStatus: checks.malware.status,
|
|
230
267
|
packageScopes: packageScopes.length,
|
|
231
268
|
managedPythonDependencies: managedPython.length,
|
|
269
|
+
nonRuntimeRoots,
|
|
270
|
+
pathClassifications: Object.fromEntries(PATH_CLASS_NAMES.map((name) => [
|
|
271
|
+
name,
|
|
272
|
+
[...pathClassifications.values()].filter((info) => info.classes.includes(name)).length,
|
|
273
|
+
])),
|
|
274
|
+
pathClassificationExcluded: [...pathClassifications.values()].filter((info) => info.excluded).length,
|
|
275
|
+
conventionEntrypoints: conventionEvidence.length,
|
|
232
276
|
},
|
|
233
277
|
summary: summarizeFindings(sorted),
|
|
234
278
|
findings: sorted,
|
|
235
|
-
deadReport: {
|
|
279
|
+
deadReport: {
|
|
280
|
+
deadSymbols: dead.deadSymbols.filter((symbol) => !isNonProductPath(symbol.file)).length,
|
|
281
|
+
deadFiles: actionableDeadFiles.length,
|
|
282
|
+
unusedExports: unusedExportCount,
|
|
283
|
+
},
|
|
284
|
+
conventionReachability: {
|
|
285
|
+
count: conventionEvidence.length,
|
|
286
|
+
entries: conventionEvidence.slice(0, 100),
|
|
287
|
+
truncated: conventionEvidence.length > 100,
|
|
288
|
+
},
|
|
236
289
|
structureReport: structure.stats,
|
|
237
290
|
checks,
|
|
238
291
|
malwareScan,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Replace Java comments and string/char literal contents with spaces while preserving length and
|
|
2
|
+
// newlines. Regex-based signal extractors can then trust match positions without accepting examples
|
|
3
|
+
// from comments, docs, or string constants as executable annotations.
|
|
4
|
+
export function maskJavaNonCode(source) {
|
|
5
|
+
const text = String(source || "");
|
|
6
|
+
const chars = [...text];
|
|
7
|
+
let state = "code";
|
|
8
|
+
let escaped = false;
|
|
9
|
+
for (let i = 0; i < text.length; i++) {
|
|
10
|
+
const ch = text[i];
|
|
11
|
+
const next = text[i + 1];
|
|
12
|
+
if (state === "code") {
|
|
13
|
+
if (ch === "/" && next === "/") { chars[i] = chars[i + 1] = " "; i++; state = "line"; }
|
|
14
|
+
else if (ch === "/" && next === "*") { chars[i] = chars[i + 1] = " "; i++; state = "block"; }
|
|
15
|
+
else if (ch === '"') { chars[i] = " "; state = "string"; escaped = false; }
|
|
16
|
+
else if (ch === "'") { chars[i] = " "; state = "char"; escaped = false; }
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (state === "line") {
|
|
20
|
+
if (ch === "\n" || ch === "\r") state = "code";
|
|
21
|
+
else chars[i] = " ";
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (state === "block") {
|
|
25
|
+
if (ch === "*" && next === "/") { chars[i] = chars[i + 1] = " "; i++; state = "code"; }
|
|
26
|
+
else if (ch !== "\n" && ch !== "\r") chars[i] = " ";
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === "\n" || ch === "\r") { escaped = false; continue; }
|
|
30
|
+
chars[i] = " ";
|
|
31
|
+
if (escaped) escaped = false;
|
|
32
|
+
else if (ch === "\\") escaped = true;
|
|
33
|
+
else if ((state === "string" && ch === '"') || (state === "char" && ch === "'")) state = "code";
|
|
34
|
+
}
|
|
35
|
+
return chars.join("");
|
|
36
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Package-level reachability: correlate a dependency finding with the graph's external imports.
|
|
2
|
+
// This is intentionally not function-level exploitability analysis. It only states whether product
|
|
3
|
+
// code imports the package and preserves unknown as unknown.
|
|
4
|
+
export const PACKAGE_REACHABILITY_V = 1
|
|
5
|
+
|
|
6
|
+
export function packageReachability(externalImports, packageName, {isNonProductPath = () => false} = {}) {
|
|
7
|
+
const imports = (Array.isArray(externalImports) ? externalImports : [])
|
|
8
|
+
.filter((item) => item?.pkg === packageName && item.builtin !== true)
|
|
9
|
+
.map((item) => ({
|
|
10
|
+
file: String(item.file || '').replace(/\\/g, '/'),
|
|
11
|
+
line: Number.isInteger(item.line) ? item.line : 0,
|
|
12
|
+
kind: String(item.kind || 'import'),
|
|
13
|
+
typeOnly: item.typeOnly === true,
|
|
14
|
+
dynamic: item.dynamic === true,
|
|
15
|
+
}))
|
|
16
|
+
.filter((item) => item.file)
|
|
17
|
+
const product = imports.filter((item) => !isNonProductPath(item.file))
|
|
18
|
+
const runtime = product.filter((item) => !item.typeOnly)
|
|
19
|
+
const state = runtime.length
|
|
20
|
+
? 'DIRECT_RUNTIME_IMPORT'
|
|
21
|
+
: product.length
|
|
22
|
+
? 'TYPE_ONLY_IMPORT'
|
|
23
|
+
: imports.length
|
|
24
|
+
? 'NON_PRODUCT_IMPORT_ONLY'
|
|
25
|
+
: 'NOT_OBSERVED_IN_GRAPH'
|
|
26
|
+
return {
|
|
27
|
+
packageReachabilityV: PACKAGE_REACHABILITY_V,
|
|
28
|
+
level: 'package-import',
|
|
29
|
+
state,
|
|
30
|
+
directRuntimeImports: runtime.length,
|
|
31
|
+
directProductImports: product.length,
|
|
32
|
+
observedImports: imports.length,
|
|
33
|
+
files: [...new Set((runtime.length ? runtime : product.length ? product : imports).map((item) => item.file))].sort().slice(0, 20),
|
|
34
|
+
evidence: (runtime.length ? runtime : product.length ? product : imports).slice(0, 10),
|
|
35
|
+
note: state === 'NOT_OBSERVED_IN_GRAPH'
|
|
36
|
+
? 'No static import was observed; implicit, dynamic, plugin and transitive runtime use remain unknown.'
|
|
37
|
+
: 'Package-level static import evidence; this does not prove vulnerable-function reachability.',
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// A deliberately weaker fallback than real coverage: follow runtime graph edges out of test files
|
|
2
|
+
// and report nearest test paths. This is evidence of static reachability only; it never claims a line,
|
|
3
|
+
// branch or symbol executed. The traversal is bounded for large repositories.
|
|
4
|
+
import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
5
|
+
import { isStructuralRelation } from "../graph/relations.js";
|
|
6
|
+
|
|
7
|
+
const MAX_TEST_FILES = 250;
|
|
8
|
+
const MAX_NEAREST_TESTS = 3;
|
|
9
|
+
const MAX_DEPTH = 10;
|
|
10
|
+
const MAX_STATES = 100_000;
|
|
11
|
+
|
|
12
|
+
const normalize = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
13
|
+
const endpoint = (value) => String(value && typeof value === "object" ? value.id : value || "");
|
|
14
|
+
const confidenceScore = (value) => {
|
|
15
|
+
const normalized = String(value || "").toUpperCase();
|
|
16
|
+
if (["EXTRACTED", "EXACT", "HIGH"].includes(normalized)) return 3;
|
|
17
|
+
if (["INFERRED", "MEDIUM"].includes(normalized)) return 2;
|
|
18
|
+
return 1;
|
|
19
|
+
};
|
|
20
|
+
const confidenceLabel = (score) => score >= 3 ? "HIGH" : score >= 2 ? "MEDIUM" : "LOW";
|
|
21
|
+
const pathInScope = (path, prefix) => !prefix || path === prefix || path.startsWith(`${prefix}/`);
|
|
22
|
+
|
|
23
|
+
export function computeStaticTestReachability(graph, {
|
|
24
|
+
repoRoot = null,
|
|
25
|
+
path = "",
|
|
26
|
+
maxDepth = MAX_DEPTH,
|
|
27
|
+
maxStates = MAX_STATES,
|
|
28
|
+
maxTests = MAX_TEST_FILES,
|
|
29
|
+
maxNearestTests = MAX_NEAREST_TESTS,
|
|
30
|
+
} = {}) {
|
|
31
|
+
const nodes = graph?.nodes || [];
|
|
32
|
+
const links = graph?.links || [];
|
|
33
|
+
const classifier = createPathClassifier(repoRoot);
|
|
34
|
+
const classificationCache = new Map();
|
|
35
|
+
const classify = (file) => {
|
|
36
|
+
const normalized = normalize(file);
|
|
37
|
+
if (!classificationCache.has(normalized)) classificationCache.set(normalized, classifier.explain(normalized, { content: "" }));
|
|
38
|
+
return classificationCache.get(normalized);
|
|
39
|
+
};
|
|
40
|
+
const isTest = (file) => hasPathClass(classify(file), "test", "e2e");
|
|
41
|
+
const isProduct = (file) => {
|
|
42
|
+
const info = classify(file);
|
|
43
|
+
return !info.excluded && !hasPathClass(info, "test", "e2e", "generated", "mock", "story", "docs", "benchmark", "temp");
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const idToFile = new Map();
|
|
47
|
+
const files = new Set();
|
|
48
|
+
for (const node of nodes) {
|
|
49
|
+
const file = normalize(node?.source_file || (!String(node?.id || "").includes("#") ? node?.id : ""));
|
|
50
|
+
if (!file) continue;
|
|
51
|
+
idToFile.set(String(node.id), file);
|
|
52
|
+
files.add(file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Collapse symbol/file edges to directed file dependencies. Multiple symbol edges retain the best
|
|
56
|
+
// confidence for the same hop; compile-time-only and ownership relations never imply test reach.
|
|
57
|
+
const adjacency = new Map();
|
|
58
|
+
for (const link of links) {
|
|
59
|
+
if (!link || link.typeOnly === true || link.compileOnly === true || isStructuralRelation(link.relation)) continue;
|
|
60
|
+
const from = idToFile.get(endpoint(link.source));
|
|
61
|
+
const to = idToFile.get(endpoint(link.target));
|
|
62
|
+
if (!from || !to || from === to) continue;
|
|
63
|
+
if (!adjacency.has(from)) adjacency.set(from, new Map());
|
|
64
|
+
const score = confidenceScore(link.confidence);
|
|
65
|
+
const previous = adjacency.get(from).get(to);
|
|
66
|
+
if (!previous || score > previous.score) adjacency.get(from).set(to, { score, relation: link.relation || "dependency" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const allTests = [...files].filter(isTest).sort((a, b) => a.localeCompare(b));
|
|
70
|
+
const testLimit = Math.max(0, Math.min(MAX_TEST_FILES, Number(maxTests) || MAX_TEST_FILES));
|
|
71
|
+
const tests = allTests.slice(0, testLimit);
|
|
72
|
+
const scope = normalize(path).replace(/\/+$/, "");
|
|
73
|
+
const productFiles = [...files].filter(isProduct).filter((file) => pathInScope(file, scope)).sort((a, b) => a.localeCompare(b));
|
|
74
|
+
const productSet = new Set(productFiles);
|
|
75
|
+
const nearest = new Map();
|
|
76
|
+
const queue = tests.map((test) => ({ file: test, test, distance: 0, score: 3, path: [test] }));
|
|
77
|
+
const seen = new Map(tests.map((test) => [`${test}\0${test}`, 0]));
|
|
78
|
+
let cursor = 0;
|
|
79
|
+
let traversedStates = 0;
|
|
80
|
+
const depthLimit = Math.max(1, Math.min(MAX_DEPTH, Number(maxDepth) || MAX_DEPTH));
|
|
81
|
+
const stateLimit = Math.max(100, Math.min(MAX_STATES, Number(maxStates) || MAX_STATES));
|
|
82
|
+
const nearestLimit = Math.max(1, Math.min(MAX_NEAREST_TESTS, Number(maxNearestTests) || MAX_NEAREST_TESTS));
|
|
83
|
+
|
|
84
|
+
while (cursor < queue.length && traversedStates < stateLimit) {
|
|
85
|
+
const current = queue[cursor++];
|
|
86
|
+
traversedStates++;
|
|
87
|
+
if (current.distance >= depthLimit) continue;
|
|
88
|
+
for (const [next, edge] of adjacency.get(current.file) || []) {
|
|
89
|
+
const distance = current.distance + 1;
|
|
90
|
+
const score = Math.min(current.score, edge.score);
|
|
91
|
+
const key = `${current.test}\0${next}`;
|
|
92
|
+
if ((seen.get(key) ?? Infinity) <= distance) continue;
|
|
93
|
+
seen.set(key, distance);
|
|
94
|
+
const record = { test: current.test, distance, score, confidence: confidenceLabel(score), path: [...current.path, next] };
|
|
95
|
+
if (productSet.has(next)) {
|
|
96
|
+
const records = nearest.get(next) || [];
|
|
97
|
+
records.push(record);
|
|
98
|
+
records.sort((a, b) => a.distance - b.distance || b.score - a.score || a.test.localeCompare(b.test));
|
|
99
|
+
if (records.length > nearestLimit) records.length = nearestLimit;
|
|
100
|
+
nearest.set(next, records);
|
|
101
|
+
}
|
|
102
|
+
// Once enough nearer test paths meet at the same file, a longer test prefix cannot become a
|
|
103
|
+
// nearest path downstream because every continuation from this point is shared.
|
|
104
|
+
const records = nearest.get(next);
|
|
105
|
+
if (records?.length >= nearestLimit && !records.some((item) => item.test === current.test) && distance > records[records.length - 1].distance) continue;
|
|
106
|
+
queue.push({ file: next, test: current.test, distance, score, path: record.path });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const reachable = productFiles
|
|
111
|
+
.filter((file) => nearest.has(file))
|
|
112
|
+
.map((file) => ({ file, nearestTests: nearest.get(file) }))
|
|
113
|
+
.sort((a, b) => a.nearestTests[0].distance - b.nearestTests[0].distance || a.file.localeCompare(b.file));
|
|
114
|
+
const unreachable = productFiles.filter((file) => !nearest.has(file));
|
|
115
|
+
return {
|
|
116
|
+
kind: "staticTestReachability",
|
|
117
|
+
actualCoverage: "NOT_AVAILABLE",
|
|
118
|
+
testFiles: tests.length,
|
|
119
|
+
totalTestFiles: allTests.length,
|
|
120
|
+
productFiles: productFiles.length,
|
|
121
|
+
reachableFiles: reachable.length,
|
|
122
|
+
unreachableFiles: unreachable.length,
|
|
123
|
+
reachable,
|
|
124
|
+
unreachable,
|
|
125
|
+
bounds: {
|
|
126
|
+
maxDepth: depthLimit,
|
|
127
|
+
maxStates: stateLimit,
|
|
128
|
+
traversedStates,
|
|
129
|
+
maxTests: testLimit,
|
|
130
|
+
truncated: allTests.length > tests.length || (cursor < queue.length && traversedStates >= stateLimit),
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/build-graph.js
CHANGED
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
// the ENTIRE app froze on big repos. The worker writes graph.json itself and returns only counts +
|
|
7
7
|
// summaries. If the worker can't even start (exotic packaging), we fall back to the in-process build
|
|
8
8
|
// rather than lose the feature.
|
|
9
|
-
import { existsSync,
|
|
10
|
-
import { join } from "node:path";
|
|
9
|
+
import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
11
|
import { Worker } from "node:worker_threads";
|
|
12
12
|
import { childProcessEnv } from "./child-env.js";
|
|
13
|
-
import { graphOutDirForRepo, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
13
|
+
import { graphHomeDir, graphOutDirForRepo, graphStorageKey, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
14
|
+
import { registerRepository } from "./graph/repo-registry.js";
|
|
15
|
+
import { refreshGraphIncrementally, snapshotRepository } from "./graph/incremental-refresh.js";
|
|
16
|
+
import { atomicWriteFileSync, withFileLock } from "./graph/file-lock.js";
|
|
17
|
+
import { repositoryFreshnessProbe, stampRepositoryFreshness } from "./graph/freshness-probe.js";
|
|
14
18
|
|
|
15
19
|
// The worker path deadlocks web-tree-sitter's WASM in Electron's worker threads (fine in plain Node) — off
|
|
16
20
|
// until that's Electron-safe. In-process + event-loop yielding keeps the window responsive without it.
|
|
@@ -54,23 +58,66 @@ function buildGraphInWorker(payload) {
|
|
|
54
58
|
|
|
55
59
|
async function buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central }) {
|
|
56
60
|
const { buildInternalGraph } = await import("./graph/internal-builder.js");
|
|
57
|
-
|
|
61
|
+
// Capture the cheap Git state on both sides of the authoritative build. Only an unchanged pair is
|
|
62
|
+
// safe to persist: if the working tree moves while parsing, the next process must take the slow path.
|
|
63
|
+
const probeBefore = scope ? null : repositoryFreshnessProbe(repoPath);
|
|
64
|
+
let graph;
|
|
65
|
+
let refresh = null;
|
|
66
|
+
if (mode === "full" && !scope && existsSync(graphJson)) {
|
|
67
|
+
try {
|
|
68
|
+
const existing = JSON.parse(readFileSync(graphJson, "utf8"));
|
|
69
|
+
if (existing.graphBuildMode === "full" && !existing.graphBuildScope) {
|
|
70
|
+
refresh = await refreshGraphIncrementally(repoPath, existing, { buildGraph: buildInternalGraph });
|
|
71
|
+
graph = refresh.graph;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* malformed/legacy graph: the safe path below is a full rebuild */ }
|
|
74
|
+
}
|
|
75
|
+
if (!graph && mode !== "full" && !scope && existsSync(graphJson)) {
|
|
76
|
+
try {
|
|
77
|
+
const existing = JSON.parse(readFileSync(graphJson, "utf8"));
|
|
78
|
+
if (existing.graphBuildMode === mode && existing.graphRevision) {
|
|
79
|
+
const snapshot = snapshotRepository(repoPath);
|
|
80
|
+
if (snapshot.revision === existing.graphRevision) {
|
|
81
|
+
graph = existing;
|
|
82
|
+
refresh = { kind: "none", changedFiles: [], reason: "content-unchanged", revision: snapshot.revision };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch { /* malformed/legacy filtered graph: rebuild below */ }
|
|
86
|
+
}
|
|
87
|
+
if (!graph) {
|
|
88
|
+
graph = await buildInternalGraph(repoPath);
|
|
89
|
+
refresh = { kind: "full", changedFiles: [], reason: "full-build-requested", revision: graph.graphRevision };
|
|
90
|
+
}
|
|
58
91
|
// mode (no-tests/tests-only) + path-scope reuse the same pure filters graph-builder used.
|
|
59
|
-
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode);
|
|
92
|
+
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode, { repoRoot: repoPath });
|
|
60
93
|
if (scope) graph = filterGraphByScope(graph, scope);
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
graph.graphBuildMode = mode;
|
|
95
|
+
graph.graphBuildScope = scope || "";
|
|
96
|
+
const probeAfter = scope ? null : repositoryFreshnessProbe(repoPath);
|
|
97
|
+
const stableProbe = probeBefore && probeAfter === probeBefore ? probeAfter : null;
|
|
98
|
+
const freshnessMetadataChanged = stampRepositoryFreshness(graph, stableProbe, mode);
|
|
99
|
+
// Auto-refresh runs before every graph/health call. A no-op must not serialize and rewrite a
|
|
100
|
+
// multi-megabyte graph merely to answer that nothing changed (or manufacture a newer mtime).
|
|
101
|
+
// A one-time metadata-only write is intentional for legacy graphs so the next MCP process can use
|
|
102
|
+
// the persisted probe instead of repeating a full repository snapshot.
|
|
103
|
+
if (refresh?.kind !== "none" || freshnessMetadataChanged) {
|
|
104
|
+
mkdirSync(central, { recursive: true });
|
|
105
|
+
atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
106
|
+
}
|
|
63
107
|
return {
|
|
64
108
|
nodes: graph.nodes.length,
|
|
65
109
|
links: graph.links.length,
|
|
66
110
|
communities: summarizeCommunities(graphJson),
|
|
67
111
|
hotspots: summarizeHotspots(graphJson),
|
|
112
|
+
refresh,
|
|
68
113
|
};
|
|
69
114
|
}
|
|
70
115
|
|
|
71
|
-
export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", outDir } = {}) {
|
|
116
|
+
export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", outDir, graphHome } = {}) {
|
|
72
117
|
if (!existsSync(repoPath)) return { ok: false, error: "Repo path not found", builder: "internal" };
|
|
73
|
-
const
|
|
118
|
+
const registryHome = graphHome || graphHomeDir();
|
|
119
|
+
const canonicalDir = graphHome ? join(registryHome, graphStorageKey(repoPath)) : graphOutDirForRepo(repoPath);
|
|
120
|
+
const central = outDir || canonicalDir;
|
|
74
121
|
const graphJson = join(central, "graph.json");
|
|
75
122
|
try {
|
|
76
123
|
// In-process is the ONLY path now. The worker (buildGraphInWorker) hung web-tree-sitter's WASM inside an
|
|
@@ -78,12 +125,23 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
|
|
|
78
125
|
// in ~3s on the main thread (and in a plain-Node worker). buildInternalGraph now YIELDS the event loop
|
|
79
126
|
// between file chunks, so the main-thread parse no longer freezes the window — the reason the worker was
|
|
80
127
|
// introduced. buildGraphInWorker/USE_BUILD_WORKER are kept for a future Electron-safe re-enable.
|
|
81
|
-
const
|
|
82
|
-
?
|
|
128
|
+
const build = () => USE_BUILD_WORKER
|
|
129
|
+
? buildGraphInWorker({ repoPath, mode, scope, graphJson, central }).catch((e) => {
|
|
83
130
|
if (e && e.workerStartFailed) return buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
|
|
84
131
|
throw e;
|
|
85
132
|
})
|
|
86
|
-
:
|
|
133
|
+
: buildAndWriteInProcess(repoPath, { mode, scope, graphJson, central });
|
|
134
|
+
// Canonical graphs are shared by all local MCP clients. Serialize the complete read/refresh/write
|
|
135
|
+
// transaction so an older process cannot overwrite a newer incremental result.
|
|
136
|
+
const canonical = !scope && resolve(central) === resolve(canonicalDir);
|
|
137
|
+
const built = canonical
|
|
138
|
+
? await withFileLock(join(central, ".graph.lock"), build, { timeoutMs: 5 * 60_000, staleMs: 10 * 60_000 })
|
|
139
|
+
: await build();
|
|
140
|
+
// Scoped builds are disposable diagnostics under modules/<scope>; registering one would retarget
|
|
141
|
+
// list_known_repos and cross-repo analysis away from the complete canonical graph.
|
|
142
|
+
if (canonical) {
|
|
143
|
+
registerRepository({ repoPath, graphDir: central, graphHome: registryHome });
|
|
144
|
+
}
|
|
87
145
|
return {
|
|
88
146
|
ok: true,
|
|
89
147
|
builder: "internal",
|
|
@@ -95,7 +153,8 @@ export async function buildGraphForRepo(repoPath, { mode = "full", scope = "", o
|
|
|
95
153
|
graphDir: central,
|
|
96
154
|
communities: built.communities,
|
|
97
155
|
hotspots: built.hotspots,
|
|
98
|
-
|
|
156
|
+
refresh: built.refresh,
|
|
157
|
+
log: `built-in builder: ${built.nodes} nodes, ${built.links} links (${built.refresh?.kind || "full"}: ${built.refresh?.reason || "build"})`
|
|
99
158
|
};
|
|
100
159
|
} catch (error) {
|
|
101
160
|
return { ok: false, error: `graph build failed: ${error.message}`, builder: "internal" };
|
|
@@ -5,18 +5,17 @@
|
|
|
5
5
|
// result object crosses back (never the graph itself — structured-cloning a huge graph would stall
|
|
6
6
|
// the main thread again).
|
|
7
7
|
import { parentPort, workerData } from "node:worker_threads";
|
|
8
|
-
import { writeFileSync, mkdirSync } from "node:fs";
|
|
9
8
|
import { buildInternalGraph } from "./internal-builder.js";
|
|
10
9
|
import { filterGraphForMode, filterGraphByScope, summarizeCommunities, summarizeHotspots } from "./layout.js";
|
|
10
|
+
import { atomicWriteFileSync } from "./file-lock.js";
|
|
11
11
|
|
|
12
12
|
(async () => {
|
|
13
13
|
const { repoPath, mode, scope, graphJson, central } = workerData || {};
|
|
14
14
|
try {
|
|
15
15
|
let graph = await buildInternalGraph(repoPath);
|
|
16
|
-
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode);
|
|
16
|
+
if (mode === "no-tests" || mode === "tests-only") graph = filterGraphForMode(graph, mode, { repoRoot: repoPath });
|
|
17
17
|
if (scope) graph = filterGraphByScope(graph, scope);
|
|
18
|
-
|
|
19
|
-
writeFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
18
|
+
atomicWriteFileSync(graphJson, JSON.stringify(graph), "utf8");
|
|
20
19
|
parentPort.postMessage({
|
|
21
20
|
ok: true,
|
|
22
21
|
nodes: graph.nodes.length,
|