weavatrix 0.2.15 → 0.2.18
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 +99 -13
- package/docs/adr/0001-v0.3-offline-online-split.md +58 -0
- package/docs/releases/v0.2.18.md +41 -0
- package/package.json +7 -2
- package/src/analysis/cargo-dependency-evidence.js +105 -0
- package/src/analysis/cargo-manifests.js +74 -0
- package/src/analysis/dep-check-ecosystems.js +3 -0
- package/src/analysis/dependency/scoped-dependencies.js +13 -7
- package/src/analysis/duplicates.tokenize.js +13 -6
- package/src/analysis/endpoints-java.js +2 -5
- package/src/analysis/findings.js +12 -0
- package/src/analysis/git-history/options.js +2 -4
- package/src/analysis/go-dependency-evidence.js +62 -0
- package/src/analysis/health-capabilities.js +1 -1
- package/src/analysis/hot-path-review.js +2 -4
- package/src/analysis/http-contracts/analysis.js +2 -0
- package/src/analysis/http-contracts/client-call-detection.js +1 -0
- package/src/analysis/http-contracts/client-call-parser.js +18 -4
- package/src/analysis/http-contracts/shared.js +3 -4
- package/src/analysis/internal-audit/dependency-health.js +15 -13
- package/src/analysis/internal-audit/python-manifests.js +18 -5
- package/src/analysis/internal-audit/repo-files.js +5 -6
- package/src/analysis/internal-audit/supply-chain.js +2 -0
- package/src/analysis/internal-audit.reach.js +14 -0
- package/src/analysis/internal-audit.run.js +15 -10
- package/src/analysis/jvm-dependency-evidence.js +102 -56
- package/src/analysis/jvm-manifests.js +114 -0
- package/src/analysis/manifests.js +0 -49
- package/src/analysis/source-correctness.js +2 -5
- package/src/analysis/structure/dependency-graph.js +3 -8
- package/src/analysis/transport-contracts.js +157 -0
- package/src/graph/builder/lang-go.js +4 -2
- package/src/graph/builder/lang-java.js +12 -3
- package/src/graph/builder/lang-rust.js +22 -3
- package/src/graph/freshness-probe.js +1 -1
- package/src/graph/internal-builder.build.js +2 -2
- package/src/graph/internal-builder.langs.js +19 -0
- package/src/graph/internal-builder.resolvers.js +18 -8
- package/src/graph/node-id.js +7 -0
- package/src/mcp/actions/advisories.mjs +2 -3
- package/src/mcp/architecture-bootstrap.mjs +3 -3
- package/src/mcp/catalog.mjs +7 -4
- package/src/mcp/company-contract-verdict.mjs +42 -0
- package/src/mcp/evidence-snapshot.common.mjs +1 -3
- package/src/mcp/evidence-snapshot.health.mjs +11 -4
- package/src/mcp/evidence-snapshot.mjs +1 -1
- package/src/mcp/graph-diff.mjs +2 -1
- package/src/mcp/tools-architecture.mjs +2 -6
- package/src/mcp/tools-company.mjs +28 -53
- package/src/mcp/tools-impact-change.mjs +3 -8
- package/src/mcp/tools-impact-precision.mjs +89 -0
- package/src/mcp/tools-impact.mjs +52 -94
- package/src/precision/lsp-overlay/build.js +10 -0
- package/src/precision/lsp-overlay/contract.js +1 -1
- package/src/precision/symbol-query.js +39 -0
- package/src/precision/typescript-provider/client.js +16 -3
- package/src/precision/typescript-provider/discovery.js +1 -1
- package/src/precision/typescript-provider/isolated-runtime.js +39 -0
- package/src/precision/typescript-provider/project-host.js +11 -6
- package/src/precision/typescript-provider/project-safety.js +5 -0
- package/src/security/advisory-store.js +2 -2
- package/src/security/installed-jvm-rust.js +46 -0
- package/src/security/installed.js +21 -1
- package/src/security/typosquat.js +15 -6
- package/src/util.js +13 -0
- package/docs/releases/v0.2.15.md +0 -37
|
@@ -113,7 +113,7 @@ export default {
|
|
|
113
113
|
],
|
|
114
114
|
|
|
115
115
|
pass1(ctx) {
|
|
116
|
-
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
116
|
+
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, addExternalImport, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
117
117
|
const owned = [];
|
|
118
118
|
for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
|
|
119
119
|
for (const cap of caps(grammar, src, tree.rootNode)) {
|
|
@@ -175,7 +175,13 @@ export default {
|
|
|
175
175
|
const ancestors = inlineAncestors(use);
|
|
176
176
|
for (const leaf of useLeaves(use)) {
|
|
177
177
|
const resolved = resolveRustPath(fileRel, leaf.segments, { inlineModules: ancestors, unqualified: true });
|
|
178
|
-
if (!resolved)
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
const crate = cleanSegment(leaf.segments[0]);
|
|
180
|
+
if (crate && !["crate", "self", "super", "std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
181
|
+
addExternalImport({ spec: leaf.segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-use", line: use.startPosition.row + 1 });
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
179
185
|
emit(resolved.targetFile, { relation, line: use.startPosition.row + 1, specifier: use.text.replace(/;\s*$/, "") });
|
|
180
186
|
if (!leaf.wildcard && leaf.local && leaf.local !== "_") {
|
|
181
187
|
const imported = resolved.remaining.length ? cleanSegment(resolved.remaining.at(-1)) : "*";
|
|
@@ -192,7 +198,13 @@ export default {
|
|
|
192
198
|
if (under(node, "use_declaration")) continue;
|
|
193
199
|
if (["scoped_identifier", "scoped_type_identifier"].includes(node.parent?.type)) continue;
|
|
194
200
|
const segments = pathParts(node);
|
|
195
|
-
if (!["crate", "self", "super"].includes(segments[0]))
|
|
201
|
+
if (!["crate", "self", "super"].includes(segments[0])) {
|
|
202
|
+
const crate = cleanSegment(segments[0]);
|
|
203
|
+
if (crate && !["std", "core", "alloc"].includes(crate) && /^[a-z_][\w]*$/.test(crate)) {
|
|
204
|
+
addExternalImport({ spec: segments.join("::"), pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-path", line: node.startPosition.row + 1 });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
196
208
|
const resolved = resolveRustPath(fileRel, segments, { inlineModules: inlineAncestors(node), unqualified: false });
|
|
197
209
|
if (!resolved) continue;
|
|
198
210
|
emit(resolved.targetFile, { line: node.startPosition.row + 1, specifier: node.text });
|
|
@@ -201,5 +213,12 @@ export default {
|
|
|
201
213
|
imports.set(finalName, { imported: finalName, targetFile: resolved.targetFile, rustQualified: true });
|
|
202
214
|
}
|
|
203
215
|
}
|
|
216
|
+
|
|
217
|
+
for (const cap of caps(grammar, `(extern_crate_declaration name: (identifier) @crate)`, tree.rootNode)) {
|
|
218
|
+
const crate = cleanSegment(cap.node.text);
|
|
219
|
+
if (crate && !["std", "core", "alloc"].includes(crate)) {
|
|
220
|
+
addExternalImport({ spec: crate, pkg: crate.replace(/_/g, "-"), builtin: false, ecosystem: "crates.io", kind: "rust-extern-crate", line: cap.node.startPosition.row + 1 });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
204
223
|
},
|
|
205
224
|
};
|
|
@@ -19,7 +19,7 @@ export const GRAPH_BUILDER_VERSION = (() => {
|
|
|
19
19
|
// version invalidates stamps across releases; the explicit schema requirements also fail closed when
|
|
20
20
|
// a saved graph is hand-edited or a development build bumps a structural schema without a version bump.
|
|
21
21
|
const CURRENT_GRAPH_SCHEMA = Object.freeze({
|
|
22
|
-
extImportsV:
|
|
22
|
+
extImportsV: 3,
|
|
23
23
|
edgeTypesV: 2,
|
|
24
24
|
edgeProvenanceV: 1,
|
|
25
25
|
complexityV: 2,
|
|
@@ -248,7 +248,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
248
248
|
assignDeterministicCommunities(nodes);
|
|
249
249
|
stampEdgeProvenance(links);
|
|
250
250
|
|
|
251
|
-
// extImportsV: bump when the externalImports schema/coverage changes (
|
|
251
|
+
// extImportsV: bump when the externalImports schema/coverage changes (v3 = Java/Rust ecosystems) —
|
|
252
252
|
// deps-engine rebuilds in memory when a saved graph is older than this.
|
|
253
253
|
// edgeTypesV 2 adds language-neutral compile-only edges (currently Rust mod/use/re-export) on top
|
|
254
254
|
// of v1's TypeScript typeOnly classification.
|
|
@@ -256,7 +256,7 @@ export async function buildInternalGraph(repoDir, opts = {}) {
|
|
|
256
256
|
nodes,
|
|
257
257
|
links,
|
|
258
258
|
externalImports,
|
|
259
|
-
extImportsV:
|
|
259
|
+
extImportsV: 3,
|
|
260
260
|
edgeTypesV: 2,
|
|
261
261
|
edgeProvenanceV: EDGE_PROVENANCE_V,
|
|
262
262
|
complexityV: 2,
|
|
@@ -115,6 +115,25 @@ function gitFileUniverse(dir) {
|
|
|
115
115
|
});
|
|
116
116
|
} catch { return null; }
|
|
117
117
|
|
|
118
|
+
// `git -C <dir>` also succeeds when <dir> is merely nested under some parent repository. If that
|
|
119
|
+
// nested directory is ignored by the parent, `ls-files` is empty even though <dir> can be a valid
|
|
120
|
+
// standalone repository root selected by the user (or a repository staged by a test/tool). In
|
|
121
|
+
// that one ambiguous case, fall back to the boundary-safe walker. Preserve an empty universe for
|
|
122
|
+
// an actual Git root so ignored files do not leak back into its graph.
|
|
123
|
+
if (!raw) {
|
|
124
|
+
try {
|
|
125
|
+
const top = execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
windowsHide: true,
|
|
128
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
129
|
+
timeout: 15_000,
|
|
130
|
+
maxBuffer: 1024 * 1024,
|
|
131
|
+
env: childProcessEnv(),
|
|
132
|
+
}).trim();
|
|
133
|
+
if (realpathSync.native(top) !== realpathSync.native(dir)) return null;
|
|
134
|
+
} catch { return null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
118
137
|
let rootReal;
|
|
119
138
|
try { rootReal = realpathSync.native(dir); } catch { return null; }
|
|
120
139
|
const files = [];
|
|
@@ -6,6 +6,7 @@ import { readFileSync } from "node:fs";
|
|
|
6
6
|
import { join, dirname } from "node:path";
|
|
7
7
|
import { parseGoMod } from "../analysis/manifests.js";
|
|
8
8
|
import { createRepoBoundary } from "../repo-path.js";
|
|
9
|
+
import { listRepoFiles } from "../analysis/internal-audit/repo-files.js";
|
|
9
10
|
import { createRustResolvers } from "./resolvers/rust.js";
|
|
10
11
|
|
|
11
12
|
export function buildResolvers(repoDir, fileSet) {
|
|
@@ -19,11 +20,18 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
19
20
|
// go.mod requires also feed goSpecToPkg so external Go imports map to their declared module.
|
|
20
21
|
let goModule = "";
|
|
21
22
|
let goRequires = [];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const goModules = [];
|
|
24
|
+
for (const manifest of listRepoFiles(repoDir).filter((file) => /(^|\/)go\.mod$/i.test(file))) {
|
|
25
|
+
try {
|
|
26
|
+
const gomod = parseGoMod(readLocal(manifest));
|
|
27
|
+
if (!gomod.module) continue;
|
|
28
|
+
const root = manifest.includes("/") ? manifest.slice(0, manifest.lastIndexOf("/")) : "";
|
|
29
|
+
goModules.push({ root, module: gomod.module, requires: gomod.requires.map((item) => item.path) });
|
|
30
|
+
} catch { /* unreadable module */ }
|
|
31
|
+
}
|
|
32
|
+
goModules.sort((left, right) => right.module.length - left.module.length);
|
|
33
|
+
goModule = goModules.find((item) => !item.root)?.module || goModules[0]?.module || "";
|
|
34
|
+
goRequires = [...new Set(goModules.flatMap((item) => item.requires))];
|
|
27
35
|
const dirFiles = new Map();
|
|
28
36
|
const filesByBase = new Map();
|
|
29
37
|
for (const fr of fileSet) {
|
|
@@ -32,8 +40,10 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
32
40
|
if (fr.endsWith(".go")) { const d = fr.includes("/") ? fr.slice(0, fr.lastIndexOf("/")) : ""; (dirFiles.get(d) || dirFiles.set(d, []).get(d)).push(fr); }
|
|
33
41
|
}
|
|
34
42
|
const resolveGoImport = (importPath) => {
|
|
35
|
-
|
|
36
|
-
|
|
43
|
+
for (const own of goModules) {
|
|
44
|
+
if (importPath !== own.module && !importPath.startsWith(own.module + "/")) continue;
|
|
45
|
+
const subpath = importPath === own.module ? "" : importPath.slice(own.module.length + 1);
|
|
46
|
+
const d = [own.root, subpath].filter(Boolean).join("/");
|
|
37
47
|
if (dirFiles.has(d)) return d;
|
|
38
48
|
}
|
|
39
49
|
// a module DECLARED in go.mod is external by definition — never let the suffix fallback hijack it
|
|
@@ -207,5 +217,5 @@ export function buildResolvers(repoDir, fileSet) {
|
|
|
207
217
|
return fileSet.has(cand) ? cand : null;
|
|
208
218
|
};
|
|
209
219
|
|
|
210
|
-
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goRequires };
|
|
220
|
+
return { resolveJsImport, resolveAlias, resolvePyPath, pyBaseDir, pyTopDirs, resolveGoImport, dirFiles, resolveRustMod, resolveRustPath, resolveJavaImport, resolveHref, selectorIndex, htmlUsages, goModule, goModules, goRequires };
|
|
211
221
|
}
|
|
@@ -4,15 +4,14 @@ import {collectInstalled} from '../../security/installed.js'
|
|
|
4
4
|
export async function tRefreshAdvisories(g, args, ctx) {
|
|
5
5
|
if (!ctx.repoRoot) return 'No repo root — cannot collect installed packages.'
|
|
6
6
|
const {installed} = collectInstalled(ctx.repoRoot)
|
|
7
|
-
if (!installed.length) return 'No pinned packages found in lockfiles (npm/yarn/pip/poetry/uv/go) — nothing to query.'
|
|
7
|
+
if (!installed.length) return 'No pinned packages found in supported manifests/lockfiles (npm/yarn/pip/poetry/uv/go/Maven/Gradle/Cargo) — nothing to query.'
|
|
8
8
|
const res = await refreshAdvisories({installed, repoKey: ctx.repoRoot, timeoutMs: Number(args.timeout_ms) || undefined})
|
|
9
9
|
if (res.ok === false) return `Advisory refresh failed: ${res.error}`
|
|
10
10
|
const meta = storeMeta()
|
|
11
11
|
return [
|
|
12
12
|
`Advisory store ${res.status === 'PARTIAL' ? 'partially refreshed' : 'refreshed'} from OSV.dev: ${res.queriedOk ?? res.queried}/${res.queried} package versions queried successfully, ${res.vulnerable} with known advisories (${res.fetched} advisory records fetched).`,
|
|
13
|
-
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — npm/PyPI/Go
|
|
13
|
+
res.unsupported ? `${res.unsupported} packages skipped (ecosystem not OSV-queryable — supported: npm/PyPI/Go/Maven/crates.io).` : null,
|
|
14
14
|
res.errors?.length ? `Partial: ${res.errors.length} request error(s), first: ${res.errors[0]}` : null,
|
|
15
15
|
`Store: ${DEFAULT_STORE} (${meta.advisoryCount} advisories, fetched ${meta.fetchedAt}). run_audit now reflects it — offline.`,
|
|
16
16
|
].filter(Boolean).join('\n')
|
|
17
17
|
}
|
|
18
|
-
|
|
@@ -49,13 +49,13 @@ function materializeCandidate(candidate, verification, baselineMode) {
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
function
|
|
52
|
+
export function activeArchitectureContract(ctx) {
|
|
53
53
|
if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
|
|
54
54
|
return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function preview(g, args, ctx) {
|
|
58
|
-
const active =
|
|
58
|
+
const active = activeArchitectureContract(ctx)
|
|
59
59
|
if (active.contract) return toolResult(
|
|
60
60
|
`Architecture bootstrap refused: an active contract already exists at ${active.source}.`,
|
|
61
61
|
{state: 'ALREADY_CONFIGURED', source: active.source, contractHash: active.contract.contractHash},
|
|
@@ -122,7 +122,7 @@ function approve(g, args, ctx) {
|
|
|
122
122
|
state: 'REPOSITORY_CHANGED', wrote: false,
|
|
123
123
|
})
|
|
124
124
|
}
|
|
125
|
-
const active =
|
|
125
|
+
const active = activeArchitectureContract(ctx)
|
|
126
126
|
if (active.contract || active.error) return toolResult(
|
|
127
127
|
`Architecture approval refused: ${active.source || 'a contract'} appeared or changed after preview.`,
|
|
128
128
|
{state: active.error ? 'ERROR' : 'ALREADY_CONFIGURED', wrote: false, source: active.source, error: active.error || undefined},
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const HOT_OWNERS = [
|
|
|
39
39
|
'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
|
|
40
40
|
'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
|
|
41
41
|
'architecture-starter.mjs', 'architecture-bootstrap.mjs',
|
|
42
|
+
'company-contract-verdict.mjs',
|
|
42
43
|
]
|
|
43
44
|
export const HOT_FILES = [...HOT_FACADES, ...HOT_OWNERS, 'catalog.mjs']
|
|
44
45
|
|
|
@@ -50,8 +51,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
50
51
|
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept or exact symbols (BFS/DFS). Exact seed files/symbols stay pinned; relation_filter and flow_direction support bounded event/data-flow views without a separate tool. Classified paths and unreferenced constant/field leaves stay suppressed unless explicitly requested.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Optional natural-language question or keyword search when exact seeds are not sufficient'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact repo-relative file paths. Resolved exact seeds remain pinned unless augment_seeds is true'}, seed_symbols: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact node IDs or unambiguous symbol labels; enables focused flows without fuzzy query seeds'}, relation_filter: {oneOf: [{type: 'array', items: {type: 'string'}}, {type: 'string'}], description: 'Optional relation allow-list, e.g. calls,references,imports'}, flow_direction: {type: 'string', enum: ['forward', 'backward', 'both'], default: 'both', description: 'Traverse outgoing, incoming, or both directions'}, augment_seeds: {type: 'boolean', default: false, description: 'With exact seeds, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, anyOf: [{required: ['question']}, {required: ['seed_files']}, {required: ['seed_symbols']}]}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
51
52
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
52
53
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
53
|
-
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node
|
|
54
|
-
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff
|
|
54
|
+
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node. JavaScript/TypeScript symbols use a cached on-demand EXACT_LSP point query by default, then traverse exact direct callers through the wider graph; incomplete precision is labelled and never silently presented as exact. Set precision=graph to skip LSP or include_container_importers for a conservative module-wide radius.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto', description: 'auto uses exact JS/TS point queries when precision is enabled; graph skips LSP; lsp forces an attempt'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a, ctx) => ti.tGetDependents(g, a, ctx)},
|
|
55
|
+
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff and uses one bounded EXACT_LSP batch query for direct references to changed JavaScript/TypeScript symbols; transitive hops stay explicitly graph-backed. Additive exports do not inherit legacy file importers. Measured coverage is used when present; otherwise static reachability is labelled, not treated as coverage.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 16384, default: 5000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 45000}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
55
56
|
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
56
57
|
{
|
|
57
58
|
cap: 'graph', refreshGraph: true, name: 'verified_change',
|
|
@@ -75,12 +76,13 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
75
76
|
{
|
|
76
77
|
cap: 'crossrepo',
|
|
77
78
|
name: 'trace_api_contract',
|
|
78
|
-
description: 'Cross-repository HTTP contract, handler-liveness and blast-radius evidence.
|
|
79
|
+
description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static backend contracts to registered client repositories; dynamic URLs/topics, reflection and unresolved runtime configuration remain explicit UNKNOWN evidence. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and cannot be supplied through this tool.',
|
|
79
80
|
inputSchema: {
|
|
80
81
|
type: 'object',
|
|
81
82
|
properties: {
|
|
82
83
|
backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
|
|
83
84
|
clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'},
|
|
85
|
+
transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs every supported static detector'},
|
|
84
86
|
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
85
87
|
path: {type: 'string', maxLength: 2048, description: 'Optional full route or segment-aligned route fragment; /query matches /edgeAnalytics/query/... and {id}, :id and concrete parameter values are normalized'},
|
|
86
88
|
changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
|
|
@@ -104,6 +106,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
104
106
|
},
|
|
105
107
|
},
|
|
106
108
|
auto_discover_wrappers: {type: 'boolean', default: true, description: 'Discover only simple unambiguous functions that forward a URL parameter directly to a known object-style HTTP client'},
|
|
109
|
+
runtime_config: {type: 'object', maxProperties: 50, additionalProperties: {type: 'string', maxLength: 2048}, description: 'Optional non-secret static bindings for runtime URL prefixes, e.g. process.env.API_BASE. Values are used locally for this call and are not returned.'},
|
|
107
110
|
include_tests: {type: 'boolean', default: false},
|
|
108
111
|
max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
|
|
109
112
|
max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
|
|
@@ -138,7 +141,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
138
141
|
{cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
|
|
139
142
|
{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. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). 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'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
140
143
|
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
141
|
-
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's
|
|
144
|
+
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's concrete npm/PyPI/Go/Maven/Gradle/Cargo package versions. 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)},
|
|
142
145
|
{cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
|
|
143
146
|
{cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
|
|
144
147
|
{cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function contractVerdict(analysis, transportAnalysis) {
|
|
2
|
+
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
3
|
+
const transportWithCallers = (transportAnalysis.contracts || []).filter((contract) => contract.callsites.length > 0)
|
|
4
|
+
const affectedFiles = new Set(), affectedScreens = new Set()
|
|
5
|
+
for (const contract of [...endpointsWithCallers, ...transportWithCallers]) {
|
|
6
|
+
for (const item of contract.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
7
|
+
for (const item of contract.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
8
|
+
}
|
|
9
|
+
const totalContracts = analysis.totals.endpoints + transportAnalysis.totals.contracts
|
|
10
|
+
const totalMatches = analysis.totals.matches + transportAnalysis.totals.matches
|
|
11
|
+
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
12
|
+
if (totalContracts > 0 && totalMatches === 0) {
|
|
13
|
+
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
14
|
+
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
15
|
+
} else if (totalMatches > 0 && analysis.totals.methodMismatches > 0) {
|
|
16
|
+
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
17
|
+
} else if (totalMatches > 0) {
|
|
18
|
+
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
code, risk,
|
|
22
|
+
endpointsWithCallers: endpointsWithCallers.length + transportWithCallers.length,
|
|
23
|
+
callsites: totalMatches,
|
|
24
|
+
affectedFiles: affectedFiles.size,
|
|
25
|
+
affectedScreens: affectedScreens.size,
|
|
26
|
+
methodMismatches: analysis.totals.methodMismatches,
|
|
27
|
+
uncertainCalls: analysis.totals.uncertainCalls + transportAnalysis.totals.uncertain,
|
|
28
|
+
transportContracts: transportAnalysis.totals.contracts,
|
|
29
|
+
transportMatches: transportAnalysis.totals.matches,
|
|
30
|
+
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
31
|
+
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
32
|
+
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
33
|
+
unknownLiveness: analysis.totals.unknownLiveness,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function contractVerdictLine(verdict, contracts) {
|
|
38
|
+
if (verdict.code === 'NO_ENDPOINTS_MATCHED') return 'VERDICT NO_ENDPOINTS_MATCHED — no backend contract satisfied the requested transport/method/path/change filter.'
|
|
39
|
+
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') return `VERDICT NO_STATIC_CLIENT_CALLERS — ${contracts} backend contract(s) matched, but no bounded static client call was proven; this is unknown, not proof of no consumers.`
|
|
40
|
+
if (verdict.code === 'HTTP_METHOD_MISMATCH') return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
41
|
+
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} contract(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
42
|
+
}
|
|
@@ -83,9 +83,7 @@ export function moduleId(value) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export function nonNegativeInteger(value) {
|
|
86
|
-
return
|
|
87
|
-
? Math.trunc(value)
|
|
88
|
-
: 0
|
|
86
|
+
return optionalNonNegativeInteger(value) ?? 0
|
|
89
87
|
}
|
|
90
88
|
|
|
91
89
|
export function optionalNonNegativeInteger(value) {
|
|
@@ -3,6 +3,8 @@ import {
|
|
|
3
3
|
normalizeCheckState, numericRecord, optionalNonNegativeInteger, privacySafeText, repoRelativePath,
|
|
4
4
|
sanitizeFinding,
|
|
5
5
|
} from './evidence-snapshot.common.mjs'
|
|
6
|
+
import {summarizeFindings} from '../analysis/findings.js'
|
|
7
|
+
import {auditFindingPathScope} from './health/audit-format.mjs'
|
|
6
8
|
|
|
7
9
|
function buildHotspots(graph) {
|
|
8
10
|
let analyzed = 0
|
|
@@ -40,7 +42,7 @@ function buildHotspots(graph) {
|
|
|
40
42
|
return {analyzed, ...bounded(facts, CAPS.hotspots)}
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export function buildHealthSection(graph, audit) {
|
|
45
|
+
export function buildHealthSection(graph, audit, repoRoot = null) {
|
|
44
46
|
if (!audit?.ok) {
|
|
45
47
|
return {
|
|
46
48
|
state: STATE.ERROR,
|
|
@@ -52,7 +54,12 @@ export function buildHealthSection(graph, audit) {
|
|
|
52
54
|
complexity: {thresholds: COMPLEXITY_THRESHOLDS, analyzed: 0, hotspots: []},
|
|
53
55
|
}
|
|
54
56
|
}
|
|
55
|
-
|
|
57
|
+
// Public/private hosted evidence follows the same production-first path policy as
|
|
58
|
+
// run_audit. Classified-only findings remain available locally through
|
|
59
|
+
// include_classified:true, but must not turn a hosted production snapshot red.
|
|
60
|
+
const scopedFindings = auditFindingPathScope(audit.findings, {repoRoot}).findings
|
|
61
|
+
const scopedSummary = summarizeFindings(scopedFindings)
|
|
62
|
+
const findings = bounded(scopedFindings.map(sanitizeFinding).filter(Boolean)
|
|
56
63
|
.sort((a, b) => compareText(a.severity, b.severity) || compareText(a.category, b.category) ||
|
|
57
64
|
compareText(a.rule, b.rule) || compareText(a.file || a.package || '', b.file || b.package || '') || compareText(a.id, b.id)),
|
|
58
65
|
CAPS.findings)
|
|
@@ -76,8 +83,8 @@ export function buildHealthSection(graph, audit) {
|
|
|
76
83
|
reasons: state === STATE.PARTIAL ? ['OPTIONAL_CHECKS_INCOMPLETE'] : [],
|
|
77
84
|
},
|
|
78
85
|
summary: {
|
|
79
|
-
bySeverity: numericRecord(
|
|
80
|
-
byCategory: numericRecord(
|
|
86
|
+
bySeverity: numericRecord(scopedSummary.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
|
|
87
|
+
byCategory: numericRecord(scopedSummary.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
|
|
81
88
|
dead: numericRecord(audit.deadReport, ['deadSymbols', 'deadFiles', 'unusedExports']),
|
|
82
89
|
structure: numericRecord(audit.structureReport, [
|
|
83
90
|
'runtimeImportEdges', 'typeOnlyImportEdges', 'compileOnlyImportEdges', 'runtimeCycles',
|
|
@@ -35,7 +35,7 @@ export async function createEvidenceSnapshot({repoRoot, graph}) {
|
|
|
35
35
|
const sections = {
|
|
36
36
|
architecture: buildArchitectureSection(inputGraph, aggregate, audit, structure),
|
|
37
37
|
duplicates: buildDuplicatesSection(repoRoot, inputGraph),
|
|
38
|
-
health: buildHealthSection(inputGraph, audit),
|
|
38
|
+
health: buildHealthSection(inputGraph, audit, repoRoot),
|
|
39
39
|
technologies: buildTechnologiesSection(stack, stackError),
|
|
40
40
|
packages: buildPackagesSection(installedResult, installedError, inputGraph, audit, repoRoot),
|
|
41
41
|
}
|
package/src/mcp/graph-diff.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
|
|
3
3
|
import {folderModuleOf} from '../analysis/graph-analysis.aggregate.js'
|
|
4
4
|
import {isStructuralRelation} from '../graph/relations.js'
|
|
5
|
+
import {fileOfId} from '../graph/node-id.js'
|
|
5
6
|
|
|
6
7
|
// ---- graph diff ----------------------------------------------------------------------------------
|
|
7
8
|
// One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
|
|
@@ -10,7 +11,7 @@ import {isStructuralRelation} from '../graph/relations.js'
|
|
|
10
11
|
// the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
|
|
11
12
|
export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
|
|
12
13
|
export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
|
|
13
|
-
export
|
|
14
|
+
export {fileOfId}
|
|
14
15
|
const folderOfFile = folderModuleOf
|
|
15
16
|
|
|
16
17
|
const terminalLineSuffix = (id) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Executable target architecture for agents: read the active contract before a change, then run the
|
|
2
2
|
// same deterministic verifier after it. No tool silently edits policy or accepts debt.
|
|
3
|
-
import {contractForChange,
|
|
3
|
+
import {contractForChange, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
|
|
4
4
|
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
5
5
|
import {detectRepoStack} from '../scan/discover.js'
|
|
6
6
|
import {toolResult} from './tool-result.mjs'
|
|
@@ -13,6 +13,7 @@ const [starter, bootstrap] = await Promise.all([
|
|
|
13
13
|
])
|
|
14
14
|
const {createArchitectureStarter, PROVISIONAL_BUDGETS} = starter
|
|
15
15
|
export const tBootstrapArchitecture = bootstrap.tBootstrapArchitecture
|
|
16
|
+
const activeContract = bootstrap.activeArchitectureContract
|
|
16
17
|
|
|
17
18
|
const remediation = () => ({
|
|
18
19
|
offlinePath: '.weavatrix/architecture.json',
|
|
@@ -29,11 +30,6 @@ const stackIds = (repoRoot) => {
|
|
|
29
30
|
} catch { return [] }
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function activeContract(ctx) {
|
|
33
|
-
if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
|
|
34
|
-
return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
|
|
35
|
-
}
|
|
36
|
-
|
|
37
33
|
function notConfiguredResult(g, action, {includeStarter = false, repoRoot = null} = {}) {
|
|
38
34
|
const proposal = includeStarter ? createArchitectureStarter(g, repoRoot) : null
|
|
39
35
|
const starter = proposal?.contract || null
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
// can never pass an arbitrary filesystem path through this tool.
|
|
4
4
|
import {join} from 'node:path'
|
|
5
5
|
import {analyzeHttpContracts} from '../analysis/http-contracts.js'
|
|
6
|
+
import {analyzeTransportContracts} from '../analysis/transport-contracts.js'
|
|
6
7
|
import {buildGraphForRepo} from '../build-graph.js'
|
|
7
8
|
import {graphHomeDir} from '../graph/layout.js'
|
|
8
9
|
import {liveRepositoryRecords} from '../graph/repo-registry.js'
|
|
9
10
|
import {loadGraph} from './graph-context.mjs'
|
|
10
11
|
import {toolResult} from './tool-result.mjs'
|
|
12
|
+
import {contractVerdict, contractVerdictLine} from './company-contract-verdict.mjs'
|
|
11
13
|
|
|
12
|
-
const CROSS_REPO_HTTP_CONTRACT_V =
|
|
14
|
+
const CROSS_REPO_HTTP_CONTRACT_V = 3
|
|
13
15
|
const selectorText = (value) => String(value ?? '').trim()
|
|
14
16
|
|
|
15
17
|
function selectRecord(records, selector) {
|
|
@@ -103,52 +105,6 @@ async function reconcileGraph(record, alias, role, graphHome) {
|
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
function verdictFor(analysis) {
|
|
107
|
-
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
108
|
-
const affectedFiles = new Set()
|
|
109
|
-
const affectedScreens = new Set()
|
|
110
|
-
for (const endpoint of endpointsWithCallers) {
|
|
111
|
-
for (const item of endpoint.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
112
|
-
for (const item of endpoint.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
113
|
-
}
|
|
114
|
-
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
115
|
-
if (analysis.totals.endpoints > 0 && analysis.totals.matches === 0) {
|
|
116
|
-
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
117
|
-
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
118
|
-
} else if (analysis.totals.matches > 0 && analysis.totals.methodMismatches > 0) {
|
|
119
|
-
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
120
|
-
} else if (analysis.totals.matches > 0) {
|
|
121
|
-
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
code,
|
|
125
|
-
risk,
|
|
126
|
-
endpointsWithCallers: endpointsWithCallers.length,
|
|
127
|
-
callsites: analysis.totals.matches,
|
|
128
|
-
affectedFiles: affectedFiles.size,
|
|
129
|
-
affectedScreens: affectedScreens.size,
|
|
130
|
-
methodMismatches: analysis.totals.methodMismatches,
|
|
131
|
-
uncertainCalls: analysis.totals.uncertainCalls,
|
|
132
|
-
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
133
|
-
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
134
|
-
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
135
|
-
unknownLiveness: analysis.totals.unknownLiveness,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function verdictLine(verdict, endpoints) {
|
|
140
|
-
if (verdict.code === 'NO_ENDPOINTS_MATCHED') {
|
|
141
|
-
return 'VERDICT NO_ENDPOINTS_MATCHED — no backend endpoint satisfied the requested method/path/change filter.'
|
|
142
|
-
}
|
|
143
|
-
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') {
|
|
144
|
-
return `VERDICT NO_STATIC_CLIENT_CALLERS — ${endpoints} backend endpoint(s) matched, but no bounded literal/template client call was proven; this is unknown, not proof of no consumers.`
|
|
145
|
-
}
|
|
146
|
-
if (verdict.code === 'HTTP_METHOD_MISMATCH') {
|
|
147
|
-
return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
148
|
-
}
|
|
149
|
-
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} endpoint(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
150
|
-
}
|
|
151
|
-
|
|
152
108
|
export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
153
109
|
const graphHome = ctx.graphHome || graphHomeDir()
|
|
154
110
|
const records = liveRepositoryRecords(graphHome)
|
|
@@ -215,7 +171,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
215
171
|
}
|
|
216
172
|
|
|
217
173
|
try {
|
|
218
|
-
|
|
174
|
+
let analysis = analyzeHttpContracts({
|
|
219
175
|
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
220
176
|
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
221
177
|
method: args.method,
|
|
@@ -229,9 +185,25 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
229
185
|
maxEndpoints: args.max_endpoints,
|
|
230
186
|
maxMatches: args.max_matches,
|
|
231
187
|
maxAffectedFiles: args.max_affected_files,
|
|
188
|
+
runtimeValues: args.runtime_config,
|
|
232
189
|
})
|
|
233
|
-
const
|
|
234
|
-
|
|
190
|
+
const selectedTransport = ['all', 'http', 'graphql', 'grpc', 'event'].includes(args.transport) ? args.transport : 'all'
|
|
191
|
+
if (!['all', 'http'].includes(selectedTransport)) analysis = {
|
|
192
|
+
...analysis, status: 'complete', completeness: {complete: true, reasons: []}, endpoints: [], uncertain: [],
|
|
193
|
+
totals: {...analysis.totals, endpoints: 0, clientCalls: 0, matches: 0, methodMismatches: 0, uncertainCalls: 0, notDeadExternalUse: 0, notDeadExternalHandlers: 0, possibleExternalUse: 0, unknownLiveness: 0},
|
|
194
|
+
}
|
|
195
|
+
const transportAnalysis = selectedTransport === 'http'
|
|
196
|
+
? {transportContractsV: 1, transport: 'http', status: 'COMPLETE', completeness: {complete: true, reasons: []}, totals: {contracts: 0, matches: 0, uncertain: 0, filesScanned: 0}, contracts: [], uncertain: []}
|
|
197
|
+
: analyzeTransportContracts({
|
|
198
|
+
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
199
|
+
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
200
|
+
transport: selectedTransport === 'all' ? 'all' : selectedTransport,
|
|
201
|
+
includeTests: args.include_tests === true,
|
|
202
|
+
maxImpactDepth: args.max_impact_depth,
|
|
203
|
+
maxAffectedFiles: args.max_affected_files,
|
|
204
|
+
})
|
|
205
|
+
const verdict = contractVerdict(analysis, transportAnalysis)
|
|
206
|
+
const reasons = [...new Set([...reconciliationReasons, ...(analysis.completeness?.reasons || []), ...(transportAnalysis.completeness?.reasons || [])])]
|
|
235
207
|
const completeness = {
|
|
236
208
|
complete: reasons.length === 0 && analysis.completeness?.complete === true,
|
|
237
209
|
status: reasons.length === 0 && analysis.completeness?.complete === true ? 'COMPLETE' : 'PARTIAL',
|
|
@@ -243,8 +215,8 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
243
215
|
right.affected.files.length - left.affected.files.length ||
|
|
244
216
|
left.path.localeCompare(right.path))
|
|
245
217
|
const lines = [
|
|
246
|
-
|
|
247
|
-
`Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} endpoint(s), ${analysis.totals.clientCalls} inspected
|
|
218
|
+
contractVerdictLine(verdict, analysis.totals.endpoints + transportAnalysis.totals.contracts),
|
|
219
|
+
`Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} HTTP endpoint(s), ${transportAnalysis.totals.contracts} GraphQL/gRPC/event contract(s), ${analysis.totals.clientCalls} inspected HTTP call(s), ${verdict.uncertainCalls} uncertain.`,
|
|
248
220
|
...ranked.slice(0, topN).flatMap((endpoint) => {
|
|
249
221
|
const location = endpoint.file ? ` (${endpoint.backend}:${endpoint.file}${endpoint.line ? `:${endpoint.line}` : ''})` : ''
|
|
250
222
|
const callsites = endpoint.callsites.slice(0, 3)
|
|
@@ -257,8 +229,10 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
257
229
|
...screens,
|
|
258
230
|
]
|
|
259
231
|
}),
|
|
232
|
+
...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
|
|
233
|
+
` ${contract.transport.toUpperCase()} ${contract.service ? `${contract.service}.` : contract.operation ? `${contract.operation} ` : ''}${contract.name} (${contract.file}:${contract.line}) [${contract.liveness}] → ${contract.callsites.length} callsite(s), ${contract.affected.files.length} affected file(s)`),
|
|
260
234
|
completeness.complete
|
|
261
|
-
? 'Completeness: complete within the declared repository graphs and static HTTP
|
|
235
|
+
? 'Completeness: complete within the declared repository graphs and supported static HTTP/GraphQL/gRPC/event models.'
|
|
262
236
|
: `Completeness: partial — ${completeness.reasons.join('; ')}.`,
|
|
263
237
|
]
|
|
264
238
|
const result = {
|
|
@@ -269,6 +243,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
269
243
|
clients: clients.map(({record, alias}) => publicRecord(record, alias)),
|
|
270
244
|
},
|
|
271
245
|
...analysis,
|
|
246
|
+
transportContracts: transportAnalysis,
|
|
272
247
|
status: completeness.status,
|
|
273
248
|
graphReconciliation,
|
|
274
249
|
completeness,
|