weavatrix 0.3.9 → 0.3.11

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 (40) hide show
  1. package/README.md +29 -4
  2. package/docs/releases/v0.3.10.md +76 -0
  3. package/docs/releases/v0.3.11.md +55 -0
  4. package/package.json +4 -2
  5. package/skill/SKILL.md +13 -6
  6. package/src/analysis/dead-check.js +10 -8
  7. package/src/analysis/dead-code-review.js +3 -0
  8. package/src/analysis/dep-rules.js +1 -1
  9. package/src/analysis/hot-path-review.js +1 -1
  10. package/src/analysis/http-contracts/analysis.js +9 -1
  11. package/src/analysis/structure/findings.js +21 -1
  12. package/src/analysis/task-retrieval.js +2 -0
  13. package/src/build-graph.js +9 -1
  14. package/src/graph/builder/lang-rust.js +36 -0
  15. package/src/graph/builder/lang-solidity.js +126 -0
  16. package/src/graph/builder/lang-sql.js +263 -0
  17. package/src/graph/builder/pass2-resolution.js +3 -1
  18. package/src/graph/freshness-probe.js +1 -1
  19. package/src/graph/graph-filter.js +5 -3
  20. package/src/graph/incremental-refresh.js +1 -1
  21. package/src/graph/internal-builder.build.js +18 -6
  22. package/src/graph/internal-builder.langs.js +3 -1
  23. package/src/graph/internal-builder.resolvers.js +31 -1
  24. package/src/graph/parser-artifact-boundary.js +1 -0
  25. package/src/mcp/actions/graph-lifecycle.mjs +3 -3
  26. package/src/mcp/architecture-starter.mjs +1 -1
  27. package/src/mcp/catalog.mjs +2 -2
  28. package/src/mcp/evidence-snapshot.structure.mjs +5 -2
  29. package/src/mcp/graph/context-seeds.mjs +6 -2
  30. package/src/mcp/graph/tools-query.mjs +28 -2
  31. package/src/mcp/health/endpoints.mjs +34 -5
  32. package/src/mcp/sync/payload-v2.mjs +1 -0
  33. package/src/mcp/tools-company.mjs +9 -0
  34. package/src/mcp/tools-endpoints.mjs +54 -12
  35. package/src/mcp/tools-graph-hubs.mjs +1 -0
  36. package/src/mcp/tools-impact.mjs +9 -1
  37. package/src/precision/lsp-overlay/build.js +9 -4
  38. package/src/precision/lsp-overlay/contract.js +1 -1
  39. package/src/precision/lsp-overlay/target-index.js +8 -3
  40. package/src/precision/symbol-query.js +2 -1
@@ -6,6 +6,7 @@ import {createPathClassifier, hasPathClass} from '../../path-classification.js'
6
6
 
7
7
  const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
8
8
  const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
9
+ const AMBIGUOUS_FILE_BASENAME_RE = /^(mod|lib|main)\.rs$|^index\.[a-z0-9]+$|^__init__\.py$/
9
10
  const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
10
11
  const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
11
12
  const exactSymbolName = (node) => {
@@ -34,6 +35,23 @@ function resolveExactSeedSymbols(g, requested, limit = 12) {
34
35
  return {seeds, missing, ambiguous}
35
36
  }
36
37
 
38
+ // Edge/hop lines print bare labels without the [id] suffix node lines carry, so repeated or
39
+ // conventionally-generic file basenames (mod.rs, index.ts, __init__.py) become indistinguishable —
40
+ // widen those, scoped to the shown ids, to the last two path segments.
41
+ const makeEdgeLabel = (g, shownIds) => {
42
+ const counts = new Map()
43
+ for (const id of shownIds) {
44
+ const label = labelOf(g, id)
45
+ counts.set(label, (counts.get(label) || 0) + 1)
46
+ }
47
+ return (id) => {
48
+ const label = labelOf(g, id)
49
+ const isFile = String(id).includes('/') && !String(id).includes('#')
50
+ if (isFile && ((counts.get(label) || 0) > 1 || AMBIGUOUS_FILE_BASENAME_RE.test(label))) return String(id).split('/').slice(-2).join('/')
51
+ return label
52
+ }
53
+ }
54
+
37
55
  const relationSet = (relationFilter, legacyFilter) => {
38
56
  const raw = relationFilter ?? legacyFilter
39
57
  const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
@@ -89,6 +107,12 @@ export function tQueryGraph(g, {
89
107
  const node = g.byId.get(String(id))
90
108
  const file = querySourceFile(node)
91
109
  if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
110
+ // Extractor-proven test symbols (Rust #[cfg(test)]) live in production files; suppress them
111
+ // under the same policy as path-classified tests unless the question asks about tests.
112
+ if (node?.test_surface === true && !requestedClasses.has('test')) {
113
+ classifiedSuppressed.add(String(id))
114
+ return {ok: false, bucket: 'classified'}
115
+ }
92
116
  if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
93
117
  const info = classificationCache.get(file)
94
118
  const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
@@ -180,7 +204,8 @@ export function tQueryGraph(g, {
180
204
  'Nodes:',
181
205
  ]
182
206
  const nodeLines = shown.map((node) => ` [d${node.distance}] ${labelOf(g, node.id)} (deg ${node.degree}) [${node.id}]`)
183
- const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${labelOf(g, source)} --${relation || 'rel'}--> ${labelOf(g, target)}`)]
207
+ const edgeLabel = makeEdgeLabel(g, shownIds)
208
+ const edgeLines = ['', 'Edges:', ...shownEdges.map(([source, relation, target]) => ` ${edgeLabel(source)} --${relation || 'rel'}--> ${edgeLabel(target)}`)]
184
209
  let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
185
210
  if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
186
211
  return text
@@ -218,6 +243,7 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
218
243
  if (!previous.has(targetId)) return `No path found between "${sourceNode.label ?? sourceId}" and "${targetNode.label ?? targetId}" within ${limit} hops.`
219
244
  const path = []
220
245
  for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
221
- const lines = path.map((id, index) => index === 0 ? ` ${labelOf(g, id)}` : ` --${relationTo.get(id) || 'rel'}--> ${labelOf(g, id)}`)
246
+ const edgeLabel = makeEdgeLabel(g, path)
247
+ const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
222
248
  return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
223
249
  }
@@ -1,5 +1,6 @@
1
1
  import {rawGraph} from '../graph-context.mjs'
2
2
  import {analyzeEndpointInventory} from '../../analysis/endpoints.js'
3
+ import {PATH_CLASS_NAMES, createPathClassifier, hasPathClass} from '../../path-classification.js'
3
4
  import {toolResult} from '../tool-result.mjs'
4
5
 
5
6
  export function tListEndpoints(g, args, ctx) {
@@ -16,12 +17,34 @@ export function tListEndpoints(g, args, ctx) {
16
17
  const path = args.path ? String(args.path) : null
17
18
  if (method) eps = eps.filter((endpoint) => endpoint.method === method)
18
19
  if (path) eps = eps.filter((endpoint) => endpoint.path === path || endpoint.path.endsWith(path))
19
- if (!eps.length) return 'No HTTP endpoints detected in the indexed code files.'
20
+ // Production-first: classified endpoints are suppressed by default; a tests-only graph would
21
+ // suppress everything, so that build mode auto-includes them (same precedent as module_map).
22
+ const includeClassified = args.include_classified === true || graph.graphBuildMode === 'tests-only'
23
+ const classifier = createPathClassifier(ctx.repoRoot)
24
+ const byFile = new Map()
25
+ const classesOf = (file) => {
26
+ const key = String(file || '')
27
+ if (!byFile.has(key)) {
28
+ const info = classifier.explain(key, {content: ''})
29
+ byFile.set(key, {classified: info.excluded || hasPathClass(info, ...PATH_CLASS_NAMES), pathClasses: info.classes})
30
+ }
31
+ return byFile.get(key)
32
+ }
33
+ const production = eps.filter((endpoint) => !classesOf(endpoint.file).classified)
34
+ const classified = eps.filter((endpoint) => classesOf(endpoint.file).classified)
35
+ const suppressed = includeClassified ? 0 : classified.length
36
+ eps = includeClassified ? [...production, ...classified] : production
37
+ const suppressionNote = suppressed
38
+ ? `${suppressed} endpoint(s) in classified test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded paths were suppressed; pass include_classified:true to inspect them.`
39
+ : ''
40
+ if (!eps.length) return suppressed
41
+ ? `No production HTTP endpoints detected; ${suppressionNote}`
42
+ : 'No HTTP endpoints detected in the indexed code files.'
20
43
  const max = Math.max(1, Math.min(300, Number(args.max_results) || 100))
21
44
  const shown = eps.slice(0, max)
22
45
  const stats = inventory.stats
23
46
  const text = [
24
- `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.`,
47
+ `${eps.length} endpoint(s) matched${eps.length > shown.length ? `, showing ${shown.length}` : ''}. Inventory: ${stats.declaredRoutes} declaration(s); ${stats.reachableStaticRoutes} statically reachable composed route(s); ${stats.localDeclarations} local/root declaration candidate(s); ${stats.staticMounts} static router mount(s)${stats.truncated ? `; TRUNCATED at ${stats.maxEndpoints}` : ''}.${suppressed ? ` ${suppressionNote}` : ''}`,
25
48
  ...shown.map((e) => {
26
49
  const via = e.mountChain?.length
27
50
  ? `\n declared ${e.declaredPath} in ${e.file}${e.line ? `:${e.line}` : ''}; mount chain ${e.mountChain.map((mount) => `${mount.file}:${mount.line} ${mount.path}`).join(' → ')}`
@@ -29,14 +52,20 @@ export function tListEndpoints(g, args, ctx) {
29
52
  const activation = e.conditional
30
53
  ? `; conditional default ${e.defaultActive === false ? 'inactive' : e.defaultActive === true ? 'active' : 'unknown'}`
31
54
  : ''
32
- return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence}${activation})${via}`
55
+ const info = classesOf(e.file)
56
+ const tag = info.classified ? ` [classified${info.pathClasses.length ? `:${info.pathClasses.join('+')}` : ''}]` : ''
57
+ return ` ${e.method.toUpperCase().padEnd(6)} ${e.path}${e.handler ? ` → ${e.handler}` : ''} (${e.file}${e.line ? `:${e.line}` : ''}; ${e.mountState}/${e.confidence}${activation})${tag}${via}`
33
58
  }),
34
59
  ].join('\n')
35
60
  return toolResult(text, {
36
61
  filters: {method, path},
37
62
  stats,
38
- endpoints: shown,
63
+ pathPolicy: includeClassified ? 'all' : 'production-first',
64
+ suppressed,
65
+ endpoints: shown.map((e) => {
66
+ const info = classesOf(e.file)
67
+ return info.classified ? {...e, classified: true, pathClasses: info.pathClasses} : e
68
+ }),
39
69
  page: {shown: shown.length, total: eps.length, truncated: eps.length > shown.length || stats.truncated},
40
70
  }, {completeness: {status: stats.truncated ? 'PARTIAL' : 'COMPLETE', reason: stats.truncated ? `endpoint cap ${stats.maxEndpoints} reached` : 'all indexed code files scanned'}})
41
71
  }
42
-
@@ -19,6 +19,7 @@ export function sanitizeNode(value) {
19
19
  if (community !== undefined && Number.isInteger(community) && community >= 0) out.community = community;
20
20
  if (typeof value.exported === 'boolean') out.exported = value.exported;
21
21
  if (typeof value.decorated === 'boolean') out.decorated = value.decorated;
22
+ if (value.test_surface === true) out.test_surface = true;
22
23
  setIf(out, 'complexity', sanitizeComplexity(value.complexity));
23
24
  return out;
24
25
  }
@@ -231,6 +231,15 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
231
231
  ...screens,
232
232
  ]
233
233
  }),
234
+ // Mismatch-only endpoints rarely rank into top_n (zero callsites), yet they drive the
235
+ // *_METHOD_MISMATCH verdicts — always surface them, citing the verdict's own total.
236
+ ...(analysis.totals.methodMismatches > 0 ? [
237
+ ` Method mismatches (${verdict.methodMismatches} call(s)):`,
238
+ ...analysis.endpoints.filter((endpoint) => endpoint.methodMismatches > 0).slice(0, 5).flatMap((endpoint) => [
239
+ ` ${endpoint.method} ${endpoint.path} — ${endpoint.methodMismatches} call(s) use a different method`,
240
+ ...(endpoint.methodMismatchSites || []).slice(0, 2).map((site) => ` caller ${site.clientRepo}:${site.file}:${site.line} uses ${site.method}`),
241
+ ]),
242
+ ] : []),
234
243
  ...transportAnalysis.contracts.filter((contract) => contract.callsites.length).slice(0, topN).map((contract) =>
235
244
  ` ${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)`),
236
245
  completeness.complete
@@ -29,17 +29,47 @@ function endpointCandidates(inventory, args) {
29
29
  return exact.length ? exact : eligible.filter((endpoint) => endpoint.path.endsWith(path))
30
30
  }
31
31
 
32
- function handlerCandidates(graph, endpoint) {
32
+ // A handler_file hint narrows the FULL same-named pool before any scoring, so it can rescue a
33
+ // candidate the ranking would otherwise collapse away. An exact repo-relative match beats suffix
34
+ // matches; comparison is case-insensitive (Windows paths and human-typed hints disagree on casing).
35
+ function matchesHandlerHint(file, hint) {
36
+ if (!hint) return true
37
+ const lower = file.toLowerCase()
38
+ return lower === hint || lower.endsWith(`/${hint}`)
39
+ }
40
+
41
+ function handlerCandidates(graph, endpoint, hint = '') {
33
42
  if (!endpoint.handler) return []
34
43
  const wanted = endpoint.handler.toLowerCase()
35
44
  const qualifier = String(endpoint.handlerRef || '').split('.').slice(-2, -1)[0]?.toLowerCase() || ''
36
45
  const routeDir = posix.dirname(endpoint.file)
37
- const scored = (graph.nodes || [])
46
+ // File-level import/re-export bindings at the route file break same-name ties between otherwise
47
+ // unranked candidates. The bonus deliberately sits BELOW same-directory proximity (+40): a
48
+ // file-level edge proves coupling, not symbol identity, and must never outrank the conventional
49
+ // same-package/same-dir handler (Go same-package files have no import edge at all).
50
+ const importBound = new Set()
51
+ for (const edge of graph.out.get(endpoint.file) || []) {
52
+ if (edge.relation !== 'imports' && edge.relation !== 're_exports') continue
53
+ if (edge.typeOnly === true || edge.barrelProxy === true) continue
54
+ const targetId = String(edge.id)
55
+ if (targetId.includes('#')) continue
56
+ const target = graph.byId.get(targetId)
57
+ importBound.add(String(target?.source_file || targetId).replace(/\\/g, '/'))
58
+ }
59
+ let pool = (graph.nodes || [])
38
60
  .filter((node) => String(node.id).includes('#') && node.source_file && symbolName(node).toLowerCase() === wanted)
61
+ if (hint) {
62
+ pool = pool.filter((node) => matchesHandlerHint(String(node.source_file).replace(/\\/g, '/').toLowerCase(), hint))
63
+ const exact = pool.filter((node) => String(node.source_file).replace(/\\/g, '/').toLowerCase() === hint)
64
+ if (exact.length) pool = exact
65
+ }
66
+ const scored = pool
39
67
  .map((node) => {
40
68
  const file = String(node.source_file).replace(/\\/g, '/')
41
69
  let score = 0
70
+ const importBoundAtRoute = importBound.has(file)
42
71
  if (file === endpoint.file) score += 100
72
+ if (importBoundAtRoute) score += 35
43
73
  if (posix.dirname(file) === routeDir) score += 40
44
74
  if (file.startsWith(`${routeDir}/`)) score += 15
45
75
  if (qualifier) {
@@ -48,16 +78,15 @@ function handlerCandidates(graph, endpoint) {
48
78
  }
49
79
  const line = symbolLine(node)
50
80
  if (file === endpoint.file && line >= endpoint.line && line - endpoint.line <= 250) score += 20
51
- return {node, score}
81
+ return {node, score, importBoundAtRoute}
52
82
  })
53
83
  .sort((a, b) => b.score - a.score || String(a.node.id).localeCompare(String(b.node.id)))
54
84
  if (!scored.length) return []
55
85
  const best = scored[0].score
56
- return scored.filter((item) => item.score === best).map((item) => item.node)
86
+ return scored.filter((item) => item.score === best).map(({node, importBoundAtRoute}) => ({node, importBoundAtRoute}))
57
87
  }
58
88
 
59
- function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, repoRoot}) {
60
- const classifier = createPathClassifier(repoRoot)
89
+ function traceCalls(graph, start, {maxDepth, maxNodes, includeClassified, classifier}) {
61
90
  const allowed = (node) => {
62
91
  if (!node?.source_file) return false
63
92
  if (includeClassified) return true
@@ -125,12 +154,25 @@ export function tTraceEndpoint(graph, args, ctx) {
125
154
  completeness: {status: 'PARTIAL', reason: 'ambiguous endpoint'},
126
155
  })
127
156
  const endpoint = candidates[0]
128
- const handlers = handlerCandidates(graph, endpoint)
157
+ const classifier = createPathClassifier(ctx.repoRoot)
158
+ const classify = (file) => {
159
+ const info = classifier.explain(file, {content: ''})
160
+ return {classified: info.excluded || NON_PRODUCT.some((name) => hasPathClass(info, name)), pathClasses: info.classes}
161
+ }
162
+ const hint = String(args.handler_file || '').trim().replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
163
+ let handlers = handlerCandidates(graph, endpoint, hint)
164
+ .map((item) => ({...item, ...classify(String(item.node.source_file).replace(/\\/g, '/'))}))
165
+ // Production-first: a classified twin (tests/mocks/…) never ties with a production candidate,
166
+ // but an all-classified candidate set is kept rather than emptied.
167
+ if (args.include_classified !== true && handlers.some((item) => !item.classified)) {
168
+ handlers = handlers.filter((item) => !item.classified)
169
+ }
129
170
  if (handlers.length !== 1) return toolResult([
130
- `Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : 'not present in the graph'}:`,
171
+ `Endpoint resolved, but its handler symbol is ${handlers.length ? 'ambiguous' : `not present in the graph${hint ? ` (no candidate matched handler_file "${hint}")` : ''}`}:`,
131
172
  ` ${endpointLine(endpoint)}`,
132
- ...handlers.slice(0, 12).map((node) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}) [${node.id}]`),
133
- ].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers}, {
173
+ ...handlers.slice(0, 12).map(({node, importBoundAtRoute, pathClasses}) => ` candidate ${node.label || node.id} (${node.source_file}:${symbolLine(node) || '?'}; import-bound at route: ${importBoundAtRoute ? 'yes' : 'no'}${pathClasses.length ? `; classified:${pathClasses.join('+')}` : ''}) [${node.id}]`),
174
+ ...(handlers.length > 1 ? [' Next: pass handler_file with the repo-relative path (or an unambiguous path suffix) of the file that declares the intended handler.'] : []),
175
+ ].join('\n'), {status: handlers.length ? 'AMBIGUOUS_HANDLER' : 'HANDLER_NOT_FOUND', endpoint, handlers: handlers.map(({node, importBoundAtRoute, pathClasses}) => ({...node, importBoundAtRoute, pathClasses}))}, {
134
176
  completeness: {status: 'PARTIAL', reason: handlers.length ? 'ambiguous handler symbol' : 'handler symbol not found'},
135
177
  })
136
178
 
@@ -138,9 +180,9 @@ export function tTraceEndpoint(graph, args, ctx) {
138
180
  const maxNodes = Math.max(2, Math.min(40, Number(args.max_nodes) || 20))
139
181
  const contextLines = Math.max(0, Math.min(6, Number(args.context_lines) || 2))
140
182
  const maxExcerpts = Math.max(0, Math.min(12, Number(args.max_excerpts) || 6))
141
- const handler = handlers[0]
183
+ const handler = handlers[0].node
142
184
  const traced = traceCalls(graph, handler, {
143
- maxDepth, maxNodes, includeClassified: args.include_classified === true, repoRoot: ctx.repoRoot,
185
+ maxDepth, maxNodes, includeClassified: args.include_classified === true, classifier,
144
186
  })
145
187
  const excerpts = traced.edges.slice(0, maxExcerpts).map((edge) => {
146
188
  const source = graph.byId.get(edge.from)
@@ -12,6 +12,7 @@ export function tGodNodes(g, {top_n = 10, include_classified = false} = {}, ctx
12
12
  const classificationByFile = new Map()
13
13
  const isNonProduct = (node) => {
14
14
  if (include_classified === true) return false
15
+ if (node?.test_surface === true) return true
15
16
  const file = sourceFileOf(node)
16
17
  if (!file) return false
17
18
  if (!classificationByFile.has(file)) classificationByFile.set(file, classifier.explain(file))
@@ -87,10 +87,18 @@ export function tGetDependents(g, args = {}, ctx = {}) {
87
87
  `Semantic precision: EXACT_LSP point query${result.cached ? ' (cache hit)' : ''}; ${count} classified direct reference edge(s).`,
88
88
  )
89
89
  }
90
+ // Never echo COMPLETE here ('COMPLETE; exact absence was not proven' is a contradiction),
91
+ // but keep NONE/UNAVAILABLE truthful — PARTIAL implies probing actually began.
92
+ const fallbackState = result.overlay?.state === 'NONE' ? 'NONE'
93
+ : result.overlay?.state === 'UNAVAILABLE' ? 'UNAVAILABLE' : 'PARTIAL'
90
94
  return getDependentsFromGraph(
91
95
  g,
92
96
  args,
93
- `Semantic precision: ${result.overlay?.state || 'UNAVAILABLE'}; exact absence was not proven, so graph edges are retained (${result.overlay?.reason || 'incomplete project coverage'}).`,
97
+ fallbackState === 'NONE'
98
+ ? `Semantic precision: NONE; exact references are not available for this language/target, so graph edges are retained (${result.overlay?.reason || 'no eligible JavaScript/TypeScript semantic targets'}).`
99
+ : fallbackState === 'UNAVAILABLE'
100
+ ? `Semantic precision: UNAVAILABLE; graph evidence retained (${result.overlay?.reason || 'semantic point query unavailable'}).`
101
+ : `Semantic precision: PARTIAL; exact absence was not proven, so graph edges are retained (${result.overlay?.reason || 'incomplete project coverage'}).`,
94
102
  )
95
103
  } catch (error) {
96
104
  return getDependentsFromGraph(
@@ -39,7 +39,9 @@ function coverage(session, verifiedEdges = session.links.length) {
39
39
  }
40
40
 
41
41
  function completedOverlay(session) {
42
- const state = session.errors || session.truncated || session.unclassifiedReferences
42
+ // Coherence guard: COMPLETE must mean every selected target was actually probed.
43
+ const underQueried = session.queried < session.targets.length
44
+ const state = session.errors || session.truncated || session.unclassifiedReferences || underQueried
43
45
  ? 'PARTIAL' : 'COMPLETE'
44
46
  return baseOverlay(session.graph, state, {
45
47
  request: session.request,
@@ -67,7 +69,9 @@ function completedOverlay(session) {
67
69
  ...(session.errors ? {reason: `${session.errors} semantic request(s) failed or were refused`}
68
70
  : session.truncated ? {reason: 'semantic precision stopped at a configured safety limit'}
69
71
  : session.unclassifiedReferences
70
- ? {reason: 'some exact references could not be classified as runtime or type-only'} : {}),
72
+ ? {reason: 'some exact references could not be classified as runtime or type-only'}
73
+ : underQueried
74
+ ? {reason: 'semantic precision stopped before probing every selected target'} : {}),
71
75
  })
72
76
  }
73
77
 
@@ -185,14 +189,15 @@ export async function buildLspPrecisionOverlay({
185
189
  }
186
190
  if (graphPath) {
187
191
  const cached = readPrecisionOverlay(graphPath, graph)
188
- if (cached?.state === 'COMPLETE'
192
+ // NONE (no eligible targets) is as stable as COMPLETE for an unchanged fingerprint.
193
+ if ((cached?.state === 'COMPLETE' || cached?.state === 'NONE')
189
194
  && precisionOverlayMatches(cached, graph, {request: session.request})
190
195
  && cached.semanticInputFingerprint === session.semanticInputs.fingerprint) return cached
191
196
  }
192
197
  session.eligible = eligibleTargets(graph, session.boundedMax, targetIds)
193
198
  session.targets = session.eligible.targets
194
199
  if (!session.targets.length) {
195
- return persist(session, baseOverlay(graph, 'COMPLETE', {
200
+ return persist(session, baseOverlay(graph, 'NONE', {
196
201
  request: session.request,
197
202
  semanticInputFingerprint: session.semanticInputs.fingerprint,
198
203
  pluginPolicy: {
@@ -1,6 +1,6 @@
1
1
  import {typeScriptLspContract} from '../typescript-lsp-provider.js'
2
2
 
3
- export const PRECISION_OVERLAY_V = 4
3
+ export const PRECISION_OVERLAY_V = 5
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)
@@ -99,10 +99,15 @@ export function eligibleTargets(graph, limit, requestedIds = null) {
99
99
  const byId = new Map((graph.nodes || []).map((node) => [String(node.id), node]))
100
100
  if (Array.isArray(requestedIds) && requestedIds.length) {
101
101
  const ids = [...new Set(requestedIds.map(String))]
102
- const targets = ids.map((id) => byId.get(id))
102
+ const eligible = ids.map((id) => byId.get(id))
103
103
  .filter((node) => node?.selection_start && JS_TS_FILE.test(String(node.source_file || '')))
104
- .slice(0, limit)
105
- return {targets, total: ids.length, orphanIds: new Set()}
104
+ // total counts only eligible targets so coverage fractions stay honest when ids were dropped.
105
+ return {
106
+ targets: eligible.slice(0, limit),
107
+ total: eligible.length,
108
+ droppedRequested: ids.length - eligible.length,
109
+ orphanIds: new Set(),
110
+ }
106
111
  }
107
112
  const ranked = new Map()
108
113
  const inbound = new Set()
@@ -75,7 +75,8 @@ async function storeEntry(path, entry) {
75
75
  }
76
76
 
77
77
  function cacheable(overlay) {
78
- if (overlay?.state === 'COMPLETE') return true
78
+ // NONE is stable for the fingerprint, but only COMPLETE ever proves absence (see reader below).
79
+ if (overlay?.state === 'COMPLETE' || overlay?.state === 'NONE') return true
79
80
  return overlay?.state === 'PARTIAL'
80
81
  && overlay?.reason === 'semantic precision stopped at a configured safety limit'
81
82
  }