weavatrix 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -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
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Conservative incremental graph refresh. This module never writes graph.json: it either returns the
|
|
2
|
+
// untouched complete graph, a fully rebuilt graph, or a complete merge of a bounded scoped parse into
|
|
3
|
+
// the previous graph. Callers own serialization/locking.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { extname, relative } from "node:path";
|
|
7
|
+
import { walk } from "./internal-builder.langs.js";
|
|
8
|
+
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { assignDeterministicCommunities } from "./community.js";
|
|
10
|
+
|
|
11
|
+
const JS_EXT = new Set([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"]);
|
|
12
|
+
const CONFIG_RISK = /(^|\/)(package(?:-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|tsconfig(?:\.[^/]*)?\.json|jsconfig(?:\.[^/]*)?\.json|go\.(?:mod|sum)|Cargo\.(?:toml|lock)|pom\.xml|build\.gradle(?:\.kts)?|vite\.config\.[^/]+|webpack\.config\.[^/]+|babel\.config\.[^/]+)$/i;
|
|
13
|
+
const CONTROL_FILES = [".gitignore", ".weavatrixignore", ".weavatrix.json"];
|
|
14
|
+
const MAX_CONTROL_BYTES = 1_000_000;
|
|
15
|
+
|
|
16
|
+
const norm = (value) => String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
17
|
+
const hash = (value) => createHash("sha256").update(value).digest("hex");
|
|
18
|
+
const endpoint = (value) => String(value && typeof value === "object" ? value.id : value);
|
|
19
|
+
const fileOfEndpoint = (value, nodesById) => {
|
|
20
|
+
const id = endpoint(value);
|
|
21
|
+
const source = nodesById.get(id)?.source_file;
|
|
22
|
+
if (source) return norm(source);
|
|
23
|
+
const marker = id.indexOf("#");
|
|
24
|
+
return norm(marker < 0 ? id : id.slice(0, marker));
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// We only need to know whether a module's public surface changed, not understand its implementation.
|
|
28
|
+
// The full tree-sitter pass remains authoritative; this signature is deliberately conservative and
|
|
29
|
+
// whitespace-insensitive. Any changed export statement forces a full rebuild.
|
|
30
|
+
export function jsExportSignature(text, file = "") {
|
|
31
|
+
if (!JS_EXT.has(extname(file))) return "";
|
|
32
|
+
const source = String(text || "");
|
|
33
|
+
const exports = [];
|
|
34
|
+
const pattern = /\bexport\s+(?:declare\s+)?(?:type\s+)?(?:\*\s+(?:as\s+[A-Za-z_$][\w$]*\s+)?from\s*["'][^"']+["']|\{[\s\S]*?\}(?:\s+from\s*["'][^"']+["'])?|default\s+(?:(?:async\s+)?function\s*\*?|class)?\s*[A-Za-z_$]?[\w$]*|(?:async\s+)?function\s*\*?\s+[A-Za-z_$][\w$]*|class\s+[A-Za-z_$][\w$]*|(?:const|let|var|interface|type|enum)\s+[A-Za-z_$][\w$]*)/g;
|
|
35
|
+
for (const match of source.matchAll(pattern)) exports.push(match[0].replace(/\s+/g, " ").trim());
|
|
36
|
+
// The general matcher intentionally stops before implementation bodies. Preserve every binding name
|
|
37
|
+
// in multi-declarator exports (`export const a = 1, b = 2`) so adding/removing one cannot slip through.
|
|
38
|
+
for (const match of source.matchAll(/\bexport\s+(?:declare\s+)?(?:const|let|var)\s+([^;\n]+)/g)) {
|
|
39
|
+
const names = [];
|
|
40
|
+
for (const part of match[1].split(",")) {
|
|
41
|
+
const declared = part.trim().match(/^([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=/);
|
|
42
|
+
if (declared) names.push(declared[1]);
|
|
43
|
+
}
|
|
44
|
+
exports.push(`bindings:${names.join(",")}`);
|
|
45
|
+
}
|
|
46
|
+
return hash(exports.join("\n"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function snapshotRepository(repoDir, files = walk(repoDir)) {
|
|
50
|
+
const root = String(repoDir);
|
|
51
|
+
const fileHashes = {};
|
|
52
|
+
const fileExportSignatures = {};
|
|
53
|
+
const relativeFiles = [];
|
|
54
|
+
for (const abs of files) {
|
|
55
|
+
const rel = norm(relative(root, abs));
|
|
56
|
+
let body;
|
|
57
|
+
try { body = readFileSync(abs); } catch { continue; }
|
|
58
|
+
relativeFiles.push(rel);
|
|
59
|
+
fileHashes[rel] = hash(body);
|
|
60
|
+
if (JS_EXT.has(extname(rel))) fileExportSignatures[rel] = jsExportSignature(body.toString("utf8"), rel);
|
|
61
|
+
}
|
|
62
|
+
relativeFiles.sort();
|
|
63
|
+
const controlHashes = {};
|
|
64
|
+
const boundary = createRepoBoundary(root);
|
|
65
|
+
for (const rel of CONTROL_FILES) {
|
|
66
|
+
const resolved = boundary.resolve(rel);
|
|
67
|
+
if (!resolved.ok) {
|
|
68
|
+
controlHashes[rel] = resolved.reason === "not-found" ? null : `UNREADABLE:${resolved.reason}`;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const stats = statSync(resolved.path);
|
|
73
|
+
controlHashes[rel] = stats.isFile() && stats.size <= MAX_CONTROL_BYTES
|
|
74
|
+
? hash(readFileSync(resolved.path))
|
|
75
|
+
: stats.isFile() ? "UNREADABLE:oversized" : "UNREADABLE:not-file";
|
|
76
|
+
} catch {
|
|
77
|
+
controlHashes[rel] = "UNREADABLE:unreadable";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const revision = hash([
|
|
81
|
+
...relativeFiles.map((file) => `${file}:${fileHashes[file]}`),
|
|
82
|
+
...Object.keys(controlHashes).sort().map((file) => `${file}:${controlHashes[file] ?? "missing"}`),
|
|
83
|
+
].join("\n"));
|
|
84
|
+
return { files, relativeFiles, fileHashes, fileExportSignatures, controlHashes, revision };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sameRecord(left, right) {
|
|
88
|
+
const a = left && typeof left === "object" ? left : {};
|
|
89
|
+
const b = right && typeof right === "object" ? right : {};
|
|
90
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
91
|
+
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function changedPaths(previous, current) {
|
|
96
|
+
const keys = new Set([...Object.keys(previous || {}), ...Object.keys(current || {})]);
|
|
97
|
+
return [...keys].filter((file) => previous?.[file] !== current?.[file]).sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isBarrelFile(graph, file) {
|
|
101
|
+
const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
102
|
+
return (graph.links || []).some((link) => {
|
|
103
|
+
const sourceFile = fileOfEndpoint(link.source, nodesById);
|
|
104
|
+
return sourceFile === file && (link.relation === "re_exports" || link.barrelProxy === true);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function reverseImporters(graph, changed) {
|
|
109
|
+
const changedSet = new Set(changed);
|
|
110
|
+
const nodesById = new Map((graph.nodes || []).map((node) => [String(node.id), node]));
|
|
111
|
+
const result = new Set();
|
|
112
|
+
for (const link of graph.links || []) {
|
|
113
|
+
const targetFile = fileOfEndpoint(link.target, nodesById);
|
|
114
|
+
if (!changedSet.has(targetFile)) continue;
|
|
115
|
+
const sourceFile = fileOfEndpoint(link.source, nodesById);
|
|
116
|
+
if (sourceFile && sourceFile !== targetFile) result.add(sourceFile);
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mergeScopedGraph(base, scoped, affected, snapshot) {
|
|
122
|
+
const affectedSet = new Set(affected);
|
|
123
|
+
const baseNodes = Array.isArray(base.nodes) ? base.nodes : [];
|
|
124
|
+
const scopedNodes = Array.isArray(scoped.nodes) ? scoped.nodes : [];
|
|
125
|
+
const baseNodesById = new Map(baseNodes.map((node) => [String(node.id), node]));
|
|
126
|
+
const nodeFile = (node) => norm(node?.source_file || fileOfEndpoint(node?.id, baseNodesById));
|
|
127
|
+
const nodes = [...baseNodes.filter((node) => !affectedSet.has(nodeFile(node))), ...scopedNodes];
|
|
128
|
+
assignDeterministicCommunities(nodes);
|
|
129
|
+
const nodesById = new Map(nodes.map((node) => [String(node.id), node]));
|
|
130
|
+
if (nodesById.size !== nodes.length) throw new Error("incremental merge produced duplicate node ids");
|
|
131
|
+
|
|
132
|
+
const baseLinkKept = (link) => {
|
|
133
|
+
const sourceFile = fileOfEndpoint(link.source, baseNodesById);
|
|
134
|
+
// A scoped rebuild owns every outgoing edge of an affected source. Incoming edges from an
|
|
135
|
+
// unaffected source still describe that source and must survive (A -> B -> changed C reparses
|
|
136
|
+
// B+C, but A -> B is not regenerated by the scoped builder). The node-id filter below safely
|
|
137
|
+
// removes an incoming edge when its affected target symbol no longer exists.
|
|
138
|
+
return !affectedSet.has(sourceFile);
|
|
139
|
+
};
|
|
140
|
+
// Base and scoped links are disjoint by source file: every scoped source was removed above. Keep
|
|
141
|
+
// repeated call/reference occurrences intact because occurrence hotspots intentionally use them.
|
|
142
|
+
const links = [...(base.links || []).filter(baseLinkKept), ...(scoped.links || [])]
|
|
143
|
+
.filter((link) => nodesById.has(endpoint(link.source)) && nodesById.has(endpoint(link.target)));
|
|
144
|
+
|
|
145
|
+
const externalImports = [
|
|
146
|
+
...(base.externalImports || []).filter((record) => !affectedSet.has(norm(record?.file))),
|
|
147
|
+
...(scoped.externalImports || []),
|
|
148
|
+
];
|
|
149
|
+
const fileNodes = new Set(nodes.filter((node) => !String(node.id).includes("#")).map((node) => norm(node.source_file || node.id)));
|
|
150
|
+
if (fileNodes.size !== snapshot.relativeFiles.length || snapshot.relativeFiles.some((file) => !fileNodes.has(file))) {
|
|
151
|
+
throw new Error("incremental merge did not preserve the complete file universe");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const merged = {
|
|
155
|
+
...base,
|
|
156
|
+
nodes,
|
|
157
|
+
links,
|
|
158
|
+
externalImports,
|
|
159
|
+
jsExportRecords: scoped.jsExportRecords || base.jsExportRecords || {},
|
|
160
|
+
fileHashes: snapshot.fileHashes,
|
|
161
|
+
fileExportSignatures: snapshot.fileExportSignatures,
|
|
162
|
+
controlHashes: snapshot.controlHashes,
|
|
163
|
+
graphRevision: snapshot.revision,
|
|
164
|
+
};
|
|
165
|
+
for (const key of ["extImportsV", "edgeTypesV", "complexityV", "repoBoundaryV", "barrelResolutionV", "extractorSchemaV"]) {
|
|
166
|
+
merged[key] = Math.max(Number(base[key]) || 0, Number(scoped[key]) || 0);
|
|
167
|
+
}
|
|
168
|
+
return merged;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function refreshGraphIncrementally(repoDir, existingGraph, {
|
|
172
|
+
buildGraph,
|
|
173
|
+
maxChangedFiles = 24,
|
|
174
|
+
maxParsedFiles = 80,
|
|
175
|
+
builderOptions = {},
|
|
176
|
+
} = {}) {
|
|
177
|
+
const builder = buildGraph || (await import("./internal-builder.js")).buildInternalGraph;
|
|
178
|
+
const snapshot = snapshotRepository(repoDir);
|
|
179
|
+
const full = async (reason, changedFiles = []) => {
|
|
180
|
+
const graph = await builder(repoDir, builderOptions);
|
|
181
|
+
return { graph, kind: "full", changedFiles, reason, revision: graph.graphRevision || snapshot.revision, parsedFiles: snapshot.relativeFiles };
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
if (!existingGraph || !existingGraph.fileHashes || !existingGraph.fileExportSignatures
|
|
185
|
+
|| !existingGraph.controlHashes || !existingGraph.jsExportRecords || Number(existingGraph.barrelResolutionV) < 1
|
|
186
|
+
|| Number(existingGraph.extractorSchemaV) < 1) {
|
|
187
|
+
return full("incremental-baseline-unavailable");
|
|
188
|
+
}
|
|
189
|
+
if (!sameRecord(existingGraph.controlHashes, snapshot.controlHashes)) return full("ignore-or-control-config-changed");
|
|
190
|
+
|
|
191
|
+
const oldFiles = Object.keys(existingGraph.fileHashes).sort();
|
|
192
|
+
if (oldFiles.length !== snapshot.relativeFiles.length || oldFiles.some((file, index) => file !== snapshot.relativeFiles[index])) {
|
|
193
|
+
const changedFiles = changedPaths(existingGraph.fileHashes, snapshot.fileHashes);
|
|
194
|
+
return full("file-universe-changed", changedFiles);
|
|
195
|
+
}
|
|
196
|
+
const changedFiles = changedPaths(existingGraph.fileHashes, snapshot.fileHashes);
|
|
197
|
+
if (!changedFiles.length) {
|
|
198
|
+
return { graph: existingGraph, kind: "none", changedFiles: [], reason: "content-unchanged", revision: snapshot.revision, parsedFiles: [] };
|
|
199
|
+
}
|
|
200
|
+
if (changedFiles.length > maxChangedFiles) return full("changed-set-too-large", changedFiles);
|
|
201
|
+
if (changedFiles.some((file) => CONFIG_RISK.test(file))) return full("config-manifest-or-alias-changed", changedFiles);
|
|
202
|
+
if (changedFiles.some((file) => !JS_EXT.has(extname(file)))) return full("language-requires-full-context", changedFiles);
|
|
203
|
+
for (const file of changedFiles) {
|
|
204
|
+
if (isBarrelFile(existingGraph, file)) return full(`barrel-file-changed:${file}`, changedFiles);
|
|
205
|
+
if (existingGraph.fileExportSignatures[file] !== snapshot.fileExportSignatures[file]) {
|
|
206
|
+
return full(`export-surface-changed:${file}`, changedFiles);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const importers = reverseImporters(existingGraph, changedFiles);
|
|
211
|
+
const affected = [...new Set([...changedFiles, ...importers])].filter((file) => snapshot.fileHashes[file]).sort();
|
|
212
|
+
if (affected.length > maxParsedFiles) return full("reverse-importer-set-too-large", changedFiles);
|
|
213
|
+
try {
|
|
214
|
+
const scoped = await builder(repoDir, {
|
|
215
|
+
...builderOptions,
|
|
216
|
+
includeFiles: affected,
|
|
217
|
+
baseGraph: existingGraph,
|
|
218
|
+
});
|
|
219
|
+
if (scoped.incrementalScope !== true) return full("builder-did-not-honor-incremental-scope", changedFiles);
|
|
220
|
+
const graph = mergeScopedGraph(existingGraph, scoped, affected, snapshot);
|
|
221
|
+
return {
|
|
222
|
+
graph,
|
|
223
|
+
kind: "incremental",
|
|
224
|
+
changedFiles,
|
|
225
|
+
reason: importers.size ? "changed-files-and-reverse-importers" : "changed-files-only",
|
|
226
|
+
revision: snapshot.revision,
|
|
227
|
+
parsedFiles: affected,
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return full("incremental-merge-safety-fallback", changedFiles);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// TypeScript/JavaScript barrel transparency. The parser keeps physical import/re-export edges for
|
|
2
|
+
// execution-order and cycle analysis; this post-pass resolves public export names to their declaring
|
|
3
|
+
// files, marks the physical facade hops as proxies, and adds direct semantic importer -> origin edges.
|
|
4
|
+
// Resolution follows ESM rules that matter for a static graph: explicit exports shadow `export *`,
|
|
5
|
+
// `default` never flows through a star, cycles terminate, and conflicting star origins stay ambiguous.
|
|
6
|
+
|
|
7
|
+
const resolved = (file, name, typeOnly = false) => ({ status: "resolved", origin: { file, name, typeOnly } });
|
|
8
|
+
const MISSING = Object.freeze({ status: "missing" });
|
|
9
|
+
const AMBIGUOUS = Object.freeze({ status: "ambiguous" });
|
|
10
|
+
|
|
11
|
+
function mergeCandidates(candidates) {
|
|
12
|
+
if (candidates.some((candidate) => candidate.status === "ambiguous")) return AMBIGUOUS;
|
|
13
|
+
const origins = new Map();
|
|
14
|
+
for (const candidate of candidates) {
|
|
15
|
+
if (candidate.status !== "resolved") continue;
|
|
16
|
+
const key = `${candidate.origin.file}\0${candidate.origin.name}`;
|
|
17
|
+
const current = origins.get(key);
|
|
18
|
+
if (!current) origins.set(key, { ...candidate.origin });
|
|
19
|
+
else current.typeOnly = current.typeOnly && candidate.origin.typeOnly;
|
|
20
|
+
}
|
|
21
|
+
if (!origins.size) return MISSING;
|
|
22
|
+
if (origins.size > 1) return AMBIGUOUS;
|
|
23
|
+
return { status: "resolved", origin: [...origins.values()][0] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function withTypeOnly(result, typeOnly) {
|
|
27
|
+
if (result.status !== "resolved" || !typeOnly) return result;
|
|
28
|
+
return resolved(result.origin.file, result.origin.name, true);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveJsBarrels({ jsExports, importedLocals, links }) {
|
|
32
|
+
const tables = new Map();
|
|
33
|
+
for (const [file, records] of jsExports) {
|
|
34
|
+
const table = { explicit: new Map(), stars: [] };
|
|
35
|
+
for (const record of records || []) {
|
|
36
|
+
if (record.kind === "star") {
|
|
37
|
+
table.stars.push(record);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (!record.exported) continue;
|
|
41
|
+
const list = table.explicit.get(record.exported) || [];
|
|
42
|
+
list.push(record);
|
|
43
|
+
table.explicit.set(record.exported, list);
|
|
44
|
+
}
|
|
45
|
+
tables.set(file, table);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const cache = new Map();
|
|
49
|
+
const resolveExport = (file, name, trail = new Set()) => {
|
|
50
|
+
const key = `${file}\0${name}`;
|
|
51
|
+
const rootResolution = trail.size === 0;
|
|
52
|
+
if (cache.has(key)) return cache.get(key);
|
|
53
|
+
if (trail.has(key)) return MISSING;
|
|
54
|
+
const table = tables.get(file);
|
|
55
|
+
if (!table) return MISSING;
|
|
56
|
+
const nextTrail = new Set(trail);
|
|
57
|
+
nextTrail.add(key);
|
|
58
|
+
|
|
59
|
+
const explicit = table.explicit.get(name) || [];
|
|
60
|
+
if (explicit.length) {
|
|
61
|
+
const candidates = explicit.map((record) => {
|
|
62
|
+
if (record.kind === "named") {
|
|
63
|
+
return withTypeOnly(resolveExport(record.targetFile, record.imported, nextTrail), record.typeOnly);
|
|
64
|
+
}
|
|
65
|
+
if (record.kind === "namespace") return resolved(record.targetFile, "*", record.typeOnly);
|
|
66
|
+
const local = record.local || name;
|
|
67
|
+
const binding = importedLocals.get(file)?.get(local);
|
|
68
|
+
if (binding?.targetFile && binding.imported && binding.imported !== "*") {
|
|
69
|
+
return withTypeOnly(resolveExport(binding.targetFile, binding.imported, nextTrail), record.typeOnly || binding.typeOnly);
|
|
70
|
+
}
|
|
71
|
+
// Type/interface declarations are intentionally not symbol nodes yet, but their declaring file is
|
|
72
|
+
// still the correct semantic origin for file/module dependency analysis.
|
|
73
|
+
return resolved(file, local, record.typeOnly);
|
|
74
|
+
});
|
|
75
|
+
const result = mergeCandidates(candidates);
|
|
76
|
+
if (rootResolution) cache.set(key, result);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (name === "default") {
|
|
81
|
+
if (rootResolution) cache.set(key, MISSING);
|
|
82
|
+
return MISSING;
|
|
83
|
+
}
|
|
84
|
+
const candidates = table.stars.map((star) => withTypeOnly(resolveExport(star.targetFile, name, nextTrail), star.typeOnly));
|
|
85
|
+
const result = mergeCandidates(candidates);
|
|
86
|
+
if (rootResolution) cache.set(key, result);
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Every internal re-export is a physical facade hop. It remains in the graph for runtime cycle truth,
|
|
91
|
+
// but semantic consumers ignore it in favor of importer -> declaration edges below.
|
|
92
|
+
for (const link of links) {
|
|
93
|
+
if (link.relation !== "re_exports") continue;
|
|
94
|
+
const records = jsExports.get(String(link.source)) || [];
|
|
95
|
+
if (records.some((record) => record.targetFile === String(link.target))) link.barrelProxy = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const semanticEdges = new Map();
|
|
99
|
+
const addSemanticEdge = (source, imp, origin, usage = null, force = false) => {
|
|
100
|
+
if (!origin?.file || (!force && origin.file === imp.targetFile)) return;
|
|
101
|
+
const key = `${source}\0${origin.file}\0${imp.line || 0}`;
|
|
102
|
+
const effectiveTypeOnly = imp.typeOnly === true || origin.typeOnly === true;
|
|
103
|
+
const current = semanticEdges.get(key);
|
|
104
|
+
if (current) {
|
|
105
|
+
if (!effectiveTypeOnly) delete current.typeOnly;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
semanticEdges.set(key, {
|
|
109
|
+
source,
|
|
110
|
+
target: origin.file,
|
|
111
|
+
relation: "imports",
|
|
112
|
+
confidence: "EXTRACTED",
|
|
113
|
+
semanticOrigin: true,
|
|
114
|
+
viaBarrel: imp.targetFile,
|
|
115
|
+
...(effectiveTypeOnly ? { typeOnly: true } : {}),
|
|
116
|
+
...(imp.line ? { line: imp.line } : {}),
|
|
117
|
+
...(imp.specifier ? { specifier: imp.specifier } : {}),
|
|
118
|
+
...(usage ? { usage } : {}),
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const proxiedImportGroups = new Set();
|
|
123
|
+
for (const [source, imports] of importedLocals) {
|
|
124
|
+
for (const imp of imports.values()) {
|
|
125
|
+
if (!imp?.targetFile || imp.imported === "*") continue;
|
|
126
|
+
const result = resolveExport(imp.targetFile, imp.imported);
|
|
127
|
+
if (result.status !== "resolved") continue;
|
|
128
|
+
imp.originFile = result.origin.file;
|
|
129
|
+
imp.originName = result.origin.name;
|
|
130
|
+
imp.originTypeOnly = result.origin.typeOnly === true;
|
|
131
|
+
if (result.origin.file === imp.targetFile) continue;
|
|
132
|
+
proxiedImportGroups.add(`${source}\0${imp.targetFile}\0${imp.line || 0}`);
|
|
133
|
+
for (const link of links) {
|
|
134
|
+
if (String(link.source) === source && String(link.target) === imp.targetFile && link.relation === "imports"
|
|
135
|
+
&& (!imp.line || !link.line || link.line === imp.line)) link.barrelProxy = true;
|
|
136
|
+
}
|
|
137
|
+
addSemanticEdge(source, imp, result.origin);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// One import statement can mix a barrel-local export with re-exported bindings. Once its physical
|
|
141
|
+
// file edge becomes a proxy, preserve the local binding as its own semantic edge to the barrel file.
|
|
142
|
+
for (const [source, imports] of importedLocals) for (const imp of imports.values()) {
|
|
143
|
+
if (!imp?.originFile || imp.originFile !== imp.targetFile) continue;
|
|
144
|
+
if (!proxiedImportGroups.has(`${source}\0${imp.targetFile}\0${imp.line || 0}`)) continue;
|
|
145
|
+
addSemanticEdge(source, imp, { file: imp.originFile, name: imp.originName, typeOnly: imp.originTypeOnly }, null, true);
|
|
146
|
+
}
|
|
147
|
+
links.push(...semanticEdges.values());
|
|
148
|
+
|
|
149
|
+
// Namespace member usage is only knowable in pass 2 (`ui.Button`, `ui.run`). Expose a bounded helper
|
|
150
|
+
// that uses the same cache and emits the corresponding semantic file edge exactly once.
|
|
151
|
+
const resolveNamespaceMember = (source, imp, member, usage = null) => {
|
|
152
|
+
if (!imp?.targetFile || imp.imported !== "*") return MISSING;
|
|
153
|
+
const result = resolveExport(imp.targetFile, member);
|
|
154
|
+
if (result.status !== "resolved") return result;
|
|
155
|
+
if (result.origin.file !== imp.targetFile) {
|
|
156
|
+
for (const link of links) {
|
|
157
|
+
if (String(link.source) === source && String(link.target) === imp.targetFile && link.relation === "imports"
|
|
158
|
+
&& (!imp.line || !link.line || link.line === imp.line)) link.barrelProxy = true;
|
|
159
|
+
}
|
|
160
|
+
addSemanticEdge(source, imp, result.origin, usage);
|
|
161
|
+
// addSemanticEdge may have created a new entry after the initial push.
|
|
162
|
+
const edge = semanticEdges.get(`${source}\0${result.origin.file}\0${imp.line || 0}`);
|
|
163
|
+
if (edge && !links.includes(edge)) links.push(edge);
|
|
164
|
+
}
|
|
165
|
+
return result;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
return { resolveExport, resolveNamespaceMember };
|
|
169
|
+
}
|