weavatrix 0.1.0 → 0.1.2
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 +59 -18
- package/SECURITY.md +31 -0
- package/package.json +16 -4
- package/skill/SKILL.md +16 -10
- package/src/analysis/coverage-reports.js +14 -11
- package/src/analysis/dead-check.js +7 -2
- package/src/analysis/duplicates.compute.js +221 -215
- package/src/analysis/duplicates.js +15 -15
- package/src/analysis/duplicates.run.js +52 -51
- package/src/analysis/duplicates.tokenize.js +182 -182
- package/src/analysis/endpoints.js +5 -3
- package/src/analysis/graph-analysis.aggregate.js +5 -1
- package/src/analysis/internal-audit.collect.js +37 -11
- package/src/analysis/internal-audit.run.js +8 -5
- package/src/build-graph.js +2 -1
- package/src/child-env.js +7 -0
- package/src/graph/internal-builder.build.js +6 -3
- package/src/graph/internal-builder.langs.js +17 -6
- package/src/graph/internal-builder.resolvers.js +10 -3
- package/src/mcp/catalog.mjs +9 -7
- package/src/mcp/graph-context.mjs +8 -5
- package/src/mcp/sync-payload.mjs +102 -0
- package/src/mcp/tools-actions.mjs +22 -13
- package/src/mcp/tools-impact.mjs +3 -2
- package/src/mcp-rg.mjs +2 -1
- package/src/mcp-server.mjs +26 -17
- package/src/mcp-source-tools.mjs +16 -5
- package/src/process.js +4 -3
- package/src/repo-path.js +53 -0
- package/src/security/installed.js +44 -31
- package/src/security/malware-heuristics.exclusions.js +134 -134
- package/src/security/malware-heuristics.js +15 -15
- package/src/security/malware-heuristics.roots.js +142 -142
- package/src/security/malware-heuristics.scan.js +85 -85
- package/src/security/malware-heuristics.sweep.js +122 -122
|
@@ -15,21 +15,24 @@ 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
19
|
collectPyManifest, TEST_FILE_RE,
|
|
20
20
|
} from "./internal-audit.collect.js";
|
|
21
21
|
import { entryFiles, computeReachability } from "./internal-audit.reach.js";
|
|
22
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
22
23
|
|
|
23
24
|
// Run the internal audit. graph is optional (loaded from the repo's central graph.json when absent);
|
|
24
25
|
// advisoryStorePath overrides the default ~/.weavatrix/advisories.json (tests use a scratch path).
|
|
25
26
|
// async because the malware sweep shells out to ripgrep.
|
|
26
27
|
export async function runInternalAudit(repoPath, { graph, advisoryStorePath, skipMalwareScan = false, malwareExclusions = {}, rgPath = "" } = {}) {
|
|
27
28
|
if (!existsSync(repoPath)) return { ok: false, error: "Repo path not found" };
|
|
29
|
+
const boundary = createRepoBoundary(repoPath);
|
|
30
|
+
if (!boundary.root) return { ok: false, error: "Repository path is unreadable" };
|
|
28
31
|
if (!graph) {
|
|
29
32
|
graph = readJson(join(graphOutDirForRepo(repoPath), "graph.json"));
|
|
30
33
|
if (!graph) return { ok: false, error: "Build the graph first (no graph.json)" };
|
|
31
34
|
}
|
|
32
|
-
const pkg =
|
|
35
|
+
const pkg = readRepoJson(boundary, "package.json") || {};
|
|
33
36
|
const externalImports = graph.externalImports || [];
|
|
34
37
|
const dynamicTargets = new Set(externalImports.filter((e) => e.dynamic && e.target).map((e) => e.target));
|
|
35
38
|
|
|
@@ -43,13 +46,13 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
43
46
|
const configTexts = collectConfigTexts(repoPath);
|
|
44
47
|
const dep = computeDepFindings({ externalImports, pkg, workspacePkgNames: workspacePkgNames(repoPath, pkg), configTexts });
|
|
45
48
|
// non-npm ecosystems: Go (go.mod) + Python (requirements/pyproject/Pipfile) — same findings shape
|
|
46
|
-
const goModText =
|
|
49
|
+
const goModText = readRepoText(boundary, "go.mod");
|
|
47
50
|
const goDep = computeGoDepFindings({ externalImports, goMod: goModText != null ? parseGoMod(goModText) : null });
|
|
48
51
|
const pyDep = computePyDepFindings({ externalImports, pyManifest: collectPyManifest(repoPath), configTexts });
|
|
49
52
|
|
|
50
53
|
// structure: cycles / orphans / boundary rules. Rules come from the repo's optional .weavatrix-deps.json
|
|
51
54
|
// (the depcruise-config analogue); no bundled default rules — cycles+orphans are always on.
|
|
52
|
-
const rules =
|
|
55
|
+
const rules = readRepoJson(boundary, ".weavatrix-deps.json") || {};
|
|
53
56
|
const externalImportFiles = new Set(externalImports.filter((e) => e.pkg && !e.builtin).map((e) => e.file));
|
|
54
57
|
const structure = computeStructureFindings(graph, { rules, entrySet: entries, externalImportFiles });
|
|
55
58
|
|
|
@@ -175,7 +178,7 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
175
178
|
symbols: dead.stats.symbols,
|
|
176
179
|
manifestDeps: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
177
180
|
externalImports: externalImports.length,
|
|
178
|
-
nodeModulesPresent:
|
|
181
|
+
nodeModulesPresent: boundary.resolve("node_modules").ok,
|
|
179
182
|
installedPackages: installedCount,
|
|
180
183
|
advisoryDbDate,
|
|
181
184
|
malwareScanMode: malwareScan?.scanMode || "skipped",
|
package/src/build-graph.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { existsSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { Worker } from "node:worker_threads";
|
|
12
|
+
import { childProcessEnv } from "./child-env.js";
|
|
12
13
|
import { graphOutDirForRepo, repoTopFolders, summarizeCommunities, summarizeHotspots, filterGraphForMode, filterGraphByScope } from "./graph/layout.js";
|
|
13
14
|
|
|
14
15
|
// The worker path deadlocks web-tree-sitter's WASM in Electron's worker threads (fine in plain Node) — off
|
|
@@ -25,7 +26,7 @@ function buildGraphInWorker(payload) {
|
|
|
25
26
|
return new Promise((resolve, reject) => {
|
|
26
27
|
let worker;
|
|
27
28
|
try {
|
|
28
|
-
worker = new Worker(new URL("./graph/build-worker.js", import.meta.url), { workerData: payload });
|
|
29
|
+
worker = new Worker(new URL("./graph/build-worker.js", import.meta.url), { workerData: payload, env: childProcessEnv() });
|
|
29
30
|
} catch (e) {
|
|
30
31
|
reject(Object.assign(e, { workerStartFailed: true }));
|
|
31
32
|
return;
|
package/src/child-env.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Child processes and worker threads do not need the hosted-sync bearer token.
|
|
2
|
+
// Keep it in the MCP process so only sync_graph can attach it to an HTTP request.
|
|
3
|
+
export function childProcessEnv(overrides = {}) {
|
|
4
|
+
const env = { ...process.env, ...overrides };
|
|
5
|
+
delete env.WEAVATRIX_SYNC_TOKEN;
|
|
6
|
+
return env;
|
|
7
|
+
}
|
|
@@ -11,13 +11,16 @@ import { buildResolvers } from "./internal-builder.resolvers.js";
|
|
|
11
11
|
|
|
12
12
|
// Parse a repo directory into a graph-builder-compatible { nodes, links } graph.
|
|
13
13
|
export async function buildInternalGraph(repoDir, opts = {}) {
|
|
14
|
-
const
|
|
14
|
+
const files = walk(repoDir);
|
|
15
|
+
// Lazy grammar loading: compile only the WASMs for languages this repo actually contains.
|
|
16
|
+
const wanted = new Set();
|
|
17
|
+
for (const f of files) { const g = EXT_LANG[extname(f)]; if (g) wanted.add(g); }
|
|
18
|
+
const langs = await ensureParser(opts, wanted);
|
|
15
19
|
const qc = new Map();
|
|
16
20
|
const q = (grammar, src) => { const k = grammar + ":" + src; if (qc.has(k)) return qc.get(k); let x = null; try { x = new Query(langs[grammar], src); } catch { x = null; } qc.set(k, x); return x; };
|
|
17
21
|
const caps = (grammar, src, root) => { const query = src && q(grammar, src); return query ? query.captures(root) : []; };
|
|
18
22
|
const field = (n, f) => (n && n.childForFieldName ? n.childForFieldName(f) : null);
|
|
19
23
|
|
|
20
|
-
const files = walk(repoDir);
|
|
21
24
|
const rel = (p) => relative(repoDir, p).replace(/\\/g, "/");
|
|
22
25
|
const fileSet = new Set(files.map(rel));
|
|
23
26
|
const nodes = []; const links = []; const nodeIds = new Set(); const nodeById = new Map();
|
|
@@ -203,7 +206,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
203
206
|
|
|
204
207
|
// extImportsV: bump when the externalImports schema/coverage changes (v2 = go/python ecosystems) —
|
|
205
208
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
206
|
-
return { nodes, links, externalImports, extImportsV: 2, complexityV: 1 };
|
|
209
|
+
return { nodes, links, externalImports, extImportsV: 2, complexityV: 1, repoBoundaryV: 1 };
|
|
207
210
|
}
|
|
208
211
|
|
|
209
212
|
// Build + write graph.json to outPath (creating the dir). Returns { ok, nodes, links, graphJson }.
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readdirSync, statSync, realpathSync } from "node:fs";
|
|
7
7
|
import { join, extname, dirname } from "node:path";
|
|
8
8
|
import { createRequire } from "node:module";
|
|
9
|
+
import { isPathInside } from "../repo-path.js";
|
|
9
10
|
import LANG_JS from "./builder/lang-js.js";
|
|
10
11
|
import LANG_PY from "./builder/lang-python.js";
|
|
11
12
|
import LANG_GO from "./builder/lang-go.js";
|
|
@@ -52,20 +53,26 @@ const MAX_PARSE_BYTES = 1_500_000; // skip parsing files above this (minified
|
|
|
52
53
|
|
|
53
54
|
let _ready = null;
|
|
54
55
|
const _langs = {};
|
|
55
|
-
|
|
56
|
+
// `wanted` (a Set of grammar names) makes loading LAZY: only grammars for extensions actually present
|
|
57
|
+
// in the repo get compiled. The heavy WASMs (rust ~3MB, c_sharp ~4MB) cost seconds to compile — a tax
|
|
58
|
+
// a JS-only repo must never pay. Omit `wanted` to load everything (old behavior).
|
|
59
|
+
async function ensureParser(opts = {}, wanted = null) {
|
|
56
60
|
if (!_ready) _ready = Parser.init({ locateFile: () => opts.runtimeWasm || DEFAULT_RUNTIME_WASM });
|
|
57
61
|
await _ready;
|
|
58
62
|
const wasmDir = opts.wasmDir || DEFAULT_WASM_DIR;
|
|
59
|
-
|
|
63
|
+
const list = wanted ? GRAMMARS.filter((g) => wanted.has(g)) : GRAMMARS;
|
|
64
|
+
for (const g of list) if (!_langs[g]) { try { _langs[g] = await Language.load(join(wasmDir, `tree-sitter-${g}.wasm`)); } catch { _langs[g] = null; } }
|
|
60
65
|
return _langs;
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
// Cycle-safe directory walk. statSync FOLLOWS symlinks/junctions, so a link pointing at an ancestor would
|
|
64
69
|
// otherwise recurse forever (a/b/link/b/link/…). We dedupe by REAL path (a visited dir is never re-entered)
|
|
65
70
|
// and cap depth as a backstop, so a symlink loop can't wedge the build.
|
|
66
|
-
function walk(dir, acc = [], seen = new Set(), depth = 0) {
|
|
71
|
+
function walk(dir, acc = [], seen = new Set(), depth = 0, rootReal = null) {
|
|
67
72
|
if (depth > 40) return acc;
|
|
68
|
-
let real; try { real = realpathSync.native(dir); } catch {
|
|
73
|
+
let real; try { real = realpathSync.native(dir); } catch { return acc; }
|
|
74
|
+
if (rootReal == null) rootReal = real;
|
|
75
|
+
if (!isPathInside(rootReal, real)) return acc;
|
|
69
76
|
if (seen.has(real)) return acc;
|
|
70
77
|
seen.add(real);
|
|
71
78
|
let entries;
|
|
@@ -76,9 +83,13 @@ function walk(dir, acc = [], seen = new Set(), depth = 0) {
|
|
|
76
83
|
// never match AGENT_DOTFILE, so we still never recurse into them (.git/.github/.cursor stay out).
|
|
77
84
|
if (name.startsWith(".") && !AGENT_DOTFILE.test(name)) continue;
|
|
78
85
|
const full = join(dir, name);
|
|
86
|
+
let entryReal; try { entryReal = realpathSync.native(full); } catch { continue; }
|
|
87
|
+
if (!isPathInside(rootReal, entryReal)) continue;
|
|
79
88
|
let st; try { st = statSync(full); } catch { continue; }
|
|
80
|
-
if (st.isDirectory()) walk(full, acc, seen, depth + 1);
|
|
81
|
-
|
|
89
|
+
if (st.isDirectory()) walk(full, acc, seen, depth + 1, rootReal);
|
|
90
|
+
// include by KNOWN extension, not by loaded grammar — grammars now load lazily AFTER the walk
|
|
91
|
+
// (the parse passes skip files whose grammar failed to load, so the guarantee is unchanged)
|
|
92
|
+
else { const e = extname(name); if (EXT_LANG[e] || isDataFile(name) || isDocFile(name)) acc.push(full); }
|
|
82
93
|
}
|
|
83
94
|
return acc;
|
|
84
95
|
}
|
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { join, dirname } from "node:path";
|
|
6
6
|
import { parseGoMod } from "../analysis/manifests.js";
|
|
7
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
7
8
|
|
|
8
9
|
export function buildResolvers(repoDir, fileSet) {
|
|
10
|
+
const boundary = createRepoBoundary(repoDir);
|
|
11
|
+
const readLocal = (relativePath) => {
|
|
12
|
+
const resolved = boundary.resolve(relativePath);
|
|
13
|
+
if (!resolved.ok) throw new Error("resolver input is outside the repository");
|
|
14
|
+
return readFileSync(resolved.path, "utf8");
|
|
15
|
+
};
|
|
9
16
|
// Go package = directory (resolved via go.mod module prefix); Java class = file (basename index).
|
|
10
17
|
// go.mod requires also feed goSpecToPkg so external Go imports map to their declared module.
|
|
11
18
|
let goModule = "";
|
|
12
19
|
let goRequires = [];
|
|
13
20
|
try {
|
|
14
|
-
const gomod = parseGoMod(
|
|
21
|
+
const gomod = parseGoMod(readLocal("go.mod"));
|
|
15
22
|
goModule = gomod.module;
|
|
16
23
|
goRequires = gomod.requires.map((r) => r.path);
|
|
17
24
|
} catch { /* no go.mod */ }
|
|
@@ -51,7 +58,7 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
51
58
|
const jsBaseUrls = []; // tsconfig/jsconfig baseUrl roots — bare "components/Button" may be baseUrl-rooted, not an npm package
|
|
52
59
|
for (const cfg of ["tsconfig.json", "tsconfig.app.json", "tsconfig.base.json", "jsconfig.json"]) {
|
|
53
60
|
try {
|
|
54
|
-
const raw =
|
|
61
|
+
const raw = readLocal(cfg).replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/,(\s*[}\]])/g, "$1");
|
|
55
62
|
const tj = JSON.parse(raw); const co = tj.compilerOptions || {}; const paths = co.paths || {};
|
|
56
63
|
const baseUrl = String(co.baseUrl || ".").replace(/^\.\/?/, "").replace(/\/$/, "");
|
|
57
64
|
if (co.baseUrl != null && !jsBaseUrls.includes(baseUrl)) jsBaseUrls.push(baseUrl);
|
|
@@ -59,7 +66,7 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
59
66
|
} catch { /* no/invalid tsconfig */ }
|
|
60
67
|
}
|
|
61
68
|
for (const vc of ["vite.config.ts", "vite.config.js", "vite.config.mjs", "webpack.config.js"]) {
|
|
62
|
-
try { const src =
|
|
69
|
+
try { const src = readLocal(vc); for (const m of src.matchAll(/['"`]([^'"`]+)['"`]\s*:\s*path\.resolve\([^,]+,\s*['"`]([^'"`]+)['"`]\s*\)/g)) addAlias(m[1], m[2]); } catch { /* no bundler config */ }
|
|
63
70
|
}
|
|
64
71
|
aliasList.sort((a, b) => b.alias.length - a.alias.length);
|
|
65
72
|
const resolveAlias = (spec) => { for (const { alias, target } of aliasList) { if (spec === alias) return target; if (spec.startsWith(alias + "/")) return target + spec.slice(alias.length); } return null; };
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {readSource, searchCode} from '../mcp-source-tools.mjs'
|
|
|
11
11
|
|
|
12
12
|
const SELF_DIR = dirname(fileURLToPath(import.meta.url))
|
|
13
13
|
const resolveRg = createRgResolver(SELF_DIR)
|
|
14
|
+
export const DEFAULT_CAPS = Object.freeze(['graph', 'search', 'source', 'health', 'build', 'retarget'])
|
|
14
15
|
|
|
15
16
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
16
17
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
@@ -37,16 +38,16 @@ function buildTools({tg, ti, th, ta}) {
|
|
|
37
38
|
{cap: 'source', name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux …): method, path, handler, and file:line, deduped across code and OpenAPI docs.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
38
39
|
{cap: 'build', name: 'rebuild_graph', description: "Rebuild this repo's code graph from current source (weavatrix's own web-tree-sitter builder), reload it in-memory, and report the STRUCTURAL DELTA vs the previous state — new/removed module dependencies, cycle changes, newly orphaned symbols. The prior state is saved as graph.prev.json for graph_diff. Call after significant edits.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}, scope: {type: 'string', description: 'optional path prefix to limit the graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
|
|
39
40
|
{cap: 'graph', name: 'graph_diff', description: 'Structural diff of the last rebuild: previous graph state (graph.prev.json, saved by rebuild_graph) vs current — architecture drift (new module dependencies), broken or introduced import cycles, symbols that lost their last caller. The semantic complement to the textual git diff for validating a refactor.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
|
|
40
|
-
{cap: '
|
|
41
|
-
{cap: '
|
|
41
|
+
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}}, required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
42
|
+
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list sibling repositories with existing graphs that can be selected through open_repo.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
42
43
|
{cap: 'online', name: 'refresh_advisories', description: "ONLINE, explicit opt-in: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
43
|
-
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured:
|
|
44
|
+
{cap: 'online', name: 'sync_graph', description: 'ONLINE, explicit opt-in, off until configured: send a versioned allowlist of graph metadata to an endpoint you configure (env WEAVATRIX_SYNC_URL, optional WEAVATRIX_SYNC_TOKEN bearer auth). Unknown graph fields are discarded and source file bodies are not read for sync. Graphs built before 0.1.2 must be rebuilt once before syncing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
44
45
|
]
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
// Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
|
|
48
|
-
// capsArg semantics: undefined/null =
|
|
49
|
-
//
|
|
49
|
+
// capsArg semantics: undefined/null = offline defaults (including explicit-call repo retargeting); a
|
|
50
|
+
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
50
51
|
export async function loadHotApi(version, capsArg) {
|
|
51
52
|
const v = version ? `?v=${version}` : ''
|
|
52
53
|
const [tg, ti, th, ta] = await Promise.all([
|
|
@@ -56,8 +57,9 @@ export async function loadHotApi(version, capsArg) {
|
|
|
56
57
|
import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
|
|
57
58
|
])
|
|
58
59
|
const all = buildTools({tg, ti, th, ta})
|
|
59
|
-
const
|
|
60
|
-
const
|
|
60
|
+
const selected = capsArg == null ? DEFAULT_CAPS : String(capsArg).split(',').map((s) => s.trim()).filter(Boolean)
|
|
61
|
+
const caps = new Set(selected)
|
|
62
|
+
const tools = all.filter((t) => caps.has(t.cap))
|
|
61
63
|
return {
|
|
62
64
|
tools,
|
|
63
65
|
byName: new Map(tools.map((t) => [t.name, t])),
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
import {readFileSync, statSync} from 'node:fs'
|
|
6
6
|
import {join} from 'node:path'
|
|
7
7
|
import {spawnSync} from 'node:child_process'
|
|
8
|
+
import {childProcessEnv} from '../child-env.js'
|
|
8
9
|
import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
|
|
10
|
+
import {resolveRepoPath} from '../repo-path.js'
|
|
9
11
|
|
|
10
12
|
// ---- graph load + indexes -----------------------------------------------------------------------
|
|
11
13
|
export function loadGraph(path) {
|
|
@@ -34,7 +36,7 @@ export function loadGraph(path) {
|
|
|
34
36
|
push(out, s, {id: t, relation: e.relation, confidence: e.confidence})
|
|
35
37
|
push(inn, t, {id: s, relation: e.relation, confidence: e.confidence})
|
|
36
38
|
}
|
|
37
|
-
return {nodes, links, byId, byLabel, out, inn}
|
|
39
|
+
return {nodes, links, byId, byLabel, out, inn, repoBoundaryV: Number(raw.repoBoundaryV) || 0}
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
export const isSymbol = (id) => String(id).includes('#')
|
|
@@ -123,13 +125,13 @@ export function graphStaleness(ctx) {
|
|
|
123
125
|
try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file — nothing to report */ }
|
|
124
126
|
if (ctx.repoRoot && info.builtAt) {
|
|
125
127
|
try {
|
|
126
|
-
const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000})
|
|
128
|
+
const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
127
129
|
const iso = (head.stdout || '').trim()
|
|
128
130
|
if (head.status === 0 && iso) {
|
|
129
131
|
info.headAt = new Date(iso)
|
|
130
132
|
if (info.headAt > info.builtAt) {
|
|
131
133
|
info.stale = true
|
|
132
|
-
const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000})
|
|
134
|
+
const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
133
135
|
if (cnt.status === 0) info.behind = Number(cnt.stdout.trim()) || null
|
|
134
136
|
}
|
|
135
137
|
}
|
|
@@ -138,7 +140,7 @@ export function graphStaleness(ctx) {
|
|
|
138
140
|
// they edit, then re-query). Count dirty files actually TOUCHED after the build — a dirty file
|
|
139
141
|
// older than the graph was already part of it.
|
|
140
142
|
try {
|
|
141
|
-
const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000})
|
|
143
|
+
const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
|
|
142
144
|
if (st.status === 0) {
|
|
143
145
|
let newer = 0
|
|
144
146
|
for (const ln of String(st.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
|
|
@@ -170,7 +172,8 @@ export function fileStalenessNote(ctx, sourceFile) {
|
|
|
170
172
|
const s = graphStaleness(ctx)
|
|
171
173
|
if (!s.builtAt) return null
|
|
172
174
|
try {
|
|
173
|
-
|
|
175
|
+
const resolved = resolveRepoPath(ctx.repoRoot, String(sourceFile))
|
|
176
|
+
if (resolved.ok && statSync(resolved.path).mtime > s.builtAt) {
|
|
174
177
|
return `Note: ${sourceFile} changed after the graph was built — line numbers above may have drifted (rebuild_graph refreshes them).`
|
|
175
178
|
}
|
|
176
179
|
} catch { /* file gone — the read tools will surface that themselves */ }
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Versioned, explicit wire schema for sync_graph. Never forward graph.json wholesale: it is a local
|
|
2
|
+
// cache file and may contain future fields or attacker-injected data that are not safe to upload.
|
|
3
|
+
|
|
4
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
|
|
5
|
+
|
|
6
|
+
function metadataString(value, max = 4096) {
|
|
7
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL_CHARS.test(value)
|
|
8
|
+
? value
|
|
9
|
+
: undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function finiteNumber(value) {
|
|
13
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function setIf(out, key, value) {
|
|
17
|
+
if (value !== undefined) out[key] = value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const COMPLEXITY_NUMBERS = [
|
|
21
|
+
'startLine', 'endLine', 'loc', 'params', 'objectFields', 'branches', 'cyclomatic',
|
|
22
|
+
'loops', 'maxLoopDepth', 'returns', 'awaits', 'callCount', 'externalCalls',
|
|
23
|
+
'asyncBoundaries', 'allocations', 'objectLiterals', 'spreadCopies', 'sorts',
|
|
24
|
+
'linearOps', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function sanitizeComplexity(value) {
|
|
28
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
29
|
+
const out = {};
|
|
30
|
+
for (const key of COMPLEXITY_NUMBERS) setIf(out, key, finiteNumber(value[key]));
|
|
31
|
+
if (typeof value.recursion === 'boolean') out.recursion = value.recursion;
|
|
32
|
+
for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
|
|
33
|
+
setIf(out, key, metadataString(value[key], 32));
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length ? out : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sanitizeNode(value) {
|
|
39
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
40
|
+
const id = metadataString(value.id);
|
|
41
|
+
if (!id) return null;
|
|
42
|
+
const out = {id};
|
|
43
|
+
setIf(out, 'label', metadataString(value.label, 1024));
|
|
44
|
+
setIf(out, 'file_type', metadataString(value.file_type, 32));
|
|
45
|
+
setIf(out, 'source_file', metadataString(value.source_file));
|
|
46
|
+
const sourceLocation = metadataString(value.source_location, 32);
|
|
47
|
+
const sourceEnd = metadataString(value.source_end, 32);
|
|
48
|
+
if (sourceLocation && /^L\d+$/.test(sourceLocation)) out.source_location = sourceLocation;
|
|
49
|
+
if (sourceEnd && /^L\d+$/.test(sourceEnd)) out.source_end = sourceEnd;
|
|
50
|
+
const community = finiteNumber(value.community);
|
|
51
|
+
if (community !== undefined && Number.isInteger(community) && community >= 0) out.community = community;
|
|
52
|
+
if (typeof value.exported === 'boolean') out.exported = value.exported;
|
|
53
|
+
if (typeof value.decorated === 'boolean') out.decorated = value.decorated;
|
|
54
|
+
setIf(out, 'complexity', sanitizeComplexity(value.complexity));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizeLink(value) {
|
|
59
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
60
|
+
const source = metadataString(value.source);
|
|
61
|
+
const target = metadataString(value.target);
|
|
62
|
+
if (!source || !target) return null;
|
|
63
|
+
const out = {source, target};
|
|
64
|
+
setIf(out, 'relation', metadataString(value.relation, 32));
|
|
65
|
+
setIf(out, 'confidence', metadataString(value.confidence, 32));
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizeExternalImport(value) {
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
71
|
+
const file = metadataString(value.file);
|
|
72
|
+
if (!file) return null;
|
|
73
|
+
const out = {file};
|
|
74
|
+
for (const key of ['spec', 'target']) setIf(out, key, metadataString(value[key]));
|
|
75
|
+
for (const key of ['pkg', 'kind', 'ecosystem']) setIf(out, key, metadataString(value[key], 256));
|
|
76
|
+
const line = finiteNumber(value.line);
|
|
77
|
+
if (line !== undefined && Number.isInteger(line) && line >= 0) out.line = line;
|
|
78
|
+
for (const key of ['builtin', 'dynamic', 'unresolved']) {
|
|
79
|
+
if (typeof value[key] === 'boolean') out[key] = value[key];
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createSyncPayload(raw) {
|
|
85
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || raw.repoBoundaryV !== 1) {
|
|
86
|
+
throw new Error('graph predates repository-boundary hardening');
|
|
87
|
+
}
|
|
88
|
+
const nodes = Array.isArray(raw.nodes) ? raw.nodes.map(sanitizeNode).filter(Boolean) : [];
|
|
89
|
+
const links = Array.isArray(raw.links) ? raw.links.map(sanitizeLink).filter(Boolean) : [];
|
|
90
|
+
const externalImports = Array.isArray(raw.externalImports)
|
|
91
|
+
? raw.externalImports.map(sanitizeExternalImport).filter(Boolean)
|
|
92
|
+
: [];
|
|
93
|
+
return {
|
|
94
|
+
syncPayloadV: 1,
|
|
95
|
+
repoBoundaryV: 1,
|
|
96
|
+
extImportsV: Number.isInteger(raw.extImportsV) ? raw.extImportsV : 0,
|
|
97
|
+
complexityV: Number.isInteger(raw.complexityV) ? raw.complexityV : 0,
|
|
98
|
+
nodes,
|
|
99
|
+
links,
|
|
100
|
+
externalImports,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// Action tools: rebuild the graph,
|
|
2
|
-
//
|
|
1
|
+
// Action tools: rebuild the graph, the explicit 'retarget' group, and the explicit 'online' group
|
|
2
|
+
// (the ONLY tools that ever touch the network).
|
|
3
3
|
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
|
-
import {readFileSync, writeFileSync, existsSync, readdirSync, statSync} from 'node:fs'
|
|
5
|
-
import {join, dirname} from 'node:path'
|
|
4
|
+
import {readFileSync, writeFileSync, existsSync, readdirSync, statSync, realpathSync} from 'node:fs'
|
|
5
|
+
import {join, dirname, isAbsolute} from 'node:path'
|
|
6
6
|
import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
|
|
7
7
|
import {buildGraphForRepo} from '../build-graph.js'
|
|
8
8
|
import {graphOutDirForRepo} from '../graph/layout.js'
|
|
9
9
|
import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
|
|
10
10
|
import {collectInstalled} from '../security/installed.js'
|
|
11
|
+
import {createSyncPayload} from './sync-payload.mjs'
|
|
11
12
|
|
|
12
13
|
export async function tRebuildGraph(g, args, ctx) {
|
|
13
14
|
if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
|
|
@@ -31,9 +32,13 @@ export async function tRebuildGraph(g, args, ctx) {
|
|
|
31
32
|
// repo. Loads <parent>/weavatrix-graphs/<name>/graph.json (the central layout graphs
|
|
32
33
|
// always live in), building it first when missing. On a failed load the previous repo stays active.
|
|
33
34
|
export async function tOpenRepo(g, args, ctx) {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
if (!
|
|
35
|
+
const requestedPath = String(args.path || '').trim()
|
|
36
|
+
if (!requestedPath) return 'Provide "path" — an absolute path to a local repository folder.'
|
|
37
|
+
if (!isAbsolute(requestedPath)) return 'open_repo requires an absolute repository path.'
|
|
38
|
+
let repoPath
|
|
39
|
+
try { repoPath = realpathSync.native(requestedPath) } catch { return `Path not found: ${requestedPath}` }
|
|
40
|
+
try { if (!statSync(repoPath).isDirectory()) return `Not a directory: ${requestedPath}` } catch { return `Path not found: ${requestedPath}` }
|
|
41
|
+
if (!existsSync(join(repoPath, '.git'))) return `Not a Git repository: ${requestedPath}`
|
|
37
42
|
const graphPath = join(graphOutDirForRepo(repoPath), 'graph.json')
|
|
38
43
|
let built = false
|
|
39
44
|
if (!existsSync(graphPath)) {
|
|
@@ -101,9 +106,8 @@ export async function tRefreshAdvisories(g, args, ctx) {
|
|
|
101
106
|
].filter(Boolean).join('\n')
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
// Push the current graph.json to a user-configured endpoint
|
|
105
|
-
//
|
|
106
|
-
// file paths, symbol names, and edges — never file contents.
|
|
109
|
+
// Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
|
|
110
|
+
// The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
|
|
107
111
|
export async function tSyncGraph(g, args, ctx) {
|
|
108
112
|
const url = process.env.WEAVATRIX_SYNC_URL
|
|
109
113
|
if (!url) {
|
|
@@ -111,8 +115,13 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
111
115
|
+ ' (and WEAVATRIX_SYNC_TOKEN for bearer auth) in the MCP registration env, then call again.'
|
|
112
116
|
}
|
|
113
117
|
if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
|
|
114
|
-
let
|
|
115
|
-
try {
|
|
118
|
+
let raw
|
|
119
|
+
try { raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
|
|
120
|
+
let payload
|
|
121
|
+
try { payload = createSyncPayload(raw) } catch {
|
|
122
|
+
return 'This graph predates repository-boundary hardening. Run rebuild_graph once before sync_graph.'
|
|
123
|
+
}
|
|
124
|
+
const body = JSON.stringify(payload)
|
|
116
125
|
const repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
|
|
117
126
|
try {
|
|
118
127
|
const res = await fetch(url, {
|
|
@@ -125,7 +134,7 @@ export async function tSyncGraph(g, args, ctx) {
|
|
|
125
134
|
body,
|
|
126
135
|
})
|
|
127
136
|
if (!res.ok) return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.`
|
|
128
|
-
return `Graph for ${repoName} (${
|
|
137
|
+
return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges, ${Math.round(Buffer.byteLength(body) / 1024)} KB) pushed to ${url}.`
|
|
129
138
|
} catch (e) {
|
|
130
139
|
return `Sync failed: ${e.message} — the graph stays local.`
|
|
131
140
|
}
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
4
|
import {readFileSync} from 'node:fs'
|
|
5
5
|
import {spawnSync} from 'node:child_process'
|
|
6
|
+
import {childProcessEnv} from '../child-env.js'
|
|
6
7
|
import {
|
|
7
8
|
isSymbol, degreeOf, labelOf, resolveNodeInfo, ambiguityNote,
|
|
8
9
|
rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
|
|
@@ -81,7 +82,7 @@ export function tGraphDiff(g, args, ctx) {
|
|
|
81
82
|
|
|
82
83
|
// ---- change impact -------------------------------------------------------------------------------
|
|
83
84
|
function gitLines(repoRoot, args) {
|
|
84
|
-
const res = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000})
|
|
85
|
+
const res = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
|
|
85
86
|
if (res.status !== 0) return null
|
|
86
87
|
return String(res.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)
|
|
87
88
|
}
|
|
@@ -89,7 +90,7 @@ function gitLines(repoRoot, args) {
|
|
|
89
90
|
function resolveImpactBase(repoRoot, requested) {
|
|
90
91
|
const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
|
|
91
92
|
for (const ref of candidates) {
|
|
92
|
-
const ok = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {encoding: 'utf8', timeout: 8000})
|
|
93
|
+
const ok = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
|
|
93
94
|
if (ok.status === 0) return ref
|
|
94
95
|
}
|
|
95
96
|
return null
|
package/src/mcp-rg.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import { childProcessEnv } from './child-env.js'
|
|
4
5
|
import { createRequire } from 'node:module'
|
|
5
6
|
import process from 'node:process'
|
|
6
7
|
|
|
@@ -42,7 +43,7 @@ export function createRgResolver(selfDir) {
|
|
|
42
43
|
} catch { /* optional packaged ripgrep path */ }
|
|
43
44
|
for (const c of editorRgCandidates()) if (existsSync(c)) return (rgPath = c)
|
|
44
45
|
try {
|
|
45
|
-
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8'})
|
|
46
|
+
const probe = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['rg'], {encoding: 'utf8', env: childProcessEnv()})
|
|
46
47
|
const p = probe.status === 0 ? probe.stdout.split(/\r?\n/)[0].trim() : ''
|
|
47
48
|
if (p && existsSync(p)) return (rgPath = p)
|
|
48
49
|
} catch { /* optional PATH probe */ }
|