weavatrix 0.2.15 → 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 (46) hide show
  1. package/README.md +53 -13
  2. package/docs/releases/v0.2.16.md +53 -0
  3. package/package.json +6 -2
  4. package/src/analysis/cargo-dependency-evidence.js +111 -0
  5. package/src/analysis/cargo-manifests.js +74 -0
  6. package/src/analysis/dep-check-ecosystems.js +3 -0
  7. package/src/analysis/endpoints-java.js +2 -5
  8. package/src/analysis/findings.js +12 -0
  9. package/src/analysis/go-dependency-evidence.js +68 -0
  10. package/src/analysis/health-capabilities.js +1 -1
  11. package/src/analysis/http-contracts/analysis.js +2 -0
  12. package/src/analysis/http-contracts/client-call-detection.js +1 -0
  13. package/src/analysis/http-contracts/client-call-parser.js +18 -4
  14. package/src/analysis/internal-audit/dependency-health.js +15 -13
  15. package/src/analysis/internal-audit/python-manifests.js +18 -5
  16. package/src/analysis/internal-audit/supply-chain.js +2 -0
  17. package/src/analysis/internal-audit.run.js +12 -7
  18. package/src/analysis/jvm-dependency-evidence.js +102 -56
  19. package/src/analysis/jvm-manifests.js +114 -0
  20. package/src/analysis/source-correctness.js +2 -5
  21. package/src/analysis/transport-contracts.js +157 -0
  22. package/src/graph/builder/lang-go.js +4 -2
  23. package/src/graph/builder/lang-java.js +12 -3
  24. package/src/graph/builder/lang-rust.js +22 -3
  25. package/src/graph/freshness-probe.js +1 -1
  26. package/src/graph/internal-builder.build.js +2 -2
  27. package/src/graph/internal-builder.resolvers.js +18 -8
  28. package/src/mcp/actions/advisories.mjs +2 -3
  29. package/src/mcp/catalog.mjs +7 -4
  30. package/src/mcp/company-contract-verdict.mjs +42 -0
  31. package/src/mcp/tools-company.mjs +28 -53
  32. package/src/mcp/tools-impact-precision.mjs +89 -0
  33. package/src/mcp/tools-impact.mjs +52 -94
  34. package/src/precision/lsp-overlay/build.js +10 -0
  35. package/src/precision/lsp-overlay/contract.js +1 -1
  36. package/src/precision/symbol-query.js +39 -0
  37. package/src/precision/typescript-provider/client.js +16 -3
  38. package/src/precision/typescript-provider/discovery.js +1 -1
  39. package/src/precision/typescript-provider/isolated-runtime.js +39 -0
  40. package/src/precision/typescript-provider/project-host.js +11 -6
  41. package/src/precision/typescript-provider/project-safety.js +5 -0
  42. package/src/security/advisory-store.js +2 -2
  43. package/src/security/installed-jvm-rust.js +46 -0
  44. package/src/security/installed.js +21 -1
  45. package/src/util.js +8 -0
  46. package/docs/releases/v0.2.15.md +0 -37
@@ -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,
@@ -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
+ }
@@ -2,15 +2,13 @@
2
2
  // last rebuild (graph_diff), and the blast radius of the current change set (change_impact).
3
3
  // Hot-reloadable (re-imported by catalog.mjs on change).
4
4
  import {readFileSync} from 'node:fs'
5
- import {spawnSync} from 'node:child_process'
6
- import {childProcessEnv} from '../child-env.js'
7
5
  import {
8
6
  isSymbol, degreeOf, labelOf, resolveNodeInfo, ambiguityNote,
9
7
  rawGraph, prevGraphPathFor, edgeEndpoint, diffGraphs, formatGraphDiff,
10
8
  } from './graph-context.mjs'
11
- import {readCoverageForRepo} from '../analysis/coverage-reports.js'
9
+ import {querySymbolPrecision} from '../precision/symbol-query.js'
10
+ import {graphWithExactDirectReferences, refineChangeImpact} from './tools-impact-precision.mjs'
12
11
  import {reverseReach} from './graph/reverse-reach.mjs'
13
- import {gitLines} from './git-output.mjs'
14
12
  import {tChangeImpactV2} from './tools-impact-change.mjs'
15
13
  import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
16
14
 
@@ -24,7 +22,7 @@ const impactKind = (entry) => {
24
22
  // Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
25
23
  // (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
26
24
  // containing file, because importers depend on the file rather than the individual symbol.
27
- export function tGetDependents(g, {label, depth = 3, max_nodes = 40, include_container_importers = false} = {}) {
25
+ function getDependentsFromGraph(g, {label, depth = 3, max_nodes = 40, include_container_importers = false} = {}, precisionNote = null) {
28
26
  const info = resolveNodeInfo(g, label)
29
27
  const n = info.node
30
28
  if (!n) return `No node found matching "${label}".`
@@ -46,18 +44,64 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40, include_con
46
44
  .filter(([nid]) => !seeds.has(nid))
47
45
  .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid)}))
48
46
  .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
49
- if (!ranked.length) return [note, `No dependents found for ${n.label ?? id} within depth ${maxDepth} — nothing in the graph calls, imports, or inherits it.`].filter(Boolean).join('\n')
47
+ if (!ranked.length) return [note, precisionNote, `No dependents found for ${n.label ?? id} within depth ${maxDepth} — nothing in the graph calls, imports, or inherits it.`].filter(Boolean).join('\n')
50
48
  const shown = ranked.slice(0, cap)
51
49
  const runtimeCount = ranked.filter((entry) => !entry.entry.compileOnly).length
52
50
  const compileCount = ranked.length - runtimeCount
53
51
  return [
54
52
  note,
53
+ precisionNote,
55
54
  `Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
56
55
  containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)} by explicit request.` : null,
57
56
  ...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
58
57
  ].filter(Boolean).join('\n')
59
58
  }
60
59
 
60
+ export function tGetDependents(g, args = {}, ctx = {}) {
61
+ const info = resolveNodeInfo(g, args.label)
62
+ const id = String(info.node?.id || '')
63
+ const requested = ['auto', 'graph', 'lsp'].includes(args.precision) ? args.precision : 'auto'
64
+ const canQuery = isSymbol(id) && ctx.repoRoot && ctx.graphPath
65
+ && (requested === 'lsp' || (requested === 'auto' && g.graphPrecisionMode !== 'off'))
66
+ if (!canQuery) {
67
+ const note = isSymbol(id) && requested !== 'graph'
68
+ ? 'Semantic precision: graph-only; an exact point query was unavailable or disabled.' : null
69
+ return getDependentsFromGraph(g, args, note)
70
+ }
71
+ return (async () => {
72
+ try {
73
+ const result = await querySymbolPrecision({
74
+ repoRoot: ctx.repoRoot,
75
+ graphPath: ctx.graphPath,
76
+ targetId: id,
77
+ maxReferences: Math.max(100, Math.min(5_000, Number(args.max_references) || 1_000)),
78
+ timeoutMs: Math.max(1_000, Math.min(60_000, Number(args.timeout_ms) || 30_000)),
79
+ clientFactory: ctx.precisionClientFactory,
80
+ })
81
+ const exactGraph = graphWithExactDirectReferences(g, id, result.overlay)
82
+ if (exactGraph) {
83
+ const count = (result.overlay.links || []).filter((link) => String(link?.target || '') === id).length
84
+ return getDependentsFromGraph(
85
+ exactGraph,
86
+ args,
87
+ `Semantic precision: EXACT_LSP point query${result.cached ? ' (cache hit)' : ''}; ${count} classified direct reference edge(s).`,
88
+ )
89
+ }
90
+ return getDependentsFromGraph(
91
+ g,
92
+ args,
93
+ `Semantic precision: ${result.overlay?.state || 'UNAVAILABLE'}; exact absence was not proven, so graph edges are retained (${result.overlay?.reason || 'incomplete project coverage'}).`,
94
+ )
95
+ } catch (error) {
96
+ return getDependentsFromGraph(
97
+ g,
98
+ args,
99
+ `Semantic precision: UNAVAILABLE; graph evidence retained (${error?.message || 'point query failed'}).`,
100
+ )
101
+ }
102
+ })()
103
+ }
104
+
61
105
  // Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
62
106
  export async function tGraphDiff(g, args, ctx) {
63
107
  const current = rawGraph(ctx)
@@ -106,98 +150,12 @@ export async function tGraphDiff(g, args, ctx) {
106
150
  }
107
151
 
108
152
  // ---- change impact -------------------------------------------------------------------------------
109
- function resolveImpactBase(repoRoot, requested) {
110
- const candidates = requested ? [requested] : ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
111
- for (const ref of candidates) {
112
- const ok = spawnSync('git', ['-C', repoRoot, 'rev-parse', '--verify', '--quiet', `${ref}^{commit}`], {encoding: 'utf8', timeout: 8000, env: childProcessEnv()})
113
- if (ok.status === 0) return ref
114
- }
115
- return null
116
- }
117
-
118
153
  // Blast radius of a change, without any GitHub API: diff the CURRENT change (branch
119
154
  // commits since the merge-base + staged/unstaged + untracked) against a base ref, map the changed
120
155
  // files and their symbols onto the graph, and walk REVERSE dependency edges — everything the change
121
156
  // can break, ranked by proximity + connectivity, with file-level test coverage attached so the
122
157
  // untested part of the blast radius stands out. The pre-PR review, in one call.
123
158
  export function tChangeImpact(g, args, ctx) {
124
- return tChangeImpactV2(g, args, ctx)
125
- }
126
-
127
- // Kept private during the v2 rollout so the surrounding get_dependents/graph_diff implementation is
128
- // untouched; focused tests exercise the exported symbol-aware path above.
129
- function tChangeImpactLegacy(g, args, ctx) {
130
- if (!ctx.repoRoot) return 'change_impact needs the repo root (not provided to this server).'
131
- // Explicit file list (e.g. a PR's changed files) skips the local git diff entirely — this is how
132
- // a NOT-checked-out PR gets its impact assessed.
133
- const explicit = Array.isArray(args.files)
134
- ? [...new Set(args.files.map((f) => String(f).replace(/\\/g, '/').trim()).filter(Boolean))]
135
- : null
136
- let changed
137
- let sourceLabel
138
- if (explicit) {
139
- if (!explicit.length) return 'files was provided but empty — pass repo-relative paths, or omit it to diff the local change.'
140
- changed = explicit
141
- sourceLabel = `for ${changed.length} provided file(s)`
142
- } else {
143
- const base = resolveImpactBase(ctx.repoRoot, args.base ? String(args.base).trim() : '')
144
- if (!base) return `Could not resolve a base ref${args.base ? ` ("${args.base}")` : ''} — pass base explicitly (e.g. origin/main or HEAD~1).`
145
- const committed = gitLines(ctx.repoRoot, ['diff', '--name-only', `${base}...HEAD`])
146
- if (committed === null) return `git diff against ${base} failed — is ${ctx.repoRoot} a git repository?`
147
- const uncommitted = gitLines(ctx.repoRoot, ['diff', '--name-only', 'HEAD']) || []
148
- const untracked = gitLines(ctx.repoRoot, ['ls-files', '--others', '--exclude-standard']) || []
149
- changed = [...new Set([...committed, ...uncommitted, ...untracked])]
150
- sourceLabel = `vs ${base}`
151
- if (!changed.length) return `No changes vs ${base} — working tree clean and no branch commits.`
152
- }
153
-
154
- const changedSet = new Set(changed)
155
- const seeds = new Set()
156
- const unmapped = []
157
- for (const file of changed) {
158
- if (!g.byId.has(file)) { unmapped.push(file); continue }
159
- seeds.add(file)
160
- for (const e of g.out.get(file) || []) if (e.relation === 'contains') seeds.add(String(e.id))
161
- }
162
- if (!seeds.size) {
163
- return [
164
- `${changed.length} changed file(s) ${sourceLabel}, but none are in the graph — new files or non-code.`,
165
- `Run rebuild_graph and retry for the full picture.`,
166
- `Changed: ${changed.slice(0, 20).join(', ')}${changed.length > 20 ? ', …' : ''}`,
167
- ].join('\n')
168
- }
169
-
170
- const maxDepth = Math.max(1, Math.min(4, Number(args.depth) || 2))
171
- const cap = Math.max(5, Math.min(120, Number(args.max_nodes) || 40))
172
- const reached = reverseReach(g, seeds, maxDepth)
173
- const fileOfNode = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
174
- const impacted = [...reached.entries()]
175
- .filter(([nid]) => !seeds.has(nid) && !changedSet.has(fileOfNode(nid)))
176
- .map(([nid, entry]) => ({id: nid, d: entry.depth, entry, deg: degreeOf(g, nid), file: fileOfNode(nid)}))
177
- .sort((a, b) => a.d - b.d || Number(a.entry.compileOnly) - Number(b.entry.compileOnly) || b.deg - a.deg)
178
-
179
- // coverage overlay from EXISTING reports (fractions 0..1) — see coverage_map for details
180
- const knownFiles = (rawGraph(ctx).nodes || []).filter((n) => !String(n.id).includes('#') && n.source_file).map((n) => n.source_file)
181
- const coverage = readCoverageForRepo(ctx.repoRoot, knownFiles)
182
- const covOf = (file) => coverage.get(String(file).replace(/\\/g, '/'))?.pct ?? null
183
- const pctStr = (v) => (v == null ? 'cov n/a' : `cov ${Math.round(v * 100)}%`)
184
- const hasCoverage = coverage.size > 0
185
-
186
- const shown = impacted.slice(0, cap)
187
- const runtimeImpacted = impacted.filter((entry) => !entry.entry.compileOnly).length
188
- const compileImpacted = impacted.length - runtimeImpacted
189
- const untestedHotspots = shown.filter((n) => { const c = covOf(n.file); return c != null && c < 0.5 && n.deg >= 5 })
190
- return [
191
- `Change impact ${sourceLabel}: ${changed.length} changed file(s) → ${seeds.size} graph seed(s) incl. their symbols${unmapped.length ? `; ${unmapped.length} file(s) not in the graph (new/non-code — rebuild_graph refreshes)` : ''}.`,
192
- impacted.length
193
- ? `${impacted.length} impacted node(s) within ${maxDepth} reverse hop(s) (${runtimeImpacted} runtime, ${compileImpacted} compile-time-only), showing ${shown.length}:`
194
- : `Nothing else in the graph depends on the changed code within ${maxDepth} hop(s).`,
195
- hasCoverage ? `Coverage: existing report mapped where available.` : `Coverage: unavailable (no supported report found); per-node coverage labels omitted.`,
196
- ...shown.map((n) => ` [d${n.d} ${impactKind(n.entry)}] ${n.entry.relation || 'rel'} ${labelOf(g, n.id)} (deg ${n.deg}${hasCoverage ? `, ${pctStr(covOf(n.file))}` : ''}) [${n.id}]`),
197
- untestedHotspots.length ? `` : null,
198
- untestedHotspots.length ? `Untested hotspots in the blast radius (<50% coverage, deg ≥5) — cover these before shipping:` : null,
199
- ...untestedHotspots.slice(0, 10).map((n) => ` ${labelOf(g, n.id)} (${pctStr(covOf(n.file))}, deg ${n.deg}) ${n.file}`),
200
- ``,
201
- `Drill into any node with get_dependents / read_source; per-symbol coverage via coverage_map.`,
202
- ].filter((x) => x != null).join('\n')
159
+ const baseline = tChangeImpactV2(g, args, ctx)
160
+ return refineChangeImpact(g, args, ctx, baseline, tChangeImpactV2)
203
161
  }
@@ -51,8 +51,14 @@ function completedOverlay(session) {
51
51
  language: 'typescript/javascript',
52
52
  capability: 'textDocument/references',
53
53
  status: state,
54
+ configuredPluginsSuppressed: session.semanticInputs.pluginsSuppressed || 0,
55
+ repoLocalPluginLoads: false,
54
56
  }],
55
57
  semanticInputFingerprint: session.semanticInputs.fingerprint,
58
+ pluginPolicy: {
59
+ configuredPluginsSuppressed: session.semanticInputs.pluginsSuppressed || 0,
60
+ repoLocalPluginLoads: false,
61
+ },
56
62
  coverage: coverage(session),
57
63
  links: session.links,
58
64
  referenceEvidence: session.referenceEvidence,
@@ -181,6 +187,10 @@ export async function buildLspPrecisionOverlay({
181
187
  return persist(session, baseOverlay(graph, 'COMPLETE', {
182
188
  request: session.request,
183
189
  semanticInputFingerprint: session.semanticInputs.fingerprint,
190
+ pluginPolicy: {
191
+ configuredPluginsSuppressed: session.semanticInputs.pluginsSuppressed || 0,
192
+ repoLocalPluginLoads: false,
193
+ },
184
194
  reason: 'no eligible JavaScript/TypeScript semantic targets',
185
195
  }))
186
196
  }
@@ -1,6 +1,6 @@
1
1
  import {typeScriptLspContract} from '../typescript-lsp-provider.js'
2
2
 
3
- export const PRECISION_OVERLAY_V = 3
3
+ export const PRECISION_OVERLAY_V = 4
4
4
  export const PRECISION_FILE = 'precision.json'
5
5
  export const JS_TS_FILE = /\.(?:[cm]?[jt]sx?)$/i
6
6
  export const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
@@ -182,3 +182,42 @@ export async function querySymbolPrecision({
182
182
  inFlight.delete(flightKey)
183
183
  }
184
184
  }
185
+
186
+ export async function querySymbolsPrecision({
187
+ repoRoot,
188
+ graphPath,
189
+ targetIds,
190
+ maxReferences = 5_000,
191
+ timeoutMs = 45_000,
192
+ clientFactory,
193
+ } = {}) {
194
+ if (!repoRoot || !graphPath || !Array.isArray(targetIds) || !targetIds.length) {
195
+ throw new Error('batch symbol precision requires repoRoot, graphPath, and targetIds')
196
+ }
197
+ const ids = [...new Set(targetIds.map(String).filter(Boolean))].slice(0, 16)
198
+ const boundedReferences = boundedInteger(maxReferences, 5_000, 1, 16_384)
199
+ const boundedTimeout = boundedInteger(timeoutMs, 45_000, 1_000, 60_000)
200
+ const rawGraph = loadRawGraph(graphPath)
201
+ const graph = rawGraph.graphPrecisionMode === 'off' ? {...rawGraph, graphPrecisionMode: 'lsp'} : rawGraph
202
+ const nodes = new Map((graph.nodes || []).map((node) => [String(node?.id || ''), node]))
203
+ for (const id of ids) {
204
+ const target = nodes.get(id)
205
+ if (!target) throw new Error(`precision target is absent from the active graph: ${id}`)
206
+ if (!target.selection_start || !/\.(?:[cm]?[jt]sx?)$/i.test(String(target.source_file || ''))) {
207
+ throw new Error(`exact batch precision does not support target: ${id}`)
208
+ }
209
+ }
210
+ const startedAt = Date.now()
211
+ const overlay = await buildLspPrecisionOverlay({
212
+ repoRoot,
213
+ graph,
214
+ mode: 'lsp',
215
+ maxSymbols: ids.length,
216
+ maxReferences: boundedReferences,
217
+ maxLinks: boundedReferences,
218
+ timeoutMs: boundedTimeout,
219
+ targetIds: ids,
220
+ clientFactory,
221
+ })
222
+ return {overlay, targetIds: ids, elapsedMs: Date.now() - startedAt}
223
+ }
@@ -6,11 +6,13 @@ import {
6
6
  typeScriptLanguageId,
7
7
  typeScriptLspContract,
8
8
  } from './discovery.js'
9
+ import {isolateTypeScriptRuntime} from './isolated-runtime.js'
9
10
 
10
11
  /** Starts Weavatrix's bundled TypeScript server without executing repository configuration. */
11
12
  export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} = {}) {
12
13
  const discovered = discoverTypeScriptProvider()
13
14
  const absoluteRepoRoot = resolve(repoRoot)
15
+ const isolated = isolateTypeScriptRuntime(discovered.tsserverPath)
14
16
  let client
15
17
  let reportedTypeScript = null
16
18
  try {
@@ -41,11 +43,16 @@ export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} =
41
43
  initializationOptions: {
42
44
  hostInfo: 'weavatrix',
43
45
  disableAutomaticTypingAcquisition: true,
44
- tsserver: {path: discovered.tsserverPath},
46
+ tsserver: {path: isolated.tsserverPath},
47
+ // An explicit empty list keeps the language-server plugin manager
48
+ // from adding --globalPlugins or --pluginProbeLocations. tsserver's
49
+ // local-plugin loading remains disabled by default as well.
50
+ plugins: [],
45
51
  },
46
52
  })
47
53
  } catch (error) {
48
54
  client?.kill(error)
55
+ isolated.cleanup()
49
56
  throw error
50
57
  }
51
58
  return Object.freeze({
@@ -63,7 +70,13 @@ export async function createTypeScriptLspClient({repoRoot, timeoutMs = 10_000} =
63
70
  },
64
71
  definition(relPath, position) { return client.definition({filePath: relPath, position}) },
65
72
  closeDocument(relPath) { return client.closeDocument(relPath) },
66
- close(shutdownTimeoutMs = timeoutMs) { return client.shutdown({timeoutMs: shutdownTimeoutMs}) },
67
- kill() { client.kill() },
73
+ async close(shutdownTimeoutMs = timeoutMs) {
74
+ try { return await client.shutdown({timeoutMs: shutdownTimeoutMs}) }
75
+ finally { isolated.cleanup() }
76
+ },
77
+ kill() {
78
+ try { client.kill() }
79
+ finally { isolated.cleanup() }
80
+ },
68
81
  })
69
82
  }
@@ -4,7 +4,7 @@ import {createRequire} from 'node:module'
4
4
 
5
5
  const requireFromWeavatrix = createRequire(import.meta.url)
6
6
  export const PROVIDER = 'typescript-language-server'
7
- export const TYPESCRIPT_LSP_CAPABILITY_CONTRACT = 'typescript-references-v3'
7
+ export const TYPESCRIPT_LSP_CAPABILITY_CONTRACT = 'typescript-references-v4-plugin-suppression'
8
8
  let discoveredProvider = null
9
9
 
10
10
  function resolveOwn(specifier) {
@@ -0,0 +1,39 @@
1
+ import {copyFileSync, linkSync, mkdirSync, mkdtempSync, readdirSync, rmSync} from 'node:fs'
2
+ import {tmpdir} from 'node:os'
3
+ import {basename, dirname, join} from 'node:path'
4
+
5
+ const RUNTIME_FILES = new Set([
6
+ 'tsserver.js', '_tsserver.js', 'typescript.js',
7
+ 'typingsInstaller.js', '_typingsInstaller.js', 'typesMap.json', 'watchGuard.js',
8
+ ])
9
+
10
+ function materialize(source, target) {
11
+ try { linkSync(source, target) }
12
+ catch { copyFileSync(source, target) }
13
+ }
14
+
15
+ export function isolateTypeScriptRuntime(tsserverPath) {
16
+ const sourceLib = dirname(tsserverPath)
17
+ const sourceRoot = dirname(sourceLib)
18
+ const root = mkdtempSync(join(tmpdir(), 'weavatrix-tsserver-'))
19
+ const lib = join(root, 'lib')
20
+ mkdirSync(lib, {recursive: true, mode: 0o700})
21
+ try {
22
+ materialize(join(sourceRoot, 'package.json'), join(root, 'package.json'))
23
+ const files = readdirSync(sourceLib).filter((name) =>
24
+ RUNTIME_FILES.has(name) || /^lib(?:\..+)?\.d\.ts$/i.test(name))
25
+ for (const name of files) materialize(join(sourceLib, name), join(lib, basename(name)))
26
+ let cleaned = false
27
+ return {
28
+ tsserverPath: join(lib, 'tsserver.js'),
29
+ cleanup() {
30
+ if (cleaned) return
31
+ cleaned = true
32
+ try { rmSync(root, {recursive: true, force: true}) } catch { /* process cleanup fallback */ }
33
+ },
34
+ }
35
+ } catch (error) {
36
+ try { rmSync(root, {recursive: true, force: true}) } catch { /* best effort */ }
37
+ throw error
38
+ }
39
+ }
@@ -154,7 +154,7 @@ export function parseRepoConfig(context, configPath, budget) {
154
154
  const state = {
155
155
  outsideAccess: false,
156
156
  limitExceeded: false,
157
- configuredPlugins: false,
157
+ configuredPlugins: new Set(),
158
158
  bytes: 0,
159
159
  records: new Map(),
160
160
  }
@@ -197,8 +197,9 @@ export function parseRepoConfig(context, configPath, budget) {
197
197
  }
198
198
  try {
199
199
  const raw = ts.parseConfigFileTextToJson(path, body.toString('utf8')).config
200
- if (Array.isArray(raw?.compilerOptions?.plugins) && raw.compilerOptions.plugins.length) {
201
- state.configuredPlugins = true
200
+ for (const plugin of raw?.compilerOptions?.plugins || []) {
201
+ const name = typeof plugin?.name === 'string' ? plugin.name.trim() : ''
202
+ if (name) state.configuredPlugins.add(name.slice(0, 256))
202
203
  }
203
204
  } catch { /* parser diagnostics below decide safety */ }
204
205
  return body.toString('utf8')
@@ -213,8 +214,9 @@ export function parseRepoConfig(context, configPath, budget) {
213
214
  try { parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, host) }
214
215
  catch { return {complete: false, reason: 'CONFIG_PARSE_FAILED'} }
215
216
  const errors = [...diagnostics, ...(parsed?.errors || [])]
216
- if (state.configuredPlugins || (Array.isArray(parsed?.options?.plugins) && parsed.options.plugins.length)) {
217
- return {complete: false, reason: 'CONFIGURED_TSSERVER_PLUGINS'}
217
+ for (const plugin of parsed?.options?.plugins || []) {
218
+ const name = typeof plugin?.name === 'string' ? plugin.name.trim() : ''
219
+ if (name) state.configuredPlugins.add(name.slice(0, 256))
218
220
  }
219
221
  if (budget.reason) return {complete: false, reason: budget.reason}
220
222
  if (!parsed || state.outsideAccess || state.limitExceeded
@@ -244,6 +246,9 @@ export function parseRepoConfig(context, configPath, budget) {
244
246
  projectFiles,
245
247
  projectKeys,
246
248
  configRecords: state.records,
247
- plugins: [],
249
+ // The bundled provider runs an isolated tsserver copy whose plugin search
250
+ // root contains only TypeScript runtime files. Keep configured plugin names
251
+ // as evidence, but never execute them from the inspected repository/bundle.
252
+ plugins: [...state.configuredPlugins].sort(),
248
253
  }
249
254
  }