weavatrix 0.1.4 → 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 +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- 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 +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- 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 +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- 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-js.js +71 -12
- package/src/graph/community.js +10 -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 +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- 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 +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- 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
|
@@ -16,6 +16,16 @@ const TOPVARS = `
|
|
|
16
16
|
(program (export_statement (variable_declaration (variable_declarator) @decl)))`;
|
|
17
17
|
const REQUIRE = `(variable_declarator name: (_) @lhs value: (call_expression function: (identifier) @req arguments: (arguments (string (string_fragment) @src))))`;
|
|
18
18
|
|
|
19
|
+
function parseExportSpecifiers(raw) {
|
|
20
|
+
return String(raw || "").split(",").map((part) => {
|
|
21
|
+
const text = part.trim();
|
|
22
|
+
const typeOnly = /^type\s+/.test(text);
|
|
23
|
+
const clean = text.replace(/^type\s+/, "").trim();
|
|
24
|
+
const match = clean.match(/^([A-Za-z_$][\w$]*|default)(?:\s+as\s+([A-Za-z_$][\w$]*|default))?$/);
|
|
25
|
+
return match ? { imported: match[1], exported: match[2] || match[1], typeOnly } : null;
|
|
26
|
+
}).filter(Boolean);
|
|
27
|
+
}
|
|
28
|
+
|
|
19
29
|
export default {
|
|
20
30
|
family: "js",
|
|
21
31
|
grammars: ["javascript", "typescript", "tsx"],
|
|
@@ -26,6 +36,7 @@ export default {
|
|
|
26
36
|
|
|
27
37
|
pass1(ctx) {
|
|
28
38
|
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, markExported, imports, resolveJsImport, resolveAlias } = ctx;
|
|
39
|
+
const recordJsExport = typeof ctx.recordJsExport === "function" ? ctx.recordJsExport : () => {};
|
|
29
40
|
// exported-ness of a declaration = an export_statement ancestor (export function/class/const …).
|
|
30
41
|
// BOUNDED climb: web-tree-sitter's node.parent is O(depth) (re-walks a cursor from the root each call),
|
|
31
42
|
// so an UNBOUNDED walk to `program` for every symbol was O(depth^3) and HUNG the build for minutes/hours
|
|
@@ -34,15 +45,16 @@ export default {
|
|
|
34
45
|
// not a module-level export → bail immediately. See [[graph-builder-internalization]].
|
|
35
46
|
// Early-out for nested scopes keeps this O(1); class members are intentionally never module exports even
|
|
36
47
|
// when their owner class is exported. The hop cap remains a backstop for malformed/deep syntax trees.
|
|
37
|
-
const
|
|
48
|
+
const exportStatementOf = (node) => {
|
|
38
49
|
let p = node.parent;
|
|
39
50
|
for (let hops = 0; p && hops < 6; hops++) {
|
|
40
|
-
if (p.type === "export_statement") return
|
|
41
|
-
if (p.type === "program" || p.type === "statement_block" || p.type === "class_body") return
|
|
51
|
+
if (p.type === "export_statement") return p;
|
|
52
|
+
if (p.type === "program" || p.type === "statement_block" || p.type === "class_body") return null;
|
|
42
53
|
p = p.parent;
|
|
43
54
|
}
|
|
44
|
-
return
|
|
55
|
+
return null;
|
|
45
56
|
};
|
|
57
|
+
const isExportedDecl = (node) => !!exportStatementOf(node);
|
|
46
58
|
const isModuleDeclaration = (node) => {
|
|
47
59
|
let p = node.parent;
|
|
48
60
|
for (let hops = 0; p && hops < 8; hops++) {
|
|
@@ -78,21 +90,32 @@ export default {
|
|
|
78
90
|
};
|
|
79
91
|
for (const cap of caps(grammar, FUNCS, tree.rootNode)) {
|
|
80
92
|
const isMethod = cap.name === "method";
|
|
93
|
+
const exportStatement = !isMethod && exportStatementOf(cap.node);
|
|
81
94
|
addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name !== "class", {
|
|
82
95
|
sourceNode: cap.node.parent,
|
|
83
|
-
...(
|
|
96
|
+
...(exportStatement ? { exported: true } : {}),
|
|
84
97
|
...(isMethod ? methodMetadata(cap.node) : { symbolKind: cap.name === "class" ? "class" : "function", moduleDeclaration: isModuleDeclaration(cap.node) })
|
|
85
98
|
});
|
|
99
|
+
if (exportStatement) {
|
|
100
|
+
recordJsExport({
|
|
101
|
+
kind: "local",
|
|
102
|
+
exported: /^\s*export\s+default\b/.test(exportStatement.text) ? "default" : cap.node.text,
|
|
103
|
+
local: cap.node.text,
|
|
104
|
+
typeOnly: false,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
86
107
|
}
|
|
87
108
|
for (const cap of caps(grammar, TOPVARS, tree.rootNode)) {
|
|
88
109
|
const nameNode = field(cap.node, "name"); if (!nameNode || nameNode.type !== "identifier") continue;
|
|
89
110
|
const val = field(cap.node, "value");
|
|
111
|
+
const exported = isExportedDecl(cap.node);
|
|
90
112
|
addSym(nameNode.text, nameNode.startPosition.row + 1, !!(val && CALLABLE.test(val.type)), {
|
|
91
113
|
sourceNode: val || cap.node,
|
|
92
|
-
...(
|
|
114
|
+
...(exported ? { exported: true } : {}),
|
|
93
115
|
symbolKind: "variable",
|
|
94
116
|
moduleDeclaration: true
|
|
95
117
|
});
|
|
118
|
+
if (exported) recordJsExport({ kind: "local", exported: nameNode.text, local: nameNode.text, typeOnly: false });
|
|
96
119
|
}
|
|
97
120
|
|
|
98
121
|
const importTypeOnly = (node) => {
|
|
@@ -127,9 +150,9 @@ export default {
|
|
|
127
150
|
addImportEdge(tgt, { typeOnly, line, specifier: rawSpec });
|
|
128
151
|
const clause = node.namedChildren.find((c) => c.type === "import_clause"); if (!clause) continue;
|
|
129
152
|
for (const c of clause.namedChildren) {
|
|
130
|
-
if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt, typeOnly });
|
|
131
|
-
else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt, typeOnly }); }
|
|
132
|
-
else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt, typeOnly: typeOnly || /^\s*type\b/.test(s.text) }); }
|
|
153
|
+
if (c.type === "identifier") imports.set(c.text, { imported: "default", targetFile: tgt, typeOnly, line, specifier: rawSpec });
|
|
154
|
+
else if (c.type === "namespace_import") { const id = c.namedChildren.find((x) => x.type === "identifier"); if (id) imports.set(id.text, { imported: "*", targetFile: tgt, typeOnly, line, specifier: rawSpec }); }
|
|
155
|
+
else if (c.type === "named_imports") for (const s of c.namedChildren) { if (s.type !== "import_specifier") continue; const nm = field(s, "name"), al = field(s, "alias"); if (nm) imports.set((al || nm).text, { imported: nm.text, targetFile: tgt, typeOnly: typeOnly || /^\s*type\b/.test(s.text), line, specifier: rawSpec }); }
|
|
133
156
|
}
|
|
134
157
|
}
|
|
135
158
|
|
|
@@ -140,8 +163,8 @@ export default {
|
|
|
140
163
|
const tgt = resolveJsImport(fileRel, cap.node.text); if (!tgt) continue;
|
|
141
164
|
addImportEdge(tgt, { typeOnly: false, line: cap.node.startPosition.row + 1, specifier: cap.node.text });
|
|
142
165
|
const lhs = dv && field(dv, "name");
|
|
143
|
-
if (lhs && lhs.type === "identifier") imports.set(lhs.text, { imported: "*", targetFile: tgt });
|
|
144
|
-
else if (lhs && lhs.type === "object_pattern") for (const p of lhs.namedChildren) { const key = field(p, "key") || (p.type === "shorthand_property_identifier_pattern" ? p : null); const val = field(p, "value"); const local = (val && val.type === "identifier") ? val : key; if (key && local) imports.set(local.text, { imported: key.text, targetFile: tgt }); }
|
|
166
|
+
if (lhs && lhs.type === "identifier") imports.set(lhs.text, { imported: "*", targetFile: tgt, line: cap.node.startPosition.row + 1, specifier: cap.node.text });
|
|
167
|
+
else if (lhs && lhs.type === "object_pattern") for (const p of lhs.namedChildren) { const key = field(p, "key") || (p.type === "shorthand_property_identifier_pattern" ? p : null); const val = field(p, "value"); const local = (val && val.type === "identifier") ? val : key; if (key && local) imports.set(local.text, { imported: key.text, targetFile: tgt, line: cap.node.startPosition.row + 1, specifier: cap.node.text }); }
|
|
145
168
|
}
|
|
146
169
|
|
|
147
170
|
// ---- CJS require, ALL forms (side-effect require("dotenv/config") included): bare-pkg records +
|
|
@@ -186,11 +209,47 @@ export default {
|
|
|
186
209
|
const line = node.startPosition.row + 1;
|
|
187
210
|
const typeOnly = reexportTypeOnly(node);
|
|
188
211
|
const tgt = resolveJsImport(fileRel, rawSpec);
|
|
189
|
-
if (tgt)
|
|
212
|
+
if (tgt) {
|
|
213
|
+
addImportEdge(tgt, { relation: "re_exports", typeOnly, line, specifier: rawSpec });
|
|
214
|
+
const text = node.text.trim();
|
|
215
|
+
const star = text.match(/^export\s+(type\s+)?\*\s+from\b/);
|
|
216
|
+
const namespace = text.match(/^export\s+(type\s+)?\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\b/);
|
|
217
|
+
const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}\s+from\b/);
|
|
218
|
+
if (namespace) {
|
|
219
|
+
recordJsExport({ kind: "namespace", exported: namespace[2], targetFile: tgt, typeOnly: !!namespace[1] });
|
|
220
|
+
} else if (star) {
|
|
221
|
+
recordJsExport({ kind: "star", targetFile: tgt, typeOnly: !!star[1] });
|
|
222
|
+
} else if (clause) {
|
|
223
|
+
for (const spec of parseExportSpecifiers(clause[2])) {
|
|
224
|
+
recordJsExport({ kind: "named", exported: spec.exported, imported: spec.imported, targetFile: tgt, typeOnly: !!clause[1] || spec.typeOnly });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
190
228
|
else if (isBareSpec(rawSpec)) addExternalImport({ spec: rawSpec, kind: "reexport", line, typeOnly });
|
|
191
229
|
else if (rawSpec) recordUnresolved(rawSpec, "reexport", line, typeOnly);
|
|
192
230
|
}
|
|
193
231
|
|
|
232
|
+
// Local aliases (`export { internal as publicName }`) and default identifiers are part of the
|
|
233
|
+
// same export table used by the post-pass barrel resolver. Sourced clauses were recorded above.
|
|
234
|
+
for (const cap of caps(grammar, `(export_statement) @exp`, tree.rootNode)) {
|
|
235
|
+
const node = cap.node;
|
|
236
|
+
if (field(node, "source")) continue;
|
|
237
|
+
const text = node.text.trim();
|
|
238
|
+
const clause = text.match(/^export\s+(type\s+)?\{([\s\S]*?)\}/);
|
|
239
|
+
if (clause) for (const spec of parseExportSpecifiers(clause[2])) {
|
|
240
|
+
recordJsExport({ kind: "local", exported: spec.exported, local: spec.imported, typeOnly: !!clause[1] || spec.typeOnly });
|
|
241
|
+
}
|
|
242
|
+
const identifierDefault = text.match(/^export\s+default\s+([A-Za-z_$][\w$]*)\s*;?$/);
|
|
243
|
+
if (identifierDefault) recordJsExport({ kind: "local", exported: "default", local: identifierDefault[1], typeOnly: false });
|
|
244
|
+
const typedDeclaration = text.match(/^export\s+(?:declare\s+)?(type|interface|enum)\s+([A-Za-z_$][\w$]*)\b/);
|
|
245
|
+
if (typedDeclaration) recordJsExport({
|
|
246
|
+
kind: "local",
|
|
247
|
+
exported: typedDeclaration[2],
|
|
248
|
+
local: typedDeclaration[2],
|
|
249
|
+
typeOnly: typedDeclaration[1] !== "enum",
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
194
253
|
// ---- export markers beyond declarations: `export { a, b }`, `export default X`, CJS module.exports ----
|
|
195
254
|
for (const cap of caps(grammar, `(export_statement (export_clause (export_specifier name: (identifier) @n)))`, tree.rootNode)) {
|
|
196
255
|
let st = cap.node.parent; while (st && st.type !== "export_statement") st = st.parent;
|
package/src/graph/community.js
CHANGED
|
@@ -15,3 +15,13 @@ export function communityTerritoryOf(file) {
|
|
|
15
15
|
const dirs = normalized.split("/").filter(Boolean).slice(0, -1);
|
|
16
16
|
return dirs.length ? dirs.slice(0, 2).join("/") : "(root)";
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
// Community ids are serialized into graph.json and therefore must not depend on which subset of
|
|
20
|
+
// files happened to be reparsed. Assign ids from the complete, sorted territory universe so a
|
|
21
|
+
// scoped incremental merge produces the same ids as a full build.
|
|
22
|
+
export function assignDeterministicCommunities(nodes = []) {
|
|
23
|
+
const territories = [...new Set(nodes.map((node) => communityTerritoryOf(node?.source_file)))].sort();
|
|
24
|
+
const ids = new Map(territories.map((territory, index) => [territory, index]));
|
|
25
|
+
for (const node of nodes) node.community = ids.get(communityTerritoryOf(node?.source_file));
|
|
26
|
+
return nodes;
|
|
27
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Small cross-process lock + atomic file replacement helpers for derived graph state. Multiple MCP
|
|
2
|
+
// clients commonly run at once (Claude, Codex, desktop); a plain read -> write sequence can lose a
|
|
3
|
+
// registry record or expose a half-written multi-megabyte graph to another reader.
|
|
4
|
+
import {mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync} from 'node:fs'
|
|
5
|
+
import {dirname} from 'node:path'
|
|
6
|
+
import process from 'node:process'
|
|
7
|
+
|
|
8
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
9
|
+
const waitSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
10
|
+
|
|
11
|
+
function clearStaleLock(lockDir, staleMs) {
|
|
12
|
+
try {
|
|
13
|
+
if (Date.now() - statSync(lockDir).mtimeMs <= staleMs) return false
|
|
14
|
+
try {
|
|
15
|
+
const ownerPid = Number(readFileSync(`${lockDir}/owner`, 'utf8').split(/\s+/)[0])
|
|
16
|
+
if (Number.isInteger(ownerPid) && ownerPid > 0) {
|
|
17
|
+
try { process.kill(ownerPid, 0); return false }
|
|
18
|
+
catch (error) { if (error?.code === 'EPERM') return false }
|
|
19
|
+
}
|
|
20
|
+
} catch { /* missing owner: stale age remains the authority */ }
|
|
21
|
+
rmSync(lockDir, {recursive: true, force: true})
|
|
22
|
+
return true
|
|
23
|
+
} catch { return false }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function acquired(lockDir) {
|
|
27
|
+
mkdirSync(dirname(lockDir), {recursive: true})
|
|
28
|
+
try {
|
|
29
|
+
mkdirSync(lockDir)
|
|
30
|
+
writeFileSync(`${lockDir}/owner`, `${process.pid}\n${new Date().toISOString()}\n`, 'utf8')
|
|
31
|
+
return true
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error?.code === 'EEXIST') return false
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function withFileLock(lockDir, fn, {timeoutMs = 10_000, staleMs = 60_000, pollMs = 25} = {}) {
|
|
39
|
+
const started = Date.now()
|
|
40
|
+
while (!acquired(lockDir)) {
|
|
41
|
+
clearStaleLock(lockDir, staleMs)
|
|
42
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`timed out waiting for derived-state lock: ${lockDir}`)
|
|
43
|
+
await wait(pollMs)
|
|
44
|
+
}
|
|
45
|
+
try { return await fn() }
|
|
46
|
+
finally { rmSync(lockDir, {recursive: true, force: true}) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function withFileLockSync(lockDir, fn, {timeoutMs = 10_000, staleMs = 60_000, pollMs = 20} = {}) {
|
|
50
|
+
const started = Date.now()
|
|
51
|
+
while (!acquired(lockDir)) {
|
|
52
|
+
clearStaleLock(lockDir, staleMs)
|
|
53
|
+
if (Date.now() - started >= timeoutMs) throw new Error(`timed out waiting for derived-state lock: ${lockDir}`)
|
|
54
|
+
waitSync(pollMs)
|
|
55
|
+
}
|
|
56
|
+
try { return fn() }
|
|
57
|
+
finally { rmSync(lockDir, {recursive: true, force: true}) }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function atomicWriteFileSync(path, data, encoding = undefined) {
|
|
61
|
+
mkdirSync(dirname(path), {recursive: true})
|
|
62
|
+
const temp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(temp, data, encoding)
|
|
65
|
+
renameSync(temp, path)
|
|
66
|
+
} finally {
|
|
67
|
+
try { rmSync(temp, {force: true}) } catch { /* already renamed */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Fast repository freshness probe. Full graph snapshots hash every source file and are still the
|
|
2
|
+
// authority when something changed; this exact Git/worktree token is safe to cache in-process and in
|
|
3
|
+
// versioned graph metadata when HEAD + dirty/untracked/control-file content remain unchanged.
|
|
4
|
+
import {createHash} from 'node:crypto'
|
|
5
|
+
import {spawnSync} from 'node:child_process'
|
|
6
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
7
|
+
import {createRequire} from 'node:module'
|
|
8
|
+
import {childProcessEnv} from '../child-env.js'
|
|
9
|
+
import {createRepoBoundary} from '../repo-path.js'
|
|
10
|
+
|
|
11
|
+
export const REPOSITORY_FRESHNESS_PROBE_V = 1
|
|
12
|
+
export const GRAPH_BUILDER_SCHEMA_V = 1
|
|
13
|
+
export const GRAPH_BUILDER_VERSION = (() => {
|
|
14
|
+
try { return String(createRequire(import.meta.url)('../../package.json').version) }
|
|
15
|
+
catch { return '0.0.0' }
|
|
16
|
+
})()
|
|
17
|
+
|
|
18
|
+
// These are the graph capabilities whose meaning is owned by the current builder. The package
|
|
19
|
+
// version invalidates stamps across releases; the explicit schema requirements also fail closed when
|
|
20
|
+
// a saved graph is hand-edited or a development build bumps a structural schema without a version bump.
|
|
21
|
+
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
|
+
extImportsV: 2,
|
|
23
|
+
edgeTypesV: 2,
|
|
24
|
+
complexityV: 1,
|
|
25
|
+
repoBoundaryV: 1,
|
|
26
|
+
barrelResolutionV: 1,
|
|
27
|
+
extractorSchemaV: 1,
|
|
28
|
+
})
|
|
29
|
+
const CONTROL_FILES = ['.gitignore', '.weavatrixignore', '.weavatrix.json']
|
|
30
|
+
const MAX_CONTROL_BYTES = 1_000_000
|
|
31
|
+
|
|
32
|
+
function git(repoRoot, args) {
|
|
33
|
+
const result = spawnSync('git', ['-C', repoRoot, ...args], {
|
|
34
|
+
encoding: 'buffer', timeout: 8000, windowsHide: true, env: childProcessEnv(), maxBuffer: 16 * 1024 * 1024,
|
|
35
|
+
})
|
|
36
|
+
return result.status === 0 ? Buffer.from(result.stdout || '') : null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function statusPaths(status) {
|
|
40
|
+
const records = status.toString('utf8').split('\0').filter(Boolean)
|
|
41
|
+
const paths = []
|
|
42
|
+
for (let index = 0; index < records.length; index++) {
|
|
43
|
+
const record = records[index]
|
|
44
|
+
if (record.length < 4) continue
|
|
45
|
+
paths.push(record.slice(3))
|
|
46
|
+
// In porcelain -z, rename/copy destinations are followed by the original path as a bare item.
|
|
47
|
+
if (/^[RC]/.test(record) || /^[RC]/.test(record.slice(1, 2))) {
|
|
48
|
+
if (records[index + 1]) paths.push(records[++index])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return [...new Set(paths)]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function repositoryFreshnessProbe(repoRoot) {
|
|
55
|
+
const head = git(repoRoot, ['rev-parse', '--verify', 'HEAD'])
|
|
56
|
+
const status = git(repoRoot, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=no'])
|
|
57
|
+
if (!head || !status) return null
|
|
58
|
+
const digest = createHash('sha256').update(head).update(status)
|
|
59
|
+
const boundary = createRepoBoundary(repoRoot)
|
|
60
|
+
for (const rel of statusPaths(status).sort()) {
|
|
61
|
+
digest.update(rel)
|
|
62
|
+
const resolved = boundary.resolve(rel)
|
|
63
|
+
if (!resolved.ok) { digest.update(`!${resolved.reason}`); continue }
|
|
64
|
+
try {
|
|
65
|
+
const stats = statSync(resolved.path)
|
|
66
|
+
if (!stats.isFile()) { digest.update('!not-file'); continue }
|
|
67
|
+
// Dirty sets are normally tiny. Hashing their content makes same-size rapid edits exact while
|
|
68
|
+
// still avoiding reads of every clean file in the repository.
|
|
69
|
+
digest.update(readFileSync(resolved.path))
|
|
70
|
+
} catch { digest.update('!unreadable') }
|
|
71
|
+
}
|
|
72
|
+
// Control files can intentionally be gitignored. Always include their bounded contents so a
|
|
73
|
+
// matching Git status can never hide a changed graph universe/classification policy.
|
|
74
|
+
for (const rel of CONTROL_FILES) {
|
|
75
|
+
digest.update(`control:${rel}\0`)
|
|
76
|
+
const resolved = boundary.resolve(rel)
|
|
77
|
+
if (!resolved.ok) { digest.update(`!${resolved.reason}`); continue }
|
|
78
|
+
try {
|
|
79
|
+
const stats = statSync(resolved.path)
|
|
80
|
+
if (!stats.isFile()) { digest.update('!not-file'); continue }
|
|
81
|
+
if (stats.size > MAX_CONTROL_BYTES) { digest.update('!oversized'); continue }
|
|
82
|
+
digest.update(readFileSync(resolved.path))
|
|
83
|
+
} catch { digest.update('!unreadable') }
|
|
84
|
+
}
|
|
85
|
+
return digest.digest('hex')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const isHash = (value) => /^[a-f0-9]{64}$/.test(String(value || ''))
|
|
89
|
+
|
|
90
|
+
export function graphSchemaIsCurrent(graph) {
|
|
91
|
+
return Boolean(graph) && Object.entries(CURRENT_GRAPH_SCHEMA)
|
|
92
|
+
.every(([key, expected]) => Number(graph[key]) === expected)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Returns true only when a graph stamp was produced by this exact builder contract for the active
|
|
96
|
+
// complete build mode. Legacy, scoped, non-Git, malformed and schema-old graphs all fall through to
|
|
97
|
+
// the authoritative repository snapshot path.
|
|
98
|
+
export function persistedFreshnessMatches(graph, probe, mode = graph?.graphBuildMode || 'full') {
|
|
99
|
+
if (!graph || !isHash(probe) || !graphSchemaIsCurrent(graph)) return false
|
|
100
|
+
if (Number(graph.repositoryFreshnessProbeV) !== REPOSITORY_FRESHNESS_PROBE_V) return false
|
|
101
|
+
if (Number(graph.repositoryFreshnessBuilderSchemaV) !== GRAPH_BUILDER_SCHEMA_V) return false
|
|
102
|
+
if (String(graph.repositoryFreshnessBuilderVersion || '') !== GRAPH_BUILDER_VERSION) return false
|
|
103
|
+
if (String(graph.repositoryFreshnessProbe || '') !== probe) return false
|
|
104
|
+
if (String(graph.repositoryFreshnessMode || '') !== String(mode || 'full')) return false
|
|
105
|
+
if (String(graph.graphBuildMode || '') !== String(mode || 'full')) return false
|
|
106
|
+
if (String(graph.graphBuildScope || '') !== '') return false
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Mutates the graph metadata and reports whether serialization is required. A null probe deliberately
|
|
111
|
+
// removes an older stamp: non-Git repos and repositories that changed while parsing must never inherit
|
|
112
|
+
// a stale fast-path token.
|
|
113
|
+
export function stampRepositoryFreshness(graph, probe, mode = graph?.graphBuildMode || 'full') {
|
|
114
|
+
if (!graph || typeof graph !== 'object') return false
|
|
115
|
+
const next = isHash(probe) && graphSchemaIsCurrent(graph) && !graph.graphBuildScope
|
|
116
|
+
? {
|
|
117
|
+
repositoryFreshnessProbeV: REPOSITORY_FRESHNESS_PROBE_V,
|
|
118
|
+
repositoryFreshnessBuilderSchemaV: GRAPH_BUILDER_SCHEMA_V,
|
|
119
|
+
repositoryFreshnessBuilderVersion: GRAPH_BUILDER_VERSION,
|
|
120
|
+
repositoryFreshnessProbe: probe,
|
|
121
|
+
repositoryFreshnessMode: String(mode || 'full'),
|
|
122
|
+
}
|
|
123
|
+
: null
|
|
124
|
+
const keys = [
|
|
125
|
+
'repositoryFreshnessProbeV',
|
|
126
|
+
'repositoryFreshnessBuilderSchemaV',
|
|
127
|
+
'repositoryFreshnessBuilderVersion',
|
|
128
|
+
'repositoryFreshnessProbe',
|
|
129
|
+
'repositoryFreshnessMode',
|
|
130
|
+
]
|
|
131
|
+
let changed = false
|
|
132
|
+
for (const key of keys) {
|
|
133
|
+
if (next && Object.hasOwn(next, key)) {
|
|
134
|
+
if (graph[key] !== next[key]) { graph[key] = next[key]; changed = true }
|
|
135
|
+
} else if (Object.hasOwn(graph, key)) {
|
|
136
|
+
delete graph[key]
|
|
137
|
+
changed = true
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return changed
|
|
141
|
+
}
|
|
@@ -1,44 +1,61 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Graph filters applied to a built graph.json. Test semantics come from the shared repository path
|
|
2
|
+
// classifier, so no-tests agrees with duplicate/audit/coverage tools (including repo config).
|
|
3
|
+
import { createPathClassifier, hasPathClass } from "../path-classification.js";
|
|
3
4
|
|
|
4
5
|
export function isTestPath(path) {
|
|
5
|
-
return
|
|
6
|
+
return hasPathClass(createPathClassifier(null).explain(path), "test", "e2e");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const normalizedPath = (value) => String(value || "").replace(/\\/g, "/");
|
|
10
|
+
|
|
11
|
+
function externalImportsForNodes(graph, nodes) {
|
|
12
|
+
if (!Array.isArray(graph.externalImports)) return undefined;
|
|
13
|
+
const files = new Set(nodes.map((node) => normalizedPath(node.source_file)).filter(Boolean));
|
|
14
|
+
return graph.externalImports.filter((item) => files.has(normalizedPath(item?.file)));
|
|
6
15
|
}
|
|
7
16
|
|
|
8
17
|
// Filter a built graph.json by test-mode: drop test nodes ("no-tests") or keep only tests + the
|
|
9
18
|
// nodes they depend on ("tests-only"). graph-builder update has no test filter, so we do it post-build.
|
|
10
|
-
export function filterGraphForMode(graph, mode) {
|
|
19
|
+
export function filterGraphForMode(graph, mode, { repoRoot = null } = {}) {
|
|
11
20
|
if (mode !== "no-tests" && mode !== "tests-only") return graph;
|
|
12
21
|
const nodes = graph.nodes || [];
|
|
13
22
|
const links = graph.links || [];
|
|
14
23
|
const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
|
|
24
|
+
const classifier = createPathClassifier(repoRoot);
|
|
25
|
+
const isTest = (path) => hasPathClass(classifier.explain(path), "test", "e2e");
|
|
15
26
|
let keep;
|
|
16
27
|
if (mode === "no-tests") {
|
|
17
|
-
keep = new Set(nodes.filter((node) => !
|
|
28
|
+
keep = new Set(nodes.filter((node) => !isTest(node.source_file)).map((node) => node.id));
|
|
18
29
|
} else {
|
|
19
|
-
const testIds = new Set(nodes.filter((node) =>
|
|
30
|
+
const testIds = new Set(nodes.filter((node) => isTest(node.source_file)).map((node) => node.id));
|
|
20
31
|
keep = new Set(testIds);
|
|
21
32
|
for (const link of links) {
|
|
22
33
|
if (testIds.has(endpoint(link.source))) keep.add(endpoint(link.target)); // a test's dependency
|
|
23
34
|
}
|
|
24
35
|
}
|
|
36
|
+
const keptNodes = nodes.filter((node) => keep.has(node.id));
|
|
37
|
+
const externalImports = externalImportsForNodes(graph, keptNodes);
|
|
25
38
|
return {
|
|
26
39
|
...graph,
|
|
27
|
-
nodes:
|
|
28
|
-
links: links.filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target)))
|
|
40
|
+
nodes: keptNodes,
|
|
41
|
+
links: links.filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
|
|
42
|
+
...(externalImports ? { externalImports } : {})
|
|
29
43
|
};
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
// Keep only nodes under a subpath (path-scope), drop links touching removed nodes.
|
|
33
47
|
export function filterGraphByScope(graph, scope) {
|
|
34
48
|
if (!scope) return graph;
|
|
35
|
-
const norm =
|
|
49
|
+
const norm = normalizedPath;
|
|
36
50
|
const prefix = norm(scope).replace(/\/+$/, "") + "/";
|
|
37
51
|
const endpoint = (value) => (value && typeof value === "object" ? value.id : value);
|
|
38
52
|
const keep = new Set((graph.nodes || []).filter((node) => (norm(node.source_file) + "/").startsWith(prefix)).map((node) => node.id));
|
|
53
|
+
const keptNodes = (graph.nodes || []).filter((node) => keep.has(node.id));
|
|
54
|
+
const externalImports = externalImportsForNodes(graph, keptNodes);
|
|
39
55
|
return {
|
|
40
56
|
...graph,
|
|
41
|
-
nodes:
|
|
42
|
-
links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target)))
|
|
57
|
+
nodes: keptNodes,
|
|
58
|
+
links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
|
|
59
|
+
...(externalImports ? { externalImports } : {})
|
|
43
60
|
};
|
|
44
61
|
}
|