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.
Files changed (66) hide show
  1. package/README.md +99 -13
  2. package/docs/adr/0001-v0.3-offline-online-split.md +58 -0
  3. package/docs/releases/v0.2.18.md +41 -0
  4. package/package.json +7 -2
  5. package/src/analysis/cargo-dependency-evidence.js +105 -0
  6. package/src/analysis/cargo-manifests.js +74 -0
  7. package/src/analysis/dep-check-ecosystems.js +3 -0
  8. package/src/analysis/dependency/scoped-dependencies.js +13 -7
  9. package/src/analysis/duplicates.tokenize.js +13 -6
  10. package/src/analysis/endpoints-java.js +2 -5
  11. package/src/analysis/findings.js +12 -0
  12. package/src/analysis/git-history/options.js +2 -4
  13. package/src/analysis/go-dependency-evidence.js +62 -0
  14. package/src/analysis/health-capabilities.js +1 -1
  15. package/src/analysis/hot-path-review.js +2 -4
  16. package/src/analysis/http-contracts/analysis.js +2 -0
  17. package/src/analysis/http-contracts/client-call-detection.js +1 -0
  18. package/src/analysis/http-contracts/client-call-parser.js +18 -4
  19. package/src/analysis/http-contracts/shared.js +3 -4
  20. package/src/analysis/internal-audit/dependency-health.js +15 -13
  21. package/src/analysis/internal-audit/python-manifests.js +18 -5
  22. package/src/analysis/internal-audit/repo-files.js +5 -6
  23. package/src/analysis/internal-audit/supply-chain.js +2 -0
  24. package/src/analysis/internal-audit.reach.js +14 -0
  25. package/src/analysis/internal-audit.run.js +15 -10
  26. package/src/analysis/jvm-dependency-evidence.js +102 -56
  27. package/src/analysis/jvm-manifests.js +114 -0
  28. package/src/analysis/manifests.js +0 -49
  29. package/src/analysis/source-correctness.js +2 -5
  30. package/src/analysis/structure/dependency-graph.js +3 -8
  31. package/src/analysis/transport-contracts.js +157 -0
  32. package/src/graph/builder/lang-go.js +4 -2
  33. package/src/graph/builder/lang-java.js +12 -3
  34. package/src/graph/builder/lang-rust.js +22 -3
  35. package/src/graph/freshness-probe.js +1 -1
  36. package/src/graph/internal-builder.build.js +2 -2
  37. package/src/graph/internal-builder.langs.js +19 -0
  38. package/src/graph/internal-builder.resolvers.js +18 -8
  39. package/src/graph/node-id.js +7 -0
  40. package/src/mcp/actions/advisories.mjs +2 -3
  41. package/src/mcp/architecture-bootstrap.mjs +3 -3
  42. package/src/mcp/catalog.mjs +7 -4
  43. package/src/mcp/company-contract-verdict.mjs +42 -0
  44. package/src/mcp/evidence-snapshot.common.mjs +1 -3
  45. package/src/mcp/evidence-snapshot.health.mjs +11 -4
  46. package/src/mcp/evidence-snapshot.mjs +1 -1
  47. package/src/mcp/graph-diff.mjs +2 -1
  48. package/src/mcp/tools-architecture.mjs +2 -6
  49. package/src/mcp/tools-company.mjs +28 -53
  50. package/src/mcp/tools-impact-change.mjs +3 -8
  51. package/src/mcp/tools-impact-precision.mjs +89 -0
  52. package/src/mcp/tools-impact.mjs +52 -94
  53. package/src/precision/lsp-overlay/build.js +10 -0
  54. package/src/precision/lsp-overlay/contract.js +1 -1
  55. package/src/precision/symbol-query.js +39 -0
  56. package/src/precision/typescript-provider/client.js +16 -3
  57. package/src/precision/typescript-provider/discovery.js +1 -1
  58. package/src/precision/typescript-provider/isolated-runtime.js +39 -0
  59. package/src/precision/typescript-provider/project-host.js +11 -6
  60. package/src/precision/typescript-provider/project-safety.js +5 -0
  61. package/src/security/advisory-store.js +2 -2
  62. package/src/security/installed-jvm-rust.js +46 -0
  63. package/src/security/installed.js +21 -1
  64. package/src/security/typosquat.js +15 -6
  65. package/src/util.js +13 -0
  66. 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) continue;
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])) continue;
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: 2,
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 (v2 = go/python ecosystems) —
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: 2,
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
- try {
23
- const gomod = parseGoMod(readLocal("go.mod"));
24
- goModule = gomod.module;
25
- goRequires = gomod.requires.map((r) => r.path);
26
- } catch { /* no go.mod */ }
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
- if (goModule && (importPath === goModule || importPath.startsWith(goModule + "/"))) {
36
- const d = importPath === goModule ? "" : importPath.slice(goModule.length + 1);
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
  }
@@ -0,0 +1,7 @@
1
+ export const graphEndpointId = (value) => String(value && typeof value === "object" ? value.id : value);
2
+
3
+ export const fileOfId = (value) => {
4
+ const id = String(value || "");
5
+ const hash = id.indexOf("#");
6
+ return hash < 0 ? id : id.slice(0, hash);
7
+ };
@@ -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 only).` : null,
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 activeContract(ctx) {
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 = activeContract(ctx)
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 = activeContract(ctx)
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},
@@ -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: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', 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}, 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) => ti.tGetDependents(g, a)},
54
- {cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', 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'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
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. Select a registered backend and one or more registered clients; joins backend endpoints to built-in, configured, or conservatively auto-discovered HTTP wrappers. Medium/high-confidence external matches mark a handler NOT_DEAD_EXTERNAL_USE; no match remains UNKNOWN. Repository paths stay local and cannot be supplied through this tool.',
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 lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
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 typeof value === 'number' && Number.isFinite(value) && value >= 0
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
- const findings = bounded((audit.findings || []).map(sanitizeFinding).filter(Boolean)
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(audit.summary?.bySeverity, ['critical', 'high', 'medium', 'low', 'info']),
80
- byCategory: numericRecord(audit.summary?.byCategory, ['unused', 'structure', 'vulnerability', 'malware']),
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
  }
@@ -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 const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
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, loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
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 = 2
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
- const analysis = analyzeHttpContracts({
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 verdict = verdictFor(analysis)
234
- const reasons = [...new Set([...reconciliationReasons, ...(analysis.completeness?.reasons || [])])]
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
- verdictLine(verdict, analysis.totals.endpoints),
247
- `Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} endpoint(s), ${analysis.totals.clientCalls} inspected client call(s), ${analysis.totals.uncertainCalls} uncertain.`,
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-call model.'
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,