weavatrix 0.2.14 → 0.2.16

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 (65) hide show
  1. package/README.md +69 -13
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.16.md +53 -0
  4. package/package.json +6 -2
  5. package/src/analysis/cargo-dependency-evidence.js +111 -0
  6. package/src/analysis/cargo-manifests.js +74 -0
  7. package/src/analysis/dep-check-ecosystems.js +3 -0
  8. package/src/analysis/dep-check.js +8 -18
  9. package/src/analysis/dependency/conventions.js +16 -0
  10. package/src/analysis/dependency/scoped-dependencies.js +7 -2
  11. package/src/analysis/dependency/source-references.js +21 -0
  12. package/src/analysis/duplicates.tokenize.js +12 -2
  13. package/src/analysis/endpoints-java.js +2 -5
  14. package/src/analysis/findings.js +12 -0
  15. package/src/analysis/go-dependency-evidence.js +68 -0
  16. package/src/analysis/health-capabilities.js +1 -1
  17. package/src/analysis/http-contracts/analysis.js +2 -0
  18. package/src/analysis/http-contracts/client-call-detection.js +1 -0
  19. package/src/analysis/http-contracts/client-call-parser.js +18 -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/supply-chain.js +2 -0
  23. package/src/analysis/internal-audit.reach.js +20 -0
  24. package/src/analysis/internal-audit.run.js +13 -7
  25. package/src/analysis/jvm-dependency-evidence.js +102 -56
  26. package/src/analysis/jvm-manifests.js +114 -0
  27. package/src/analysis/source-correctness.js +2 -5
  28. package/src/analysis/task-retrieval.js +3 -14
  29. package/src/analysis/transport-contracts.js +157 -0
  30. package/src/graph/builder/lang-go.js +4 -2
  31. package/src/graph/builder/lang-java.js +12 -3
  32. package/src/graph/builder/lang-rust.js +22 -3
  33. package/src/graph/freshness-probe.js +1 -1
  34. package/src/graph/internal-builder.build.js +2 -2
  35. package/src/graph/internal-builder.resolvers.js +18 -8
  36. package/src/mcp/actions/advisories.mjs +2 -3
  37. package/src/mcp/catalog.mjs +7 -4
  38. package/src/mcp/company-contract-verdict.mjs +42 -0
  39. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  40. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  41. package/src/mcp/git-output.mjs +10 -0
  42. package/src/mcp/graph/context-seeds.mjs +3 -14
  43. package/src/mcp/graph/reverse-reach.mjs +42 -0
  44. package/src/mcp/sync/evidence-duplicates.mjs +1 -4
  45. package/src/mcp/tools-company.mjs +28 -53
  46. package/src/mcp/tools-impact-change.mjs +2 -38
  47. package/src/mcp/tools-impact-precision.mjs +89 -0
  48. package/src/mcp/tools-impact.mjs +53 -136
  49. package/src/path-classification.js +14 -0
  50. package/src/precision/lsp-overlay/build.js +10 -0
  51. package/src/precision/lsp-overlay/contract.js +1 -1
  52. package/src/precision/symbol-query.js +39 -0
  53. package/src/precision/typescript-provider/client.js +16 -3
  54. package/src/precision/typescript-provider/discovery.js +1 -1
  55. package/src/precision/typescript-provider/isolated-runtime.js +39 -0
  56. package/src/precision/typescript-provider/project-host.js +11 -6
  57. package/src/precision/typescript-provider/project-safety.js +5 -0
  58. package/src/security/advisory-store.js +2 -2
  59. package/src/security/installed-jvm-rust.js +46 -0
  60. package/src/security/installed.js +21 -1
  61. package/src/security/malware-heuristics.sweep.js +1 -1
  62. package/src/security/registry-sig.classify.js +2 -1
  63. package/src/security/registry-sig.rules.js +3 -3
  64. package/src/util.js +8 -0
  65. package/docs/releases/v0.2.14.md +0 -93
@@ -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
+ }
@@ -0,0 +1,8 @@
1
+ const compareText = (left, right) => String(left).localeCompare(String(right), 'en')
2
+
3
+ // Snapshot construction and hosted-payload validation must use the same stable
4
+ // member order or equivalent evidence can hash differently across the boundary.
5
+ export function compareDuplicateMember(left, right) {
6
+ return compareText(left.file, right.file) || left.startLine - right.startLine ||
7
+ left.endLine - right.endLine || compareText(left.graphNodeId || '', right.graphNodeId || '')
8
+ }
@@ -3,6 +3,7 @@ import {computeDuplicates} from '../analysis/duplicates.js'
3
3
  import {
4
4
  CAPS, STATE, VERDICT, bounded, compareText, graphId, repoRelativePath,
5
5
  } from './evidence-snapshot.common.mjs'
6
+ import {compareDuplicateMember} from './evidence/duplicate-member-order.mjs'
6
7
 
7
8
  const CLONE_MIN_SIMILARITY = 80
8
9
  const DIVERGENCE_MAX_SIMILARITY = 45
@@ -51,11 +52,6 @@ function memberIdentity(value) {
51
52
  return `${value.file}\0${value.startLine}\0${value.endLine}\0${value.graphNodeId || ''}`
52
53
  }
53
54
 
54
- function compareMember(a, b) {
55
- return compareText(a.file, b.file) || a.startLine - b.startLine || a.endLine - b.endLine ||
56
- compareText(a.graphNodeId || '', b.graphNodeId || '')
57
- }
58
-
59
55
  function eligibleMembers(fragments) {
60
56
  const out = new Map()
61
57
  for (let index = 0; index < fragments.length; index++) {
@@ -107,7 +103,7 @@ function cloneGroups(data, membersByIndex) {
107
103
  const value = membersByIndex.get(index)
108
104
  unique.set(memberIdentity(value), value)
109
105
  }
110
- const members = [...unique.values()].sort(compareMember)
106
+ const members = [...unique.values()].sort(compareDuplicateMember)
111
107
  if (members.length < 2) continue
112
108
  const indexSet = new Set(indices)
113
109
  const similarities = links
@@ -141,7 +137,7 @@ function divergenceCandidates(data, membersByIndex) {
141
137
  if (!Number.isInteger(left) || !Number.isInteger(right) || left === right || !leftMember || !rightMember ||
142
138
  leftMember.file === rightMember.file || !Number.isFinite(similarity) ||
143
139
  similarity < 0 || similarity > DIVERGENCE_MAX_SIMILARITY) continue
144
- const members = [leftMember, rightMember].sort(compareMember)
140
+ const members = [leftMember, rightMember].sort(compareDuplicateMember)
145
141
  pairs.push({
146
142
  similarity: Math.trunc(similarity),
147
143
  totalTokens: members[0].tokens + members[1].tokens,
@@ -0,0 +1,10 @@
1
+ import {spawnSync} from 'node:child_process'
2
+ import {childProcessEnv} from '../child-env.js'
3
+
4
+ export function gitLines(repoRoot, args) {
5
+ const result = spawnSync('git', ['-C', repoRoot, ...args], {
6
+ encoding: 'utf8', timeout: 8000, env: childProcessEnv(), windowsHide: true,
7
+ })
8
+ if (result.status !== 0) return null
9
+ return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
10
+ }
@@ -1,4 +1,4 @@
1
- import {createPathClassifier, hasPathClass} from '../../path-classification.js'
1
+ import {PATH_CLASS_NAMES, PATH_CLASS_QUERY_TERMS, createPathClassifier, hasPathClass} from '../../path-classification.js'
2
2
  import {degreeOf, isSymbol, uniqueConnCount} from './context-core.mjs'
3
3
 
4
4
  const QUERY_STOP = new Set('a an and are around architecture best code do does exact explain find focus focused for from how identify in inspect inspection is logic me of or path production project repository request requests rest show symbol symbols the through to trace what where which with'.split(' '))
@@ -17,17 +17,6 @@ const TOOL_EXECUTION_TERMS = QUERY_INTENTS.find(([id]) => id === 'tool-execution
17
17
  const TOOL_EXECUTION_TRIGGERS = new Set(['tool', 'tools', 'tooling', 'mcp'])
18
18
  const wordsOf = (value) => String(value ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
19
19
  const normPath = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
20
- const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
21
- const CLASS_QUERY_TERMS = Object.freeze({
22
- test: ['test', 'tests', 'testing', 'unit'],
23
- e2e: ['e2e', 'playwright', 'cypress'],
24
- generated: ['generated', 'autogenerated', 'dist'],
25
- mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
26
- story: ['story', 'stories', 'storybook'],
27
- docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
28
- benchmark: ['benchmark', 'benchmarks', 'bench'],
29
- temp: ['temp', 'temporary', 'tmp'],
30
- })
31
20
  const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
32
21
  const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
33
22
  const LANGUAGE_EXTENSIONS = Object.freeze({
@@ -56,7 +45,7 @@ const matchesLanguage = (node, extensions) => {
56
45
 
57
46
  export function requestedPathClasses(query) {
58
47
  const words = new Set(wordsOf(query)), requested = new Set()
59
- for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) if (terms.some((term) => words.has(term))) requested.add(category)
48
+ for (const [category, terms] of Object.entries(PATH_CLASS_QUERY_TERMS)) if (terms.some((term) => words.has(term))) requested.add(category)
60
49
  if (requested.has('test')) requested.add('e2e')
61
50
  if (requested.has('e2e')) requested.add('test')
62
51
  return requested
@@ -67,7 +56,7 @@ function isQueryEligible(node, requestedClasses, classificationCache, classifier
67
56
  if (!source) return true
68
57
  if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
69
58
  const info = classificationCache.get(source)
70
- const classified = NON_PRODUCT_CLASSES.filter((category) => hasPathClass(info, category))
59
+ const classified = PATH_CLASS_NAMES.filter((category) => hasPathClass(info, category))
71
60
  return classified.length === 0 || classified.some((category) => requestedClasses.has(category))
72
61
  }
73
62
 
@@ -0,0 +1,42 @@
1
+ import {isStructuralRelation} from '../../graph/relations.js'
2
+
3
+ // Shared reverse dependency walk for get_dependents and change_impact. Keep one
4
+ // representation so runtime/compile-time depth and edge provenance cannot drift
5
+ // between the two public tools.
6
+ export function reverseReach(g, seeds, maxDepth) {
7
+ const states = new Map([...seeds].map((id) => [String(id), {
8
+ runtimeDepth: 0, runtimeRelation: null, runtimeProvenance: null,
9
+ compileDepth: null, compileRelation: null, compileProvenance: null,
10
+ }]))
11
+ const frontier = [...seeds].map((id) => ({id: String(id), depth: 0, compileOnly: false}))
12
+ for (let cursor = 0; cursor < frontier.length; cursor++) {
13
+ const current = frontier[cursor]
14
+ if (current.depth >= maxDepth) continue
15
+ for (const edge of g.inn.get(current.id) || []) {
16
+ if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
17
+ const id = String(edge.id)
18
+ const compileOnly = current.compileOnly || edge.typeOnly === true || edge.compileOnly === true
19
+ const depth = current.depth + 1
20
+ const entry = states.get(id) || {
21
+ runtimeDepth: null, runtimeRelation: null, runtimeProvenance: null,
22
+ compileDepth: null, compileRelation: null, compileProvenance: null,
23
+ }
24
+ const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
25
+ const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
26
+ const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
27
+ if (entry[depthKey] != null && entry[depthKey] <= depth) continue
28
+ entry[depthKey] = depth
29
+ entry[relationKey] = edge.relation || 'rel'
30
+ entry[provenanceKey] = edge.provenance || 'UNKNOWN'
31
+ states.set(id, entry)
32
+ frontier.push({id, depth, compileOnly})
33
+ }
34
+ }
35
+ return new Map([...states].map(([id, entry]) => [id, {
36
+ ...entry,
37
+ depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
38
+ compileOnly: entry.runtimeDepth == null,
39
+ relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
40
+ provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
41
+ }]))
42
+ }
@@ -2,6 +2,7 @@ import {
2
2
  CAPS, DUPLICATE_THRESHOLDS, bool, compare, count, graphId, int, list,
3
3
  path, reasons, state, text, verdict,
4
4
  } from './evidence-common.mjs'
5
+ import {compareDuplicateMember} from '../evidence/duplicate-member-order.mjs'
5
6
 
6
7
  function duplicateEvidenceId(value) {
7
8
  const id = text(value, 64)
@@ -19,10 +20,6 @@ function duplicateMember(value) {
19
20
  return out
20
21
  }
21
22
 
22
- function compareDuplicateMember(a, b) {
23
- return compare(a.file, b.file) || a.startLine - b.startLine || a.endLine - b.endLine || compare(a.graphNodeId || '', b.graphNodeId || '')
24
- }
25
-
26
23
  function duplicateMembers(values, cap = CAPS.duplicateMembers) {
27
24
  const raw = Array.isArray(values) ? values : []
28
25
  const unique = new Map()
@@ -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,
@@ -5,16 +5,11 @@ import {childProcessEnv} from '../child-env.js'
5
5
  import {classifyChangeImpact} from '../analysis/change-classification.js'
6
6
  import {readCoverageForRepo} from '../analysis/coverage-reports.js'
7
7
  import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
8
- import {isStructuralRelation} from '../graph/relations.js'
9
8
  import {degreeOf, labelOf, rawGraph} from './graph-context.mjs'
9
+ import {reverseReach} from './graph/reverse-reach.mjs'
10
+ import {gitLines} from './git-output.mjs'
10
11
  import {toolResult} from './tool-result.mjs'
11
12
 
12
- function gitLines(repoRoot, args) {
13
- const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
14
- if (result.status !== 0) return null
15
- return String(result.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
16
- }
17
-
18
13
  function gitValue(repoRoot, args) {
19
14
  const result = spawnSync('git', ['-C', repoRoot, ...args], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
20
15
  if (result.status !== 0) return null
@@ -30,37 +25,6 @@ function resolveImpactBase(repoRoot, requested) {
30
25
  return null
31
26
  }
32
27
 
33
- function reverseReach(g, seeds, maxDepth) {
34
- const states = new Map([...seeds].map((id) => [String(id), {
35
- runtimeDepth: 0, runtimeRelation: null, compileDepth: null, compileRelation: null,
36
- }]))
37
- const frontier = [...seeds].map((id) => ({id: String(id), depth: 0, compileOnly: false}))
38
- for (let cursor = 0; cursor < frontier.length; cursor++) {
39
- const current = frontier[cursor]
40
- if (current.depth >= maxDepth) continue
41
- for (const edge of g.inn.get(current.id) || []) {
42
- if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
43
- const id = String(edge.id)
44
- const compileOnly = current.compileOnly || edge.typeOnly === true || edge.compileOnly === true
45
- const depth = current.depth + 1
46
- const entry = states.get(id) || {runtimeDepth: null, runtimeRelation: null, compileDepth: null, compileRelation: null}
47
- const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
48
- const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
49
- if (entry[depthKey] != null && entry[depthKey] <= depth) continue
50
- entry[depthKey] = depth
51
- entry[relationKey] = edge.relation || 'rel'
52
- states.set(id, entry)
53
- frontier.push({id, depth, compileOnly})
54
- }
55
- }
56
- return new Map([...states].map(([id, entry]) => [id, {
57
- ...entry,
58
- depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
59
- compileOnly: entry.runtimeDepth == null,
60
- relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
61
- }]))
62
- }
63
-
64
28
  const impactKind = (entry) => entry?.runtimeDepth != null
65
29
  ? (entry.compileDepth != null ? `runtime + compile-time(d${entry.compileDepth})` : 'runtime')
66
30
  : 'compile-time'
@@ -0,0 +1,89 @@
1
+ import {isStructuralRelation} from '../graph/relations.js'
2
+ import {querySymbolsPrecision} from '../precision/symbol-query.js'
3
+
4
+ export function graphWithExactDirectReferences(g, targetId, overlay) {
5
+ const exact = (overlay?.links || []).filter((link) => String(link?.target || '') === targetId
6
+ && link.provenance === 'EXACT_LSP')
7
+ const provenEmpty = overlay?.state === 'COMPLETE'
8
+ && (overlay?.noReferenceSymbols || []).some((id) => String(id) === targetId)
9
+ if (overlay?.state !== 'COMPLETE' || (!exact.length && !provenEmpty)) return null
10
+ const inn = new Map(g.inn)
11
+ const structural = (g.inn.get(targetId) || []).filter((edge) => isStructuralRelation(edge.relation))
12
+ inn.set(targetId, [...structural, ...exact.map((link) => ({
13
+ id: String(link.source),
14
+ relation: link.relation || 'references',
15
+ provenance: 'EXACT_LSP',
16
+ ...(link.typeOnly === true ? {typeOnly: true} : {}),
17
+ ...(link.compileOnly === true ? {compileOnly: true} : {}),
18
+ }))])
19
+ return {...g, inn}
20
+ }
21
+
22
+ export function refineChangeImpact(g, args, ctx, baseline, computeImpact) {
23
+ const requested = ['auto', 'graph', 'lsp'].includes(args?.precision) ? args.precision : 'auto'
24
+ if (requested === 'graph' || !baseline?.result?.seeds?.ids || !ctx?.repoRoot || !ctx?.graphPath
25
+ || (requested === 'auto' && g.graphPrecisionMode === 'off')) return baseline
26
+ const targets = baseline.result.seeds.ids.filter((id) => {
27
+ const node = g.byId.get(String(id))
28
+ return node?.selection_start && /\.(?:[cm]?[jt]sx?)$/i.test(String(node.source_file || ''))
29
+ }).slice(0, 16)
30
+ if (!targets.length) return baseline
31
+ return (async () => {
32
+ try {
33
+ const precision = await querySymbolsPrecision({
34
+ repoRoot: ctx.repoRoot,
35
+ graphPath: ctx.graphPath,
36
+ targetIds: targets,
37
+ maxReferences: Math.max(100, Math.min(16_384, Number(args.max_references) || 5_000)),
38
+ timeoutMs: Math.max(1_000, Math.min(60_000, Number(args.timeout_ms) || 45_000)),
39
+ clientFactory: ctx.precisionClientFactory,
40
+ })
41
+ let exactGraph = g
42
+ const verified = []
43
+ for (const target of targets) {
44
+ const next = graphWithExactDirectReferences(exactGraph, String(target), precision.overlay)
45
+ if (!next) continue
46
+ exactGraph = next
47
+ verified.push(String(target))
48
+ }
49
+ const value = verified.length ? computeImpact(exactGraph, args, ctx) : baseline
50
+ const allVerified = verified.length === targets.length
51
+ const status = allVerified ? 'DIRECT_EXACT_TRANSITIVE_GRAPH' : 'PARTIAL'
52
+ value.text = [
53
+ value.text.split('\n')[0],
54
+ `Semantic precision: ${status}; EXACT_LSP verified direct references for ${verified.length}/${targets.length} changed JavaScript/TypeScript symbol(s). Transitive hops remain graph-backed.`,
55
+ ...value.text.split('\n').slice(1),
56
+ ].join('\n')
57
+ value.result.semanticPrecision = {
58
+ status,
59
+ requestedTargets: targets,
60
+ verifiedTargets: verified,
61
+ exactDirectEdges: (precision.overlay?.links || []).filter((link) => verified.includes(String(link?.target || ''))).length,
62
+ transitiveEvidence: 'GRAPH',
63
+ provider: precision.overlay?.engines?.[0]?.provider || null,
64
+ elapsedMs: precision.elapsedMs,
65
+ }
66
+ if (!allVerified) value.warnings.push({
67
+ code: 'CHANGE_IMPACT_PRECISION_PARTIAL',
68
+ message: precision.overlay?.reason || 'Not every changed symbol had complete exact direct-reference evidence.',
69
+ })
70
+ return value
71
+ } catch (error) {
72
+ const reason = error?.message || 'batch point query failed'
73
+ baseline.result.semanticPrecision = {
74
+ status: 'UNAVAILABLE', requestedTargets: targets, verifiedTargets: [],
75
+ exactDirectEdges: 0, transitiveEvidence: 'GRAPH', reason,
76
+ }
77
+ baseline.warnings.push({
78
+ code: 'CHANGE_IMPACT_PRECISION_UNAVAILABLE',
79
+ message: `Exact changed-symbol references were unavailable; graph evidence was retained (${reason}).`,
80
+ })
81
+ baseline.text = [
82
+ baseline.text.split('\n')[0],
83
+ `Semantic precision: UNAVAILABLE; graph evidence retained (${reason}).`,
84
+ ...baseline.text.split('\n').slice(1),
85
+ ].join('\n')
86
+ return baseline
87
+ }
88
+ })()
89
+ }