weavatrix 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -17
- package/package.json +1 -1
- package/skill/SKILL.md +62 -4
- package/src/analysis/dead-check.js +87 -6
- package/src/analysis/dep-check-ecosystems.js +157 -0
- package/src/analysis/dep-check.js +97 -133
- package/src/analysis/dep-rules.js +91 -12
- package/src/analysis/duplicates.compute.js +12 -18
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +56 -6
- package/src/analysis/graph-analysis.aggregate.js +34 -25
- package/src/analysis/graph-analysis.edges.js +21 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/internal-audit.collect.js +162 -25
- package/src/analysis/internal-audit.reach.js +52 -11
- package/src/analysis/internal-audit.run.js +71 -18
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +66 -23
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +17 -0
- package/src/graph/internal-builder.build.js +79 -17
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +43 -2
- package/src/graph/internal-builder.resolvers.js +197 -21
- package/src/graph/relations.js +4 -0
- package/src/mcp/catalog.mjs +4 -4
- package/src/mcp/graph-context.mjs +22 -94
- package/src/mcp/graph-diff.mjs +163 -0
- package/src/mcp/sync-payload.mjs +36 -6
- package/src/mcp/tools-actions.mjs +22 -7
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +13 -22
- package/src/mcp/tools-health.mjs +54 -18
- package/src/mcp/tools-impact.mjs +61 -45
- package/src/mcp-server.mjs +3 -0
- package/src/security/advisory-store.js +51 -7
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const RUST_ROUTE_METHOD = "get|post|put|patch|delete|head|options|trace|connect|any";
|
|
2
|
+
|
|
3
|
+
// Find a call's closing parenthesis without mistaking parens inside strings for syntax. This is deliberately
|
|
4
|
+
// small (not a Rust parser), but keeps route-handler expressions bounded instead of using a cross-statement
|
|
5
|
+
// regex that could turn an unrelated function call into a route.
|
|
6
|
+
function closingParen(text, openAt) {
|
|
7
|
+
let depth = 0, quote = "", escaped = false, blockComment = 0;
|
|
8
|
+
for (let i = openAt; i < text.length; i++) {
|
|
9
|
+
const ch = text[i];
|
|
10
|
+
if (quote) {
|
|
11
|
+
if (escaped) escaped = false;
|
|
12
|
+
else if (ch === "\\") escaped = true;
|
|
13
|
+
else if (ch === quote) quote = "";
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
if (blockComment) {
|
|
17
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment++; i++; }
|
|
18
|
+
else if (ch === "*" && text[i + 1] === "/") { blockComment--; i++; }
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
22
|
+
const newline = text.indexOf("\n", i + 2);
|
|
23
|
+
if (newline < 0) return -1;
|
|
24
|
+
i = newline;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "/" && text[i + 1] === "*") { blockComment = 1; i++; continue; }
|
|
28
|
+
if (ch === '"') { quote = ch; continue; }
|
|
29
|
+
if (ch === "'") {
|
|
30
|
+
// Skip a Rust character literal, but do not mistake a lifetime (`'a`) for an unterminated string.
|
|
31
|
+
const char = /^'(?:\\.|[^\\'\r\n])'/.exec(text.slice(i));
|
|
32
|
+
if (char) { i += char[0].length - 1; continue; }
|
|
33
|
+
}
|
|
34
|
+
if (ch === "(") depth++;
|
|
35
|
+
else if (ch === ")" && --depth === 0) return i;
|
|
36
|
+
}
|
|
37
|
+
return -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse axum's MethodRouter expression, including direct method chains:
|
|
41
|
+
// get(list).post(create) / axum::routing::get(show).delete(remove)
|
|
42
|
+
// Calls inside a handler closure are not scanned and therefore cannot become fake endpoint methods.
|
|
43
|
+
function axumRoutes(expr) {
|
|
44
|
+
const found = [];
|
|
45
|
+
let rest = String(expr || ""), offset = 0;
|
|
46
|
+
const firstRe = new RegExp(`^\\s*(?:(?:axum\\s*::\\s*)?routing\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(`, "i");
|
|
47
|
+
let match = firstRe.exec(rest);
|
|
48
|
+
if (!match) return found;
|
|
49
|
+
|
|
50
|
+
while (match) {
|
|
51
|
+
const openAt = offset + match.index + match[0].lastIndexOf("(");
|
|
52
|
+
const closeAt = closingParen(expr, openAt);
|
|
53
|
+
if (closeAt < 0) break;
|
|
54
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: openAt });
|
|
55
|
+
offset = closeAt + 1;
|
|
56
|
+
rest = expr.slice(offset);
|
|
57
|
+
match = new RegExp(`^\\s*\\.\\s*(${RUST_ROUTE_METHOD})\\s*\\(`, "i").exec(rest);
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Actix's builder form is structurally distinct from axum and from HTTP clients:
|
|
63
|
+
// web::get().to(handler) / actix_web::web::post().to(handler)
|
|
64
|
+
function actixRoutes(expr) {
|
|
65
|
+
const found = [];
|
|
66
|
+
const re = new RegExp(`(?:actix_web\\s*::\\s*)?web\\s*::\\s*(${RUST_ROUTE_METHOD})\\s*\\(\\s*\\)\\s*\\.\\s*to\\s*\\(`, "gi");
|
|
67
|
+
let match;
|
|
68
|
+
while ((match = re.exec(expr))) {
|
|
69
|
+
const openAt = re.lastIndex - 1;
|
|
70
|
+
const closeAt = closingParen(expr, openAt);
|
|
71
|
+
if (closeAt < 0) continue;
|
|
72
|
+
found.push({ method: match[1], handler: expr.slice(openAt + 1, closeAt), index: match.index });
|
|
73
|
+
re.lastIndex = closeAt + 1;
|
|
74
|
+
}
|
|
75
|
+
return found;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function extractRustEndpoints(text, add) {
|
|
79
|
+
let match;
|
|
80
|
+
|
|
81
|
+
// Axum and actix both expose a path-first `.route(path, ...)` builder. Requiring either an axum
|
|
82
|
+
// MethodRouter or actix's web::<method>().to(...) avoids treating arbitrary Rust APIs named `route` as HTTP.
|
|
83
|
+
const routeRe = /\.\s*route\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*,/g;
|
|
84
|
+
while ((match = routeRe.exec(text))) {
|
|
85
|
+
const openAt = text.indexOf("(", match.index);
|
|
86
|
+
const closeAt = closingParen(text, openAt);
|
|
87
|
+
if (closeAt < 0) continue;
|
|
88
|
+
const exprStart = routeRe.lastIndex;
|
|
89
|
+
const expr = text.slice(exprStart, closeAt);
|
|
90
|
+
const routes = [...axumRoutes(expr), ...actixRoutes(expr)];
|
|
91
|
+
for (const route of routes) add(route.method, match[1], route.handler, exprStart + route.index);
|
|
92
|
+
routeRe.lastIndex = closeAt + 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// actix `web::resource(path)` associates several method routes with one path.
|
|
96
|
+
const resourceRe = /(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(\s*"(\/(?:\\.|[^"\\])*)"\s*\)/g;
|
|
97
|
+
while ((match = resourceRe.exec(text))) {
|
|
98
|
+
const tail = text.slice(resourceRe.lastIndex);
|
|
99
|
+
const nextResource = tail.search(/(?:actix_web\s*::\s*)?web\s*::\s*resource\s*\(/);
|
|
100
|
+
const semicolon = text.indexOf(";", resourceRe.lastIndex);
|
|
101
|
+
let end = Math.min(text.length, resourceRe.lastIndex + 4000);
|
|
102
|
+
if (nextResource >= 0) end = Math.min(end, resourceRe.lastIndex + nextResource);
|
|
103
|
+
if (semicolon >= 0) end = Math.min(end, semicolon);
|
|
104
|
+
const chain = text.slice(resourceRe.lastIndex, end);
|
|
105
|
+
for (const route of actixRoutes(chain)) add(route.method, match[1], route.handler, resourceRe.lastIndex + route.index);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Actix attribute macros. The route macro can declare more than one method for the same handler.
|
|
109
|
+
const macroRe = new RegExp(`#\\s*\\[\\s*(?:(?:actix_web|actix)\\s*::\\s*)?(${RUST_ROUTE_METHOD})\\s*\\(\\s*"(\\/(?:\\\\.|[^"\\\\])*)"(?:\\s*,[^\\]]*)?\\s*\\)\\s*\\]`, "gi");
|
|
110
|
+
while ((match = macroRe.exec(text))) {
|
|
111
|
+
const after = text.slice(macroRe.lastIndex, macroRe.lastIndex + 600);
|
|
112
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
113
|
+
add(match[1], match[2], fn ? fn[1] : "", match.index);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const multiMacroRe = /#\s*\[\s*(?:(?:actix_web|actix)\s*::\s*)?route\s*\(\s*"(\/(?:\\.|[^"\\])*)"([\s\S]*?)\)\s*\]/gi;
|
|
117
|
+
while ((match = multiMacroRe.exec(text))) {
|
|
118
|
+
const after = text.slice(multiMacroRe.lastIndex, multiMacroRe.lastIndex + 600);
|
|
119
|
+
const fn = /\b(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_][\w]*)/.exec(after);
|
|
120
|
+
const methodRe = /\bmethod\s*=\s*"(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE|CONNECT)"/gi;
|
|
121
|
+
let method;
|
|
122
|
+
while ((method = methodRe.exec(match[2]))) add(method[1], match[1], fn ? fn[1] : "", match.index);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
// endpoints.js — detect the repo's OWN HTTP API surface (routes it EXPOSES), so the Health tab can
|
|
2
2
|
// surface endpoints the way infra towers surface the services a repo TALKS TO. Each endpoint carries a
|
|
3
3
|
// best-effort handler NAME so the UI can join it to that method's health (O(n) complexity, criticality,
|
|
4
|
-
// coverage). Covers the common shapes across JS/TS, Python and
|
|
4
|
+
// coverage). Covers the common shapes across JS/TS, Python, Go and Rust:
|
|
5
5
|
// • object routes (Bun.serve / custom): "/path": { GET: handler, POST: fn } | "/path": handler
|
|
6
6
|
// • method-call routes (Express/Fastify/Koa/Hono/gin/echo): app.get("/path", handler)
|
|
7
7
|
// • decorators (FastAPI/Flask/NestJS): @app.get("/path") / @Get("/path")
|
|
8
8
|
// • Go net/http: mux.HandleFunc("/path", handler)
|
|
9
|
+
// • Rust axum/actix-web: .route("/path", get(handler)) / #[get("/path")]
|
|
9
10
|
import { safeRead } from "../util.js";
|
|
10
11
|
import { createRepoBoundary } from "../repo-path.js";
|
|
12
|
+
import { extractRustEndpoints } from "./endpoints-rust.js";
|
|
11
13
|
|
|
12
14
|
const MAX_FILES = 3000;
|
|
13
|
-
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "ALL", "ANY"]);
|
|
15
|
+
const HTTP_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT", "ALL", "ANY"]);
|
|
14
16
|
// UNAMBIGUOUS HTTP CLIENTS (make requests) vs servers (define routes) — reject `axios.get("/x")`-style client
|
|
15
17
|
// calls in frontend code. Ambiguous names (api/client/service — could be a server router) are NOT listed;
|
|
16
18
|
// they're filtered by the handler requirement instead (a client call has no handler / a config object arg).
|
|
@@ -31,7 +33,9 @@ function lineAt(text, index) {
|
|
|
31
33
|
function handlerName(expr) {
|
|
32
34
|
const s = String(expr || "").trim();
|
|
33
35
|
if (!s) return "";
|
|
34
|
-
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s)) return "";
|
|
36
|
+
if (/=>/.test(s) || /^\s*(async\s+)?function\b/.test(s) || /^\s*(?:async\s+)?(?:move\s+)?\|[^|]*\|/.test(s)) return "";
|
|
37
|
+
const turbofish = /(?:^|::)([A-Za-z_][\w]*)\s*::<[\s\S]*>\s*$/.exec(s);
|
|
38
|
+
if (turbofish) return turbofish[1];
|
|
35
39
|
const ids = s.match(/[A-Za-z_$][\w$]*/g);
|
|
36
40
|
if (!ids) return "";
|
|
37
41
|
const SKIP = new Set(["async", "function", "await", "req", "res", "ctx", "request", "response", "next", "return"]);
|
|
@@ -44,12 +48,31 @@ function handlerName(expr) {
|
|
|
44
48
|
// endpoints (handler "operation", {id} params). These keys never appear in a real route/handler table.
|
|
45
49
|
const OPENAPI_BLOCK = /\boperationId\b|\bresponses\s*:|\brequestBody\b|\bschemaRef\b|\boperation\s*\(|\bsummary\s*:/;
|
|
46
50
|
|
|
47
|
-
const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}
|
|
51
|
+
const looksLikePath = (p) => typeof p === "string" && /^\/[\w\-./:{}*$?]*$/.test(p) && !p.includes("://");
|
|
48
52
|
const cleanPath = (p) => String(p || "").replace(/\/+$/, "") || "/";
|
|
49
53
|
|
|
54
|
+
export function nextRoutePath(file) {
|
|
55
|
+
const parts = String(file || "").replace(/\\/g, "/").split("/").filter(Boolean);
|
|
56
|
+
if (!/^route\.[cm]?[jt]s$/i.test(parts.at(-1) || "")) return "";
|
|
57
|
+
const appAt = parts.lastIndexOf("app");
|
|
58
|
+
if (appAt < 0) return "";
|
|
59
|
+
const route = [];
|
|
60
|
+
for (let segment of parts.slice(appAt + 1, -1)) {
|
|
61
|
+
if (!segment || /^\([^)]*\)$/.test(segment) || segment.startsWith("@")) continue; // route groups / parallel slots
|
|
62
|
+
segment = segment.replace(/^\((?:\.{1,3})\)/, ""); // intercepting-route marker
|
|
63
|
+
let m;
|
|
64
|
+
if ((m = /^\[\[\.\.\.([^\]]+)\]\]$/.exec(segment))) segment = `*${m[1]}?`;
|
|
65
|
+
else if ((m = /^\[\.\.\.([^\]]+)\]$/.exec(segment))) segment = `*${m[1]}`;
|
|
66
|
+
else if ((m = /^\[([^\]]+)\]$/.exec(segment))) segment = `:${m[1]}`;
|
|
67
|
+
if (segment) route.push(segment);
|
|
68
|
+
}
|
|
69
|
+
return `/${route.join("/")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
50
72
|
export function extractEndpointsFromText(text, file) {
|
|
51
73
|
const out = [];
|
|
52
74
|
const py = /\.py$/i.test(file);
|
|
75
|
+
const rust = /\.rs$/i.test(file);
|
|
53
76
|
const add = (method, path, expr, idx) => {
|
|
54
77
|
const p = cleanPath(path);
|
|
55
78
|
if (!looksLikePath(p)) return;
|
|
@@ -58,6 +81,33 @@ export function extractEndpointsFromText(text, file) {
|
|
|
58
81
|
out.push({ method: m, path: p, handler: handlerName(expr), file, line: lineAt(text, idx) });
|
|
59
82
|
};
|
|
60
83
|
|
|
84
|
+
// Next.js App Router: the filesystem provides the path and exported HTTP-method functions provide the
|
|
85
|
+
// verbs. No literal route string exists in route.ts, so generic Express/FastAPI regexes cannot see it.
|
|
86
|
+
const nextPath = nextRoutePath(file);
|
|
87
|
+
if (nextPath) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const direct = /\bexport\s+(?:(?:async|declare)\s+)*(?:function\s+|(?:const|let|var)\s+)(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/g;
|
|
90
|
+
let nm;
|
|
91
|
+
while ((nm = direct.exec(text))) {
|
|
92
|
+
const method = nm[1].toUpperCase();
|
|
93
|
+
if (!seen.has(method)) { seen.add(method); add(method, nextPath, method, nm.index); }
|
|
94
|
+
}
|
|
95
|
+
const lists = /\bexport\s*\{([^}]+)\}/g;
|
|
96
|
+
let lm;
|
|
97
|
+
while ((lm = lists.exec(text))) {
|
|
98
|
+
for (const item of lm[1].split(",")) {
|
|
99
|
+
const mm = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS))?\s*$/.exec(item);
|
|
100
|
+
if (!mm) continue;
|
|
101
|
+
if (!mm[2] && !HTTP_METHODS.has(mm[1])) continue;
|
|
102
|
+
const method = String(mm[2] || mm[1]).toUpperCase();
|
|
103
|
+
if (!HTTP_METHODS.has(method)) continue;
|
|
104
|
+
if (!seen.has(method)) { seen.add(method); add(method, nextPath, mm[1], lm.index); }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (rust) extractRustEndpoints(text, add);
|
|
110
|
+
|
|
61
111
|
// ---- object routes: "/path": { GET: fn, POST: fn2 } or "/path": handler --------------------
|
|
62
112
|
// find each "…": { or "…": expr, where the key looks like a path
|
|
63
113
|
const objKeyRe = /(["'`])(\/[^"'`]*)\1\s*:\s*(\{)?/g;
|
|
@@ -128,11 +178,11 @@ export function detectEndpoints(repoPath, codeFiles) {
|
|
|
128
178
|
const boundary = createRepoBoundary(repoPath);
|
|
129
179
|
for (const f of files) {
|
|
130
180
|
const rel = f.path || f;
|
|
131
|
-
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go)$/i.test(rel)) continue;
|
|
181
|
+
if (!/\.(js|ts|tsx|jsx|cjs|mjs|py|go|rs)$/i.test(rel)) continue;
|
|
132
182
|
const resolved = boundary.resolve(rel);
|
|
133
183
|
if (!resolved.ok) continue;
|
|
134
184
|
const text = safeRead(resolved.path);
|
|
135
|
-
if (!text || !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text)) continue;
|
|
185
|
+
if (!text || (!nextRoutePath(rel) && !/["'`]\/|\.(get|post|put|patch|delete)\s*\(|HandleFunc|@\w*\.?(get|post|put|patch|delete)/i.test(text))) continue;
|
|
136
186
|
for (const e of extractEndpointsFromText(text, rel.replace(/\\/g, "/"))) {
|
|
137
187
|
const key = `${e.method} ${normParamKey(e.path)}`;
|
|
138
188
|
const prev = byKey.get(key);
|
|
@@ -6,6 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import { normalizeRepoParts, readCoverageForRepo, pctFromCounts } from "./coverage-reports.js";
|
|
7
7
|
import { bareSymbolName, countLocalRefsOutsideOwnRange, computeSymbolExternalRefs } from "./graph-analysis.refs.js";
|
|
8
8
|
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { edgeList, folderModuleOf } from "./graph-analysis.edges.js";
|
|
10
|
+
export { folderModuleOf } from "./graph-analysis.edges.js";
|
|
9
11
|
|
|
10
12
|
// Aggregate a built graph.json into the file- and module-level view the UI needs:
|
|
11
13
|
// - graph-builder nodes are FILES *and* their symbols (functions/methods), linked file→symbol by the
|
|
@@ -13,7 +15,6 @@ import { createRepoBoundary } from "../repo-path.js";
|
|
|
13
15
|
// - Each file is assigned to ONE module via its file-node's community (symbols may cluster apart,
|
|
14
16
|
// but the file itself belongs where its file-node sits) → exact, non-overlapping file counts.
|
|
15
17
|
// - Real edges (everything except "contains") roll up to file→file and module→module relations.
|
|
16
|
-
// Module names match summarizeCommunities (dominant first-two-path-parts), so they line up with cards.
|
|
17
18
|
export function analyzeGraph(graphJsonPath, repoRoot) {
|
|
18
19
|
let graph;
|
|
19
20
|
try {
|
|
@@ -40,15 +41,11 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
40
41
|
// MODULE = the file's own top FOLDER (its "territory"), up to 2 path levels — NOT a dependency
|
|
41
42
|
// cluster. e.g. src/widget/foo.js → "src/widget"; benchmarks/cleanup.py → "benchmarks" (so all
|
|
42
43
|
// benchmarks files are one module); a top-level file like index.js → "(root)".
|
|
43
|
-
const folderOf = (file) => {
|
|
44
|
-
const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
|
|
45
|
-
return dirs.length ? dirs.slice(0, 2).join("/") : "(root)";
|
|
46
|
-
};
|
|
47
44
|
// In a merged (cross-repo) graph every node carries a `repo`; qualify file & module identity by it
|
|
48
45
|
// so same-named folders/files in different repos never merge. Single-repo graphs have no `repo`,
|
|
49
46
|
// so identity stays the bare path (unchanged behavior).
|
|
50
47
|
const fileIdOf = (node) => (node.repo ? `${node.repo}::${node.source_file}` : node.source_file);
|
|
51
|
-
const moduleOfNode = (node) => (node.repo ? `${node.repo}/` : "") +
|
|
48
|
+
const moduleOfNode = (node) => (node.repo ? `${node.repo}/` : "") + folderModuleOf(node.source_file);
|
|
52
49
|
|
|
53
50
|
// file → module (folder) + symbols + display path, all keyed by repo-qualified file id
|
|
54
51
|
const fileModule = new Map();
|
|
@@ -100,38 +97,38 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
100
97
|
for (const node of nodes) if (node.source_file) id2file.set(node.id, fileIdOf(node));
|
|
101
98
|
const fileEdges = new Map(); // key → { count, rels:{relation→n} } so we can emit the DOMINANT relation (call/import/inherit)
|
|
102
99
|
const moduleEdges = new Map();
|
|
100
|
+
const typeOnlyFileEdges = new Map();
|
|
101
|
+
const typeOnlyModuleEdges = new Map();
|
|
102
|
+
const compileOnlyFileEdges = new Map();
|
|
103
|
+
const compileOnlyModuleEdges = new Map();
|
|
104
|
+
const compileTimeFileEdges = new Map();
|
|
105
|
+
const compileTimeModuleEdges = new Map();
|
|
106
|
+
const addFileEdge = (map, key, link) => {
|
|
107
|
+
let edge = map.get(key);
|
|
108
|
+
if (!edge) map.set(key, (edge = { count: 0, rels: {} }));
|
|
109
|
+
edge.count++;
|
|
110
|
+
if (link.relation) edge.rels[link.relation] = (edge.rels[link.relation] || 0) + 1;
|
|
111
|
+
};
|
|
103
112
|
for (const link of links) {
|
|
104
113
|
if (link.relation === "contains") continue;
|
|
105
114
|
const fromFile = id2file.get(endpoint(link.source));
|
|
106
115
|
const toFile = id2file.get(endpoint(link.target));
|
|
107
116
|
if (fromFile && toFile && fromFile !== toFile) {
|
|
108
117
|
const key = `${fromFile} ${toFile}`;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
118
|
+
const compileTime = link.typeOnly === true || link.compileOnly === true;
|
|
119
|
+
const targetFileEdges = link.typeOnly === true ? typeOnlyFileEdges : link.compileOnly === true ? compileOnlyFileEdges : fileEdges;
|
|
120
|
+
addFileEdge(targetFileEdges, key, link);
|
|
121
|
+
if (compileTime) addFileEdge(compileTimeFileEdges, key, link);
|
|
113
122
|
const fromMod = fileModule.get(fromFile);
|
|
114
123
|
const toMod = fileModule.get(toFile);
|
|
115
124
|
if (fromMod && toMod && fromMod !== toMod) {
|
|
116
125
|
const mkey = `${fromMod} ${toMod}`;
|
|
117
|
-
|
|
126
|
+
const targetModuleEdges = link.typeOnly === true ? typeOnlyModuleEdges : link.compileOnly === true ? compileOnlyModuleEdges : moduleEdges;
|
|
127
|
+
targetModuleEdges.set(mkey, (targetModuleEdges.get(mkey) || 0) + 1);
|
|
128
|
+
if (compileTime) compileTimeModuleEdges.set(mkey, (compileTimeModuleEdges.get(mkey) || 0) + 1);
|
|
118
129
|
}
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
|
-
const split = (key) => {
|
|
122
|
-
const i = key.indexOf(" ");
|
|
123
|
-
return [key.slice(0, i), key.slice(i + 1)];
|
|
124
|
-
};
|
|
125
|
-
const edgeList = (map) =>
|
|
126
|
-
[...map.entries()]
|
|
127
|
-
.map(([key, v]) => {
|
|
128
|
-
const [from, to] = split(key);
|
|
129
|
-
if (typeof v === "number") return { from, to, count: v }; // moduleEdges (no relation breakdown)
|
|
130
|
-
const dom = Object.entries(v.rels).sort((a, b) => b[1] - a[1])[0];
|
|
131
|
-
return { from, to, count: v.count, relation: dom ? dom[0] : null };
|
|
132
|
-
})
|
|
133
|
-
.sort((a, b) => b.count - a.count);
|
|
134
|
-
|
|
135
132
|
// symbol-level (function/method) call graph: edges between symbol nodes (calls/method), each symbol
|
|
136
133
|
// tagged with its file's module so the Symbols view can still cluster into module regions.
|
|
137
134
|
const symEdges = new Map();
|
|
@@ -263,7 +260,13 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
263
260
|
}))
|
|
264
261
|
.sort((a, b) => b.fileCount - a.fileCount),
|
|
265
262
|
moduleEdges: edgeList(moduleEdges),
|
|
263
|
+
typeOnlyModuleEdges: edgeList(typeOnlyModuleEdges),
|
|
264
|
+
compileOnlyModuleEdges: edgeList(compileOnlyModuleEdges),
|
|
265
|
+
compileTimeModuleEdges: edgeList(compileTimeModuleEdges),
|
|
266
266
|
fileEdges: edgeList(fileEdges),
|
|
267
|
+
typeOnlyFileEdges: edgeList(typeOnlyFileEdges),
|
|
268
|
+
compileOnlyFileEdges: edgeList(compileOnlyFileEdges),
|
|
269
|
+
compileTimeFileEdges: edgeList(compileTimeFileEdges),
|
|
267
270
|
symbols,
|
|
268
271
|
symbolEdges,
|
|
269
272
|
symbolRefs: [...new Set([...symbolLocalRefs.keys(), ...symbolExternalRefs.keys()])].map((id) => {
|
|
@@ -276,7 +279,13 @@ export function aggregateGraph(graph, repoRoot) {
|
|
|
276
279
|
files: fileModule.size,
|
|
277
280
|
nodes: nodes.filter((n) => n.file_type === "code").length,
|
|
278
281
|
fileEdges: fileEdges.size,
|
|
282
|
+
typeOnlyFileEdges: typeOnlyFileEdges.size,
|
|
283
|
+
compileOnlyFileEdges: compileOnlyFileEdges.size,
|
|
284
|
+
compileTimeFileEdges: compileTimeFileEdges.size,
|
|
279
285
|
moduleEdges: moduleEdges.size,
|
|
286
|
+
typeOnlyModuleEdges: typeOnlyModuleEdges.size,
|
|
287
|
+
compileOnlyModuleEdges: compileOnlyModuleEdges.size,
|
|
288
|
+
compileTimeModuleEdges: compileTimeModuleEdges.size,
|
|
280
289
|
symbols: symbols.length,
|
|
281
290
|
symbolEdges: symbolEdges.length
|
|
282
291
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Stable folder territories used by module_map. Nested package source roots keep the first directory below
|
|
2
|
+
// `src`; otherwise a monorepo's entire crate/app would collapse into one module with no visible dependencies.
|
|
3
|
+
export function folderModuleOf(file) {
|
|
4
|
+
const dirs = String(file || "").split(/[\\/]/).filter(Boolean).slice(0, -1);
|
|
5
|
+
if (!dirs.length) return "(root)";
|
|
6
|
+
const sourceRoot = dirs.lastIndexOf("src");
|
|
7
|
+
if (sourceRoot >= 0 && dirs.length > sourceRoot + 1) return dirs.slice(0, sourceRoot + 2).join("/");
|
|
8
|
+
return dirs.slice(0, 2).join("/");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function edgeList(map) {
|
|
12
|
+
return [...map.entries()]
|
|
13
|
+
.map(([key, value]) => {
|
|
14
|
+
const splitAt = key.indexOf(" ");
|
|
15
|
+
const from = key.slice(0, splitAt), to = key.slice(splitAt + 1);
|
|
16
|
+
if (typeof value === "number") return { from, to, count: value };
|
|
17
|
+
const dominant = Object.entries(value.rels).sort((a, b) => b[1] - a[1])[0];
|
|
18
|
+
return { from, to, count: value.count, relation: dominant ? dominant[0] : null };
|
|
19
|
+
})
|
|
20
|
+
.sort((a, b) => b.count - a.count);
|
|
21
|
+
}
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
// Facade: implementation lives in graph-analysis.summaries.js and graph-analysis.aggregate.js
|
|
5
5
|
// (with internal helpers in graph-analysis.refs.js).
|
|
6
6
|
export { summarizeCommunities, summarizeHotspots } from "./graph-analysis.summaries.js";
|
|
7
|
-
export { analyzeGraph, aggregateGraph } from "./graph-analysis.aggregate.js";
|
|
7
|
+
export { analyzeGraph, aggregateGraph, folderModuleOf } from "./graph-analysis.aggregate.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Reading a built graph.json and summarizing it for the UI cards: named communities and degree
|
|
2
2
|
// hotspots. Both read the graph file from disk and return [] on any failure.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import { communityTerritoryOf } from "../graph/community.js";
|
|
5
|
+
import { isStructuralRelation } from "../graph/relations.js";
|
|
4
6
|
|
|
5
7
|
// graph-builder labels communities "Community N" without an LLM. Derive a real name from each
|
|
6
8
|
// community's dominant folder + sample files so the UI shows modules, not bare numbers.
|
|
@@ -16,7 +18,7 @@ export function summarizeCommunities(graphJsonPath, max = 40) {
|
|
|
16
18
|
const entry = byCommunity.get(community);
|
|
17
19
|
entry.size += 1;
|
|
18
20
|
const parts = String(node.source_file || "").split(/[\\/]/).filter(Boolean);
|
|
19
|
-
const dir =
|
|
21
|
+
const dir = communityTerritoryOf(node.source_file);
|
|
20
22
|
entry.dirs.set(dir, (entry.dirs.get(dir) || 0) + 1);
|
|
21
23
|
if (entry.files.length < 4) entry.files.push(parts[parts.length - 1] || node.source_file || "");
|
|
22
24
|
}
|
|
@@ -41,6 +43,7 @@ export function summarizeHotspots(graphJsonPath, max = 15) {
|
|
|
41
43
|
const inDeg = new Map();
|
|
42
44
|
const outDeg = new Map();
|
|
43
45
|
for (const link of graph.links || []) {
|
|
46
|
+
if (isStructuralRelation(link.relation)) continue;
|
|
44
47
|
const s = endpoint(link.source);
|
|
45
48
|
const t = endpoint(link.target);
|
|
46
49
|
outDeg.set(s, (outDeg.get(s) || 0) + 1);
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// internal-audit.collect.js — filesystem collection helpers for the internal audit: source/config
|
|
2
2
|
// text gathering, workspace package names, and the Python manifest reader. Split from internal-audit.js.
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
6
7
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
|
+
import { childProcessEnv } from "../child-env.js";
|
|
7
9
|
|
|
8
10
|
export const readText = (p) => { try { return readFileSync(p, "utf8"); } catch { return null; } };
|
|
9
11
|
export const readJson = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
@@ -18,10 +20,76 @@ export const readRepoJson = (boundary, relativePath) => {
|
|
|
18
20
|
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?|py|go|vue|svelte)$/i;
|
|
19
21
|
const SOURCE_SKIP_DIRS = new Set([
|
|
20
22
|
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build", "coverage", ".next", "out",
|
|
21
|
-
"
|
|
23
|
+
"release", "weavatrix-graphs", "__pycache__", ".venv", "venv", "env", ".tox", "site-packages",
|
|
22
24
|
".mypy_cache", ".pytest_cache",
|
|
23
25
|
]);
|
|
24
26
|
|
|
27
|
+
// One file universe for every filesystem-backed audit. In Git repos this exactly matches tracked files
|
|
28
|
+
// plus untracked/non-ignored work, so release bundles and other .gitignore outputs cannot re-enter through
|
|
29
|
+
// text/config/manifest fallback collectors after the graph builder correctly omitted them.
|
|
30
|
+
export function listRepoFiles(repoRoot) {
|
|
31
|
+
try {
|
|
32
|
+
const r = spawnSync("git", ["-C", repoRoot, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
|
|
33
|
+
encoding: "utf8", windowsHide: true, timeout: 15_000, maxBuffer: 32 * 1024 * 1024,
|
|
34
|
+
env: childProcessEnv(),
|
|
35
|
+
});
|
|
36
|
+
if (r.status === 0) return String(r.stdout || "").split("\0").filter(Boolean).map((f) => f.replace(/\\/g, "/"));
|
|
37
|
+
} catch { /* non-Git repo or git unavailable: use the bounded walker below */ }
|
|
38
|
+
|
|
39
|
+
const files = [];
|
|
40
|
+
const walk = (abs, parts = []) => {
|
|
41
|
+
let entries = [];
|
|
42
|
+
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
46
|
+
} else if (entry.isFile()) files.push([...parts, entry.name].join("/"));
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(repoRoot);
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const NON_RUNTIME_DIR_RE = /^(?:templates?|examples?|samples?|fixtures?|snippets?|__fixtures__)$/i;
|
|
54
|
+
const NON_RUNTIME_README_RE = /(?:\b(?:reusable|copyable|reference)\b[\s\S]{0,160}\b(?:templates?|snippets?|examples?|samples?)\b|\b(?:these|contents?)\s+are\s+templates?\b)/i;
|
|
55
|
+
const normConfiguredRoot = (value) => {
|
|
56
|
+
const root = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
57
|
+
if (!root || root === "." || root.split("/").some((part) => part === "..")) return "";
|
|
58
|
+
return root;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Runtime health findings should not treat copy-paste catalogs as deployed applications. Conventional
|
|
62
|
+
// template/example directories are safe to infer; a top-level custom catalog is inferred only when its
|
|
63
|
+
// own README explicitly describes reusable templates/snippets. Projects can declare additional roots in
|
|
64
|
+
// `.weavatrix-deps.json` through `nonRuntimeRoots` / `templateRoots`.
|
|
65
|
+
export function collectNonRuntimeRoots(repoRoot, rules = {}) {
|
|
66
|
+
const files = listRepoFiles(repoRoot);
|
|
67
|
+
const roots = new Set();
|
|
68
|
+
const configured = [
|
|
69
|
+
rules.nonRuntimeRoots, rules.templateRoots,
|
|
70
|
+
rules.dependencies?.nonRuntimeRoots, rules.dependencies?.templateRoots,
|
|
71
|
+
].flatMap((value) => Array.isArray(value) ? value : typeof value === "string" ? [value] : []);
|
|
72
|
+
for (const value of configured) {
|
|
73
|
+
const root = normConfiguredRoot(value);
|
|
74
|
+
if (root) roots.add(root);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const parts = file.replace(/\\/g, "/").split("/");
|
|
79
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
80
|
+
if (NON_RUNTIME_DIR_RE.test(parts[i])) roots.add(parts.slice(0, i + 1).join("/"));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (!/^[^/]+\/README\.md$/i.test(file)) continue; // custom inference is deliberately top-level only
|
|
87
|
+
const text = readRepoText(boundary, file);
|
|
88
|
+
if (text != null && NON_RUNTIME_README_RE.test(text)) roots.add(normRoot(dirname(file)));
|
|
89
|
+
}
|
|
90
|
+
return [...roots].filter(Boolean).sort((a, b) => a.localeCompare(b));
|
|
91
|
+
}
|
|
92
|
+
|
|
25
93
|
export function collectSourceTexts(repoRoot, graph) {
|
|
26
94
|
const sources = new Map();
|
|
27
95
|
const boundary = createRepoBoundary(repoRoot);
|
|
@@ -36,19 +104,7 @@ export function collectSourceTexts(repoRoot, graph) {
|
|
|
36
104
|
|
|
37
105
|
for (const n of graph.nodes || []) add(n.source_file);
|
|
38
106
|
|
|
39
|
-
const
|
|
40
|
-
let entries = [];
|
|
41
|
-
try { entries = readdirSync(abs, { withFileTypes: true }); } catch { return; }
|
|
42
|
-
for (const entry of entries) {
|
|
43
|
-
if (entry.isDirectory()) {
|
|
44
|
-
if (!SOURCE_SKIP_DIRS.has(entry.name)) walk(join(abs, entry.name), [...parts, entry.name]);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (!entry.isFile() || !SOURCE_EXT_RE.test(entry.name)) continue;
|
|
48
|
-
add([...parts, entry.name].join("/"));
|
|
49
|
-
}
|
|
50
|
-
};
|
|
51
|
-
walk(repoRoot);
|
|
107
|
+
for (const file of listRepoFiles(repoRoot)) if (SOURCE_EXT_RE.test(file)) add(file);
|
|
52
108
|
return sources;
|
|
53
109
|
}
|
|
54
110
|
|
|
@@ -62,7 +118,8 @@ const CONFIG_FILES = [
|
|
|
62
118
|
"jest.config.js", "jest.config.ts", "jest.config.cjs", "jest.config.mjs",
|
|
63
119
|
"vite.config.js", "vite.config.ts", "vite.config.mjs", "vitest.config.js", "vitest.config.ts",
|
|
64
120
|
"webpack.config.js", "rollup.config.js", "esbuild.config.js",
|
|
65
|
-
"postcss.config.js", "postcss.config.cjs", "
|
|
121
|
+
"postcss.config.js", "postcss.config.cjs", "postcss.config.mjs", "postcss.config.ts",
|
|
122
|
+
"tailwind.config.js", "tailwind.config.cjs", "tailwind.config.mjs", "tailwind.config.ts",
|
|
66
123
|
".prettierrc", ".prettierrc.json", "prettier.config.js",
|
|
67
124
|
"playwright.config.js", "playwright.config.ts", "cypress.config.js", "cypress.config.ts",
|
|
68
125
|
"next.config.js", "next.config.mjs", "nuxt.config.ts", "svelte.config.js", "astro.config.mjs",
|
|
@@ -76,17 +133,96 @@ const CONFIG_FILES = [
|
|
|
76
133
|
export function collectConfigTexts(repoRoot) {
|
|
77
134
|
const map = new Map();
|
|
78
135
|
const boundary = createRepoBoundary(repoRoot);
|
|
79
|
-
|
|
136
|
+
const names = new Set(CONFIG_FILES.map((f) => f.toLowerCase()));
|
|
137
|
+
for (const f of listRepoFiles(repoRoot)) {
|
|
138
|
+
const base = f.slice(f.lastIndexOf("/") + 1).toLowerCase();
|
|
139
|
+
if (!names.has(base) && !/^\.github\/workflows\/.*\.ya?ml$/i.test(f)) continue;
|
|
140
|
+
const t = readRepoText(boundary, f);
|
|
141
|
+
if (t != null) map.set(f, t);
|
|
142
|
+
}
|
|
143
|
+
return map;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function readJsonc(text) {
|
|
147
|
+
if (text == null) return null;
|
|
80
148
|
try {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
for (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
149
|
+
const input = String(text).replace(/^\uFEFF/, "");
|
|
150
|
+
let clean = "", inString = false, escaped = false;
|
|
151
|
+
for (let i = 0; i < input.length; i++) {
|
|
152
|
+
const ch = input[i], next = input[i + 1];
|
|
153
|
+
if (inString) {
|
|
154
|
+
clean += ch;
|
|
155
|
+
if (escaped) escaped = false;
|
|
156
|
+
else if (ch === "\\") escaped = true;
|
|
157
|
+
else if (ch === '"') inString = false;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (ch === '"') { inString = true; clean += ch; continue; }
|
|
161
|
+
if (ch === "/" && next === "/") {
|
|
162
|
+
while (i < input.length && input[i] !== "\n") i++;
|
|
163
|
+
clean += "\n";
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (ch === "/" && next === "*") {
|
|
167
|
+
i += 2;
|
|
168
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
169
|
+
i++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
clean += ch;
|
|
87
173
|
}
|
|
88
|
-
|
|
89
|
-
|
|
174
|
+
clean = clean.replace(/,\s*([}\]])/g, "$1");
|
|
175
|
+
return JSON.parse(clean);
|
|
176
|
+
} catch { return null; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const normRoot = (root) => {
|
|
180
|
+
const value = String(root || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
|
|
181
|
+
return value === "." ? "" : value;
|
|
182
|
+
};
|
|
183
|
+
const inScope = (file, root) => !root || file === root || file.startsWith(`${root}/`);
|
|
184
|
+
|
|
185
|
+
function aliasesForScope(repoRoot, root, files, boundary, scopeRoots) {
|
|
186
|
+
const ownerOf = (file) => scopeRoots.find((candidate) => inScope(file, candidate)) ?? "";
|
|
187
|
+
const configs = files
|
|
188
|
+
.filter((f) => ownerOf(f) === root && /(^|\/)(?:tsconfig|jsconfig)(?:\.[^/]+)?\.json$/i.test(f))
|
|
189
|
+
.filter((f) => normRoot(dirname(f)) === root)
|
|
190
|
+
.sort((a, b) => Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(a)) - Number(!/(^|\/)(?:tsconfig|jsconfig)\.json$/i.test(b)) || a.localeCompare(b));
|
|
191
|
+
const aliases = new Map();
|
|
192
|
+
for (const config of configs) {
|
|
193
|
+
const cfg = readJsonc(readRepoText(boundary, config));
|
|
194
|
+
const paths = cfg?.compilerOptions?.paths || {};
|
|
195
|
+
for (const key of Object.keys(paths)) if (!aliases.has(key)) aliases.set(key, {
|
|
196
|
+
key,
|
|
197
|
+
prefix: String(key).replace(/\*.*$/, ""),
|
|
198
|
+
suffix: String(key).includes("*") ? String(key).slice(String(key).indexOf("*") + 1) : "",
|
|
199
|
+
config,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return [...aliases.values()];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Every package.json defines a dependency scope. The nearest ancestor manifest owns an import, matching
|
|
206
|
+
// npm workspace/package semantics. Aliases are collected from that scope's tsconfig/jsconfig so `@/*`
|
|
207
|
+
// remains local rather than becoming a phantom npm package.
|
|
208
|
+
export function collectPackageScopes(repoRoot, rootPkg = null) {
|
|
209
|
+
const boundary = createRepoBoundary(repoRoot);
|
|
210
|
+
const files = listRepoFiles(repoRoot);
|
|
211
|
+
const manifests = files.filter((f) => /(^|\/)package\.json$/i.test(f));
|
|
212
|
+
if (!manifests.includes("package.json") && rootPkg) manifests.unshift("package.json");
|
|
213
|
+
const uniqueManifests = [...new Set(manifests)];
|
|
214
|
+
const scopeRoots = uniqueManifests
|
|
215
|
+
.map((manifest) => manifest === "package.json" ? "" : normRoot(dirname(manifest)))
|
|
216
|
+
.sort((a, b) => b.length - a.length);
|
|
217
|
+
const scopes = [];
|
|
218
|
+
for (const manifest of uniqueManifests) {
|
|
219
|
+
const root = manifest === "package.json" ? "" : normRoot(dirname(manifest));
|
|
220
|
+
const pkg = manifest === "package.json" && rootPkg ? rootPkg : readRepoJson(boundary, manifest);
|
|
221
|
+
if (!pkg || typeof pkg !== "object") continue;
|
|
222
|
+
scopes.push({ root, manifest, pkg, aliases: aliasesForScope(repoRoot, root, files, boundary, scopeRoots) });
|
|
223
|
+
}
|
|
224
|
+
if (!scopes.some((s) => !s.root)) scopes.push({ root: "", manifest: "package.json", pkg: rootPkg || {}, aliases: aliasesForScope(repoRoot, "", files, boundary, [...scopeRoots, ""]) });
|
|
225
|
+
return scopes.sort((a, b) => b.root.length - a.root.length);
|
|
90
226
|
}
|
|
91
227
|
|
|
92
228
|
// Monorepo-local package names: "packages/*"-style workspace globs → each child's package.json name.
|
|
@@ -111,6 +247,7 @@ export function workspacePkgNames(repoRoot, pkg) {
|
|
|
111
247
|
if (p && p.name) names.add(p.name);
|
|
112
248
|
}
|
|
113
249
|
}
|
|
250
|
+
for (const scope of collectPackageScopes(repoRoot, pkg)) if (scope.pkg?.name) names.add(scope.pkg.name);
|
|
114
251
|
return names;
|
|
115
252
|
}
|
|
116
253
|
|