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.
- package/README.md +69 -13
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.16.md +53 -0
- package/package.json +6 -2
- package/src/analysis/cargo-dependency-evidence.js +111 -0
- package/src/analysis/cargo-manifests.js +74 -0
- package/src/analysis/dep-check-ecosystems.js +3 -0
- package/src/analysis/dep-check.js +8 -18
- package/src/analysis/dependency/conventions.js +16 -0
- package/src/analysis/dependency/scoped-dependencies.js +7 -2
- package/src/analysis/dependency/source-references.js +21 -0
- package/src/analysis/duplicates.tokenize.js +12 -2
- package/src/analysis/endpoints-java.js +2 -5
- package/src/analysis/findings.js +12 -0
- package/src/analysis/go-dependency-evidence.js +68 -0
- package/src/analysis/health-capabilities.js +1 -1
- package/src/analysis/http-contracts/analysis.js +2 -0
- package/src/analysis/http-contracts/client-call-detection.js +1 -0
- package/src/analysis/http-contracts/client-call-parser.js +18 -4
- package/src/analysis/internal-audit/dependency-health.js +15 -13
- package/src/analysis/internal-audit/python-manifests.js +18 -5
- package/src/analysis/internal-audit/supply-chain.js +2 -0
- package/src/analysis/internal-audit.reach.js +20 -0
- package/src/analysis/internal-audit.run.js +13 -7
- package/src/analysis/jvm-dependency-evidence.js +102 -56
- package/src/analysis/jvm-manifests.js +114 -0
- package/src/analysis/source-correctness.js +2 -5
- package/src/analysis/task-retrieval.js +3 -14
- package/src/analysis/transport-contracts.js +157 -0
- package/src/graph/builder/lang-go.js +4 -2
- package/src/graph/builder/lang-java.js +12 -3
- package/src/graph/builder/lang-rust.js +22 -3
- package/src/graph/freshness-probe.js +1 -1
- package/src/graph/internal-builder.build.js +2 -2
- package/src/graph/internal-builder.resolvers.js +18 -8
- package/src/mcp/actions/advisories.mjs +2 -3
- package/src/mcp/catalog.mjs +7 -4
- package/src/mcp/company-contract-verdict.mjs +42 -0
- package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
- package/src/mcp/git-output.mjs +10 -0
- package/src/mcp/graph/context-seeds.mjs +3 -14
- package/src/mcp/graph/reverse-reach.mjs +42 -0
- package/src/mcp/sync/evidence-duplicates.mjs +1 -4
- package/src/mcp/tools-company.mjs +28 -53
- package/src/mcp/tools-impact-change.mjs +2 -38
- package/src/mcp/tools-impact-precision.mjs +89 -0
- package/src/mcp/tools-impact.mjs +53 -136
- package/src/path-classification.js +14 -0
- package/src/precision/lsp-overlay/build.js +10 -0
- package/src/precision/lsp-overlay/contract.js +1 -1
- package/src/precision/symbol-query.js +39 -0
- package/src/precision/typescript-provider/client.js +16 -3
- package/src/precision/typescript-provider/discovery.js +1 -1
- package/src/precision/typescript-provider/isolated-runtime.js +39 -0
- package/src/precision/typescript-provider/project-host.js +11 -6
- package/src/precision/typescript-provider/project-safety.js +5 -0
- package/src/security/advisory-store.js +2 -2
- package/src/security/installed-jvm-rust.js +46 -0
- package/src/security/installed.js +21 -1
- package/src/security/malware-heuristics.sweep.js +1 -1
- package/src/security/registry-sig.classify.js +2 -1
- package/src/security/registry-sig.rules.js +3 -3
- package/src/util.js +8 -0
- package/docs/releases/v0.2.14.md +0 -93
package/src/mcp/catalog.mjs
CHANGED
|
@@ -39,6 +39,7 @@ const HOT_OWNERS = [
|
|
|
39
39
|
'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
|
|
40
40
|
'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
|
|
41
41
|
'architecture-starter.mjs', 'architecture-bootstrap.mjs',
|
|
42
|
+
'company-contract-verdict.mjs',
|
|
42
43
|
]
|
|
43
44
|
export const HOT_FILES = [...HOT_FACADES, ...HOT_OWNERS, 'catalog.mjs']
|
|
44
45
|
|
|
@@ -50,8 +51,8 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
50
51
|
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept or exact symbols (BFS/DFS). Exact seed files/symbols stay pinned; relation_filter and flow_direction support bounded event/data-flow views without a separate tool. Classified paths and unreferenced constant/field leaves stay suppressed unless explicitly requested.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Optional natural-language question or keyword search when exact seeds are not sufficient'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact repo-relative file paths. Resolved exact seeds remain pinned unless augment_seeds is true'}, seed_symbols: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact node IDs or unambiguous symbol labels; enables focused flows without fuzzy query seeds'}, relation_filter: {oneOf: [{type: 'array', items: {type: 'string'}}, {type: 'string'}], description: 'Optional relation allow-list, e.g. calls,references,imports'}, flow_direction: {type: 'string', enum: ['forward', 'backward', 'both'], default: 'both', description: 'Traverse outgoing, incoming, or both directions'}, augment_seeds: {type: 'boolean', default: false, description: 'With exact seeds, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, anyOf: [{required: ['question']}, {required: ['seed_files']}, {required: ['seed_symbols']}]}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
51
52
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
52
53
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
53
|
-
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node
|
|
54
|
-
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff
|
|
54
|
+
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node. JavaScript/TypeScript symbols use a cached on-demand EXACT_LSP point query by default, then traverse exact direct callers through the wider graph; incomplete precision is labelled and never silently presented as exact. Set precision=graph to skip LSP or include_container_importers for a conservative module-wide radius.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto', description: 'auto uses exact JS/TS point queries when precision is enabled; graph skips LSP; lsp forces an attempt'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a, ctx) => ti.tGetDependents(g, a, ctx)},
|
|
55
|
+
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff and uses one bounded EXACT_LSP batch query for direct references to changed JavaScript/TypeScript symbols; transitive hops stay explicitly graph-backed. Additive exports do not inherit legacy file importers. Measured coverage is used when present; otherwise static reachability is labelled, not treated as coverage.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 16384, default: 5000}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 45000}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
55
56
|
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
56
57
|
{
|
|
57
58
|
cap: 'graph', refreshGraph: true, name: 'verified_change',
|
|
@@ -75,12 +76,13 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
75
76
|
{
|
|
76
77
|
cap: 'crossrepo',
|
|
77
78
|
name: 'trace_api_contract',
|
|
78
|
-
description: 'Cross-repository HTTP contract, handler-liveness and blast-radius evidence.
|
|
79
|
+
description: 'Cross-repository HTTP, GraphQL, gRPC and event/topic contract, handler-liveness and blast-radius evidence. Joins static backend contracts to registered client repositories; dynamic URLs/topics, reflection and unresolved runtime configuration remain explicit UNKNOWN evidence. Medium/high-confidence external matches mark a handler/contract NOT_DEAD_EXTERNAL_USE. Repository paths stay local and cannot be supplied through this tool.',
|
|
79
80
|
inputSchema: {
|
|
80
81
|
type: 'object',
|
|
81
82
|
properties: {
|
|
82
83
|
backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'},
|
|
83
84
|
clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'},
|
|
85
|
+
transport: {type: 'string', enum: ['all', 'http', 'graphql', 'grpc', 'event'], default: 'all', description: 'Contract family to trace; all runs every supported static detector'},
|
|
84
86
|
method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']},
|
|
85
87
|
path: {type: 'string', maxLength: 2048, description: 'Optional full route or segment-aligned route fragment; /query matches /edgeAnalytics/query/... and {id}, :id and concrete parameter values are normalized'},
|
|
86
88
|
changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'},
|
|
@@ -104,6 +106,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
104
106
|
},
|
|
105
107
|
},
|
|
106
108
|
auto_discover_wrappers: {type: 'boolean', default: true, description: 'Discover only simple unambiguous functions that forward a URL parameter directly to a known object-style HTTP client'},
|
|
109
|
+
runtime_config: {type: 'object', maxProperties: 50, additionalProperties: {type: 'string', maxLength: 2048}, description: 'Optional non-secret static bindings for runtime URL prefixes, e.g. process.env.API_BASE. Values are used locally for this call and are not returned.'},
|
|
107
110
|
include_tests: {type: 'boolean', default: false},
|
|
108
111
|
max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2},
|
|
109
112
|
max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250},
|
|
@@ -138,7 +141,7 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
138
141
|
{cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
|
|
139
142
|
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omitted mode/precision preserve an existing graph; a new graph uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Optional build mode override; omit to preserve an existing graph'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Optional semantic precision override; omit to preserve an existing graph or use the startup setting for a new graph'}} , required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
140
143
|
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
141
|
-
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's
|
|
144
|
+
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's concrete npm/PyPI/Go/Maven/Gradle/Cargo package versions. Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
142
145
|
{cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths. HTTP failures are returned as actionable AUTH_REQUIRED/FORBIDDEN/NOT_FOUND/REPOSITORY_NOT_READY states.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
|
|
143
146
|
{cap: 'hosted', name: 'preview_sync', description: 'LOCAL SAFETY PREVIEW / no network: serialize the exact bounded hosted payload, validate the HTTPS destination, and show hostname/path, repository UUID, fields, sections, node/edge counts, bytes and body hash. Returns a five-minute confirmation token for sync_graph. Source bodies, snippets, absolute paths, environment values, credentials, Git remotes and unknown fields are excluded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes bounded architecture/health/stack/package evidence; 2 is explicit graph-only compatibility'}}}, run: (g, a, ctx) => ta.tPreviewSyncGraph(g, a, ctx)},
|
|
144
147
|
{cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile. Uploads only the exact payload created by preview_sync and requires dry_run:false plus its short-lived confirm_token after user approval. Calling without dry_run:false remains a no-network preview compatibility alias. Source bodies, snippets, absolute paths and unknown fields are never uploaded.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: 'Used only when producing a compatibility preview; the approved token pins the exact serialized payload'}, dry_run: {type: 'boolean', default: true, description: 'Compatibility safety switch. Must be explicitly false to send.'}, confirm_token: {type: 'string', maxLength: 64, description: 'Short-lived token returned by preview_sync for the exact destination and payload'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function contractVerdict(analysis, transportAnalysis) {
|
|
2
|
+
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
3
|
+
const transportWithCallers = (transportAnalysis.contracts || []).filter((contract) => contract.callsites.length > 0)
|
|
4
|
+
const affectedFiles = new Set(), affectedScreens = new Set()
|
|
5
|
+
for (const contract of [...endpointsWithCallers, ...transportWithCallers]) {
|
|
6
|
+
for (const item of contract.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
7
|
+
for (const item of contract.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
8
|
+
}
|
|
9
|
+
const totalContracts = analysis.totals.endpoints + transportAnalysis.totals.contracts
|
|
10
|
+
const totalMatches = analysis.totals.matches + transportAnalysis.totals.matches
|
|
11
|
+
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
12
|
+
if (totalContracts > 0 && totalMatches === 0) {
|
|
13
|
+
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
14
|
+
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
15
|
+
} else if (totalMatches > 0 && analysis.totals.methodMismatches > 0) {
|
|
16
|
+
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
17
|
+
} else if (totalMatches > 0) {
|
|
18
|
+
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
code, risk,
|
|
22
|
+
endpointsWithCallers: endpointsWithCallers.length + transportWithCallers.length,
|
|
23
|
+
callsites: totalMatches,
|
|
24
|
+
affectedFiles: affectedFiles.size,
|
|
25
|
+
affectedScreens: affectedScreens.size,
|
|
26
|
+
methodMismatches: analysis.totals.methodMismatches,
|
|
27
|
+
uncertainCalls: analysis.totals.uncertainCalls + transportAnalysis.totals.uncertain,
|
|
28
|
+
transportContracts: transportAnalysis.totals.contracts,
|
|
29
|
+
transportMatches: transportAnalysis.totals.matches,
|
|
30
|
+
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
31
|
+
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
32
|
+
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
33
|
+
unknownLiveness: analysis.totals.unknownLiveness,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function contractVerdictLine(verdict, contracts) {
|
|
38
|
+
if (verdict.code === 'NO_ENDPOINTS_MATCHED') return 'VERDICT NO_ENDPOINTS_MATCHED — no backend contract satisfied the requested transport/method/path/change filter.'
|
|
39
|
+
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') return `VERDICT NO_STATIC_CLIENT_CALLERS — ${contracts} backend contract(s) matched, but no bounded static client call was proven; this is unknown, not proof of no consumers.`
|
|
40
|
+
if (verdict.code === 'HTTP_METHOD_MISMATCH') return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
41
|
+
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} contract(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
42
|
+
}
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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 =
|
|
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 =
|
|
14
|
+
const CROSS_REPO_HTTP_CONTRACT_V = 3
|
|
13
15
|
const selectorText = (value) => String(value ?? '').trim()
|
|
14
16
|
|
|
15
17
|
function selectRecord(records, selector) {
|
|
@@ -103,52 +105,6 @@ async function reconcileGraph(record, alias, role, graphHome) {
|
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
function verdictFor(analysis) {
|
|
107
|
-
const endpointsWithCallers = analysis.endpoints.filter((endpoint) => endpoint.callsites.length > 0)
|
|
108
|
-
const affectedFiles = new Set()
|
|
109
|
-
const affectedScreens = new Set()
|
|
110
|
-
for (const endpoint of endpointsWithCallers) {
|
|
111
|
-
for (const item of endpoint.affected.files || []) affectedFiles.add(`${item.client}\0${item.file}`)
|
|
112
|
-
for (const item of endpoint.affected.screens || []) affectedScreens.add(`${item.client}\0${item.file}`)
|
|
113
|
-
}
|
|
114
|
-
let code = 'NO_ENDPOINTS_MATCHED', risk = 'unknown'
|
|
115
|
-
if (analysis.totals.endpoints > 0 && analysis.totals.matches === 0) {
|
|
116
|
-
code = analysis.totals.methodMismatches > 0 ? 'HTTP_METHOD_MISMATCH' : 'NO_STATIC_CLIENT_CALLERS'
|
|
117
|
-
risk = analysis.totals.methodMismatches > 0 ? 'high' : 'unknown'
|
|
118
|
-
} else if (analysis.totals.matches > 0 && analysis.totals.methodMismatches > 0) {
|
|
119
|
-
code = 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'; risk = 'high'
|
|
120
|
-
} else if (analysis.totals.matches > 0) {
|
|
121
|
-
code = 'CLIENTS_AT_RISK'; risk = 'medium'
|
|
122
|
-
}
|
|
123
|
-
return {
|
|
124
|
-
code,
|
|
125
|
-
risk,
|
|
126
|
-
endpointsWithCallers: endpointsWithCallers.length,
|
|
127
|
-
callsites: analysis.totals.matches,
|
|
128
|
-
affectedFiles: affectedFiles.size,
|
|
129
|
-
affectedScreens: affectedScreens.size,
|
|
130
|
-
methodMismatches: analysis.totals.methodMismatches,
|
|
131
|
-
uncertainCalls: analysis.totals.uncertainCalls,
|
|
132
|
-
notDeadExternalUse: analysis.totals.notDeadExternalUse,
|
|
133
|
-
notDeadExternalHandlers: analysis.totals.notDeadExternalHandlers,
|
|
134
|
-
possibleExternalUse: analysis.totals.possibleExternalUse,
|
|
135
|
-
unknownLiveness: analysis.totals.unknownLiveness,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function verdictLine(verdict, endpoints) {
|
|
140
|
-
if (verdict.code === 'NO_ENDPOINTS_MATCHED') {
|
|
141
|
-
return 'VERDICT NO_ENDPOINTS_MATCHED — no backend endpoint satisfied the requested method/path/change filter.'
|
|
142
|
-
}
|
|
143
|
-
if (verdict.code === 'NO_STATIC_CLIENT_CALLERS') {
|
|
144
|
-
return `VERDICT NO_STATIC_CLIENT_CALLERS — ${endpoints} backend endpoint(s) matched, but no bounded literal/template client call was proven; this is unknown, not proof of no consumers.`
|
|
145
|
-
}
|
|
146
|
-
if (verdict.code === 'HTTP_METHOD_MISMATCH') {
|
|
147
|
-
return `VERDICT HTTP_METHOD_MISMATCH — ${verdict.methodMismatches} client call(s) match the route shape with a different method.`
|
|
148
|
-
}
|
|
149
|
-
return `VERDICT ${verdict.code} — ${verdict.callsites} callsite(s) reach ${verdict.endpointsWithCallers} endpoint(s); ${verdict.affectedScreens} screen(s) and ${verdict.affectedFiles} file(s) are in the bounded blast radius.`
|
|
150
|
-
}
|
|
151
|
-
|
|
152
108
|
export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
153
109
|
const graphHome = ctx.graphHome || graphHomeDir()
|
|
154
110
|
const records = liveRepositoryRecords(graphHome)
|
|
@@ -215,7 +171,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
215
171
|
}
|
|
216
172
|
|
|
217
173
|
try {
|
|
218
|
-
|
|
174
|
+
let analysis = analyzeHttpContracts({
|
|
219
175
|
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
220
176
|
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
221
177
|
method: args.method,
|
|
@@ -229,9 +185,25 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
229
185
|
maxEndpoints: args.max_endpoints,
|
|
230
186
|
maxMatches: args.max_matches,
|
|
231
187
|
maxAffectedFiles: args.max_affected_files,
|
|
188
|
+
runtimeValues: args.runtime_config,
|
|
232
189
|
})
|
|
233
|
-
const
|
|
234
|
-
|
|
190
|
+
const selectedTransport = ['all', 'http', 'graphql', 'grpc', 'event'].includes(args.transport) ? args.transport : 'all'
|
|
191
|
+
if (!['all', 'http'].includes(selectedTransport)) analysis = {
|
|
192
|
+
...analysis, status: 'complete', completeness: {complete: true, reasons: []}, endpoints: [], uncertain: [],
|
|
193
|
+
totals: {...analysis.totals, endpoints: 0, clientCalls: 0, matches: 0, methodMismatches: 0, uncertainCalls: 0, notDeadExternalUse: 0, notDeadExternalHandlers: 0, possibleExternalUse: 0, unknownLiveness: 0},
|
|
194
|
+
}
|
|
195
|
+
const transportAnalysis = selectedTransport === 'http'
|
|
196
|
+
? {transportContractsV: 1, transport: 'http', status: 'COMPLETE', completeness: {complete: true, reasons: []}, totals: {contracts: 0, matches: 0, uncertain: 0, filesScanned: 0}, contracts: [], uncertain: []}
|
|
197
|
+
: analyzeTransportContracts({
|
|
198
|
+
backend: {id: backendAlias, repoRoot: backend.repoPath, graph: backendGraph.graph},
|
|
199
|
+
clients: clientGraphs.map((item) => ({id: item.alias, repoRoot: item.record.repoPath, graph: item.graph})),
|
|
200
|
+
transport: selectedTransport === 'all' ? 'all' : selectedTransport,
|
|
201
|
+
includeTests: args.include_tests === true,
|
|
202
|
+
maxImpactDepth: args.max_impact_depth,
|
|
203
|
+
maxAffectedFiles: args.max_affected_files,
|
|
204
|
+
})
|
|
205
|
+
const verdict = contractVerdict(analysis, transportAnalysis)
|
|
206
|
+
const reasons = [...new Set([...reconciliationReasons, ...(analysis.completeness?.reasons || []), ...(transportAnalysis.completeness?.reasons || [])])]
|
|
235
207
|
const completeness = {
|
|
236
208
|
complete: reasons.length === 0 && analysis.completeness?.complete === true,
|
|
237
209
|
status: reasons.length === 0 && analysis.completeness?.complete === true ? 'COMPLETE' : 'PARTIAL',
|
|
@@ -243,8 +215,8 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
243
215
|
right.affected.files.length - left.affected.files.length ||
|
|
244
216
|
left.path.localeCompare(right.path))
|
|
245
217
|
const lines = [
|
|
246
|
-
|
|
247
|
-
`Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} endpoint(s), ${analysis.totals.clientCalls} inspected
|
|
218
|
+
contractVerdictLine(verdict, analysis.totals.endpoints + transportAnalysis.totals.contracts),
|
|
219
|
+
`Scope: ${backend.label} → ${clients.map(({record}) => record.label).join(', ')}; ${analysis.totals.endpoints} HTTP endpoint(s), ${transportAnalysis.totals.contracts} GraphQL/gRPC/event contract(s), ${analysis.totals.clientCalls} inspected HTTP call(s), ${verdict.uncertainCalls} uncertain.`,
|
|
248
220
|
...ranked.slice(0, topN).flatMap((endpoint) => {
|
|
249
221
|
const location = endpoint.file ? ` (${endpoint.backend}:${endpoint.file}${endpoint.line ? `:${endpoint.line}` : ''})` : ''
|
|
250
222
|
const callsites = endpoint.callsites.slice(0, 3)
|
|
@@ -257,8 +229,10 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
257
229
|
...screens,
|
|
258
230
|
]
|
|
259
231
|
}),
|
|
232
|
+
...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
|
|
233
|
+
` ${contract.transport.toUpperCase()} ${contract.service ? `${contract.service}.` : contract.operation ? `${contract.operation} ` : ''}${contract.name} (${contract.file}:${contract.line}) [${contract.liveness}] → ${contract.callsites.length} callsite(s), ${contract.affected.files.length} affected file(s)`),
|
|
260
234
|
completeness.complete
|
|
261
|
-
? 'Completeness: complete within the declared repository graphs and static HTTP
|
|
235
|
+
? 'Completeness: complete within the declared repository graphs and supported static HTTP/GraphQL/gRPC/event models.'
|
|
262
236
|
: `Completeness: partial — ${completeness.reasons.join('; ')}.`,
|
|
263
237
|
]
|
|
264
238
|
const result = {
|
|
@@ -269,6 +243,7 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
|
|
|
269
243
|
clients: clients.map(({record, alias}) => publicRecord(record, alias)),
|
|
270
244
|
},
|
|
271
245
|
...analysis,
|
|
246
|
+
transportContracts: transportAnalysis,
|
|
272
247
|
status: completeness.status,
|
|
273
248
|
graphReconciliation,
|
|
274
249
|
completeness,
|
|
@@ -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
|
+
}
|