weavatrix 0.2.13 → 0.2.15

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 (145) hide show
  1. package/README.md +99 -39
  2. package/SECURITY.md +2 -2
  3. package/docs/releases/v0.2.15.md +37 -0
  4. package/package.json +2 -2
  5. package/skill/SKILL.md +57 -38
  6. package/src/analysis/architecture/contract-graph.js +119 -0
  7. package/src/analysis/architecture/contract-schema.js +110 -0
  8. package/src/analysis/architecture/contract-storage.js +35 -0
  9. package/src/analysis/architecture/contract-verification.js +168 -0
  10. package/src/analysis/architecture-contract.js +16 -343
  11. package/src/analysis/change-classification/diff-parser.js +103 -0
  12. package/src/analysis/change-classification/options.js +40 -0
  13. package/src/analysis/change-classification/symbol-classifier.js +177 -0
  14. package/src/analysis/change-classification.js +120 -519
  15. package/src/analysis/dead-code-review/policy.js +23 -0
  16. package/src/analysis/dead-code-review.js +9 -19
  17. package/src/analysis/dep-check.js +14 -71
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/conventions.js +16 -0
  20. package/src/analysis/dependency/scoped-dependencies.js +45 -0
  21. package/src/analysis/dependency/source-references.js +21 -0
  22. package/src/analysis/duplicates.tokenize.js +12 -2
  23. package/src/analysis/endpoints/common.js +62 -0
  24. package/src/analysis/endpoints/extract.js +107 -0
  25. package/src/analysis/endpoints/inventory.js +84 -0
  26. package/src/analysis/endpoints/mounts.js +129 -0
  27. package/src/analysis/endpoints-java.js +80 -3
  28. package/src/analysis/endpoints.js +3 -448
  29. package/src/analysis/git-history/analytics.js +177 -0
  30. package/src/analysis/git-history/collector.js +109 -0
  31. package/src/analysis/git-history/options.js +68 -0
  32. package/src/analysis/git-history.js +128 -577
  33. package/src/analysis/health-capabilities.js +82 -0
  34. package/src/analysis/http-contracts/analysis.js +169 -0
  35. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  36. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  37. package/src/analysis/http-contracts/graph-context.js +143 -0
  38. package/src/analysis/http-contracts/matching.js +61 -0
  39. package/src/analysis/http-contracts/shared.js +86 -0
  40. package/src/analysis/http-contracts.js +6 -822
  41. package/src/analysis/internal-audit/dependency-health.js +111 -0
  42. package/src/analysis/internal-audit/python-manifests.js +65 -0
  43. package/src/analysis/internal-audit/repo-files.js +55 -0
  44. package/src/analysis/internal-audit/supply-chain.js +132 -0
  45. package/src/analysis/internal-audit.collect.js +5 -106
  46. package/src/analysis/internal-audit.reach.js +20 -0
  47. package/src/analysis/internal-audit.run.js +114 -200
  48. package/src/analysis/jvm-dependency-evidence.js +69 -0
  49. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  50. package/src/analysis/source-correctness.js +225 -0
  51. package/src/analysis/structure/dependency-graph.js +156 -0
  52. package/src/analysis/structure/findings.js +142 -0
  53. package/src/analysis/task-retrieval.js +3 -14
  54. package/src/graph/builder/go-receiver-resolution.js +112 -0
  55. package/src/graph/builder/js/queries.js +48 -0
  56. package/src/graph/builder/lang-go.js +89 -4
  57. package/src/graph/builder/lang-js.js +4 -44
  58. package/src/graph/builder/pass2-resolution.js +68 -0
  59. package/src/graph/freshness-probe.js +2 -1
  60. package/src/graph/incremental-refresh.js +3 -2
  61. package/src/graph/internal-builder.build.js +22 -183
  62. package/src/graph/internal-builder.pass2.js +161 -0
  63. package/src/graph/internal-builder.resolvers.js +2 -105
  64. package/src/graph/resolvers/rust.js +117 -0
  65. package/src/mcp/actions/advisories.mjs +18 -0
  66. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  67. package/src/mcp/actions/graph-sync.mjs +195 -0
  68. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  69. package/src/mcp/architecture-bootstrap.mjs +168 -0
  70. package/src/mcp/architecture-starter.mjs +234 -0
  71. package/src/mcp/catalog.mjs +20 -6
  72. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  73. package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
  74. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  75. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  76. package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
  77. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  78. package/src/mcp/git-output.mjs +10 -0
  79. package/src/mcp/graph/context-core.mjs +111 -0
  80. package/src/mcp/graph/context-seeds.mjs +197 -0
  81. package/src/mcp/graph/context-state.mjs +86 -0
  82. package/src/mcp/graph/reverse-reach.mjs +42 -0
  83. package/src/mcp/graph/tools-core.mjs +143 -0
  84. package/src/mcp/graph/tools-query.mjs +223 -0
  85. package/src/mcp/graph-context.mjs +4 -496
  86. package/src/mcp/health/audit-format.mjs +172 -0
  87. package/src/mcp/health/audit.mjs +171 -0
  88. package/src/mcp/health/dead-code.mjs +110 -0
  89. package/src/mcp/health/duplicates.mjs +83 -0
  90. package/src/mcp/health/endpoints.mjs +42 -0
  91. package/src/mcp/health/structure.mjs +219 -0
  92. package/src/mcp/runtime-version.mjs +32 -0
  93. package/src/mcp/server/auto-refresh.mjs +66 -0
  94. package/src/mcp/server/runtime-config.mjs +43 -0
  95. package/src/mcp/server/shutdown.mjs +40 -0
  96. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  97. package/src/mcp/sync/evidence-common.mjs +88 -0
  98. package/src/mcp/sync/evidence-duplicates.mjs +97 -0
  99. package/src/mcp/sync/evidence-health.mjs +56 -0
  100. package/src/mcp/sync/evidence-packages.mjs +95 -0
  101. package/src/mcp/sync/payload-common.mjs +97 -0
  102. package/src/mcp/sync/payload-v2.mjs +53 -0
  103. package/src/mcp/sync/payload-v3.mjs +79 -0
  104. package/src/mcp/sync-evidence.mjs +7 -402
  105. package/src/mcp/sync-payload.mjs +10 -244
  106. package/src/mcp/tools-actions.mjs +18 -414
  107. package/src/mcp/tools-architecture.mjs +18 -70
  108. package/src/mcp/tools-context.mjs +56 -15
  109. package/src/mcp/tools-endpoints.mjs +4 -1
  110. package/src/mcp/tools-graph.mjs +17 -331
  111. package/src/mcp/tools-health.mjs +24 -705
  112. package/src/mcp/tools-impact-change.mjs +2 -38
  113. package/src/mcp/tools-impact.mjs +2 -43
  114. package/src/mcp-server.mjs +35 -146
  115. package/src/path-classification.js +14 -0
  116. package/src/precision/lsp-client/constants.js +12 -0
  117. package/src/precision/lsp-client/environment.js +20 -0
  118. package/src/precision/lsp-client/errors.js +15 -0
  119. package/src/precision/lsp-client/lifecycle.js +127 -0
  120. package/src/precision/lsp-client/message-parser.js +81 -0
  121. package/src/precision/lsp-client/protocol.js +212 -0
  122. package/src/precision/lsp-client/registry.js +58 -0
  123. package/src/precision/lsp-client/repo-uri.js +60 -0
  124. package/src/precision/lsp-client/stdio-client.js +151 -0
  125. package/src/precision/lsp-client.js +10 -682
  126. package/src/precision/lsp-overlay/build.js +238 -0
  127. package/src/precision/lsp-overlay/contract.js +61 -0
  128. package/src/precision/lsp-overlay/reference-results.js +108 -0
  129. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  130. package/src/precision/lsp-overlay/source-session.js +134 -0
  131. package/src/precision/lsp-overlay/store.js +150 -0
  132. package/src/precision/lsp-overlay/target-index.js +147 -0
  133. package/src/precision/lsp-overlay/target-query.js +55 -0
  134. package/src/precision/lsp-overlay.js +11 -868
  135. package/src/precision/typescript-lsp-provider.js +12 -682
  136. package/src/precision/typescript-provider/client.js +69 -0
  137. package/src/precision/typescript-provider/discovery.js +124 -0
  138. package/src/precision/typescript-provider/project-host.js +249 -0
  139. package/src/precision/typescript-provider/project-safety.js +161 -0
  140. package/src/precision/typescript-provider/reference-usage.js +53 -0
  141. package/src/security/malware-heuristics.sweep.js +1 -1
  142. package/src/security/registry-sig.classify.js +2 -1
  143. package/src/security/registry-sig.rules.js +3 -3
  144. package/src/version.js +7 -0
  145. package/docs/releases/v0.2.13.md +0 -48
@@ -3,15 +3,19 @@ import {boundedInteger} from '../bounds.js'
3
3
  import {isSymbol, labelOf} from './graph-context.mjs'
4
4
  import {toolResult} from './tool-result.mjs'
5
5
  import {sourceExcerpt} from './tools-source.mjs'
6
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
6
7
 
7
8
  const MAX_LINE_SAMPLES = 5
9
+ const CONTEXT_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
8
10
 
9
11
  const fileOf = (g, id) => {
10
12
  const node = g.byId.get(String(id))
11
13
  return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
12
14
  }
13
15
 
14
- function aggregateEdges(g, edges, cap, {callsiteFile = null} = {}) {
16
+ function aggregateEdges(g, edges, cap, {
17
+ callsiteFile = null, classifier = null, includeClassified = true, productionFirst = false,
18
+ } = {}) {
15
19
  const groups = new Map()
16
20
  for (const edge of edges || []) {
17
21
  if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
@@ -33,9 +37,23 @@ function aggregateEdges(g, edges, cap, {callsiteFile = null} = {}) {
33
37
  if (edge.typeOnly === true) group.typeOnly = true
34
38
  if (edge.compileOnly === true) group.compileOnly = true
35
39
  }
36
- const all = [...groups.values()].sort((left, right) => right.count - left.count
37
- || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
38
- return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
40
+ const all = [...groups.values()]
41
+ if (classifier) for (const group of all) {
42
+ const info = classifier.explain(group.file, {content: ''})
43
+ const classes = CONTEXT_NON_PRODUCT.filter((name) => hasPathClass(info, name))
44
+ group.classified = classes.length > 0 || info?.excluded === true
45
+ if (classes.length) group.pathClasses = classes
46
+ }
47
+ const eligible = includeClassified ? all : all.filter((group) => !group.classified)
48
+ eligible.sort((left, right) => (productionFirst ? Number(left.classified) - Number(right.classified) : 0)
49
+ || right.count - left.count || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
50
+ return {
51
+ total: eligible.length,
52
+ available: all.length,
53
+ suppressed: all.length - eligible.length,
54
+ shown: eligible.slice(0, cap),
55
+ capped: eligible.length > cap,
56
+ }
39
57
  }
40
58
 
41
59
  const sameOrigin = (occurrence, definition) => occurrence.originId === definition.id
@@ -97,12 +115,14 @@ function exactReExportSites(g, definition, cap) {
97
115
  }
98
116
 
99
117
  function linesForGroups(title, groups) {
100
- if (!groups.shown.length) return [`${title}: none`]
101
- const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
118
+ const suppression = groups.suppressed ? `; ${groups.suppressed} classified container(s) suppressed` : ''
119
+ if (!groups.shown.length) return [`${title}: none${suppression}`]
120
+ const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}${suppression}`]
102
121
  for (const group of groups.shown) {
103
122
  const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
104
123
  const destination = group.targetFile ? ` → ${group.targetFile}` : ''
105
- lines.push(` ${group.count}× ${group.relation} ${group.label} [call site ${group.file}${sites}${destination}]`)
124
+ const classified = group.classified ? ` [classified${group.pathClasses?.length ? `:${group.pathClasses.join('+')}` : ''}]` : ''
125
+ lines.push(` ${group.count}× ${group.relation} ${group.label} [call site ${group.file}${sites}${destination}]${classified}`)
106
126
  }
107
127
  return lines
108
128
  }
@@ -139,26 +159,47 @@ export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
139
159
  ...inspection.definition,
140
160
  name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
141
161
  }
142
- const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
162
+ const classifier = createPathClassifier(ctx.repoRoot || null)
163
+ const includeClassified = args.include_classified === true
164
+ const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated, {
165
+ classifier, includeClassified, productionFirst: true,
166
+ })
143
167
  const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated, {callsiteFile: definition.file})
144
168
  const reExports = exactReExportSites(g, definition, maxReExports)
145
169
  const source = []
170
+ const overlaps = (left, right) => left.file === right.file
171
+ && left.startLine <= right.endLine && right.startLine <= left.endLine
146
172
  const append = (role, excerpt) => {
147
173
  if (!excerpt || source.length >= maxSourceFiles) return
148
- if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) return
174
+ if (source.some((item) => overlaps(item, excerpt))) return
149
175
  source.push({role, ...excerpt})
150
176
  }
151
177
  append('Definition', inspection.source.definition)
152
178
  const contextLines = boundedInteger(args.context_lines, 4, 0, 12)
153
- for (const group of outbound.shown) {
154
- for (const line of group.lines) append('Outbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
179
+ const excerptCandidates = []
180
+ const groupExcerpts = (groups, role) => {
181
+ const primary = []
182
+ const secondary = []
183
+ for (const group of groups.shown) for (let index = 0; index < group.lines.length; index++) {
184
+ const candidate = {role, excerpt: sourceExcerpt(ctx.repoRoot, group.file, group.lines[index], contextLines)}
185
+ ;(index === 0 ? primary : secondary).push(candidate)
186
+ }
187
+ return [...primary, ...secondary]
155
188
  }
156
- for (const group of inbound.shown) {
157
- for (const line of group.lines) append('Inbound call site', sourceExcerpt(ctx.repoRoot, group.file, line, contextLines))
189
+ const outboundExcerpts = groupExcerpts(outbound, 'Outbound call site')
190
+ const inboundExcerpts = groupExcerpts(inbound, 'Inbound call site')
191
+ for (let index = 0; index < Math.max(outboundExcerpts.length, inboundExcerpts.length); index++) {
192
+ if (outboundExcerpts[index]) excerptCandidates.push(outboundExcerpts[index])
193
+ if (inboundExcerpts[index]) excerptCandidates.push(inboundExcerpts[index])
158
194
  }
159
- for (const excerpt of inspection.source.callers || []) {
160
- append('Reference', excerpt)
195
+ for (const excerpt of inspection.source.callers || []) excerptCandidates.push({role: 'Reference', excerpt})
196
+ const deferred = []
197
+ for (const candidate of excerptCandidates) {
198
+ const knownFile = source.some((item) => item.file === candidate.excerpt?.file)
199
+ if (knownFile && candidate.role !== 'Outbound call site') deferred.push(candidate)
200
+ else append(candidate.role, candidate.excerpt)
161
201
  }
202
+ for (const candidate of deferred) append(candidate.role, candidate.excerpt)
162
203
  const result = {
163
204
  status: 'OK', definition, evidence: inspection.evidence,
164
205
  references: {
@@ -105,7 +105,10 @@ function endpointLine(endpoint) {
105
105
  const mount = endpoint.mountChain?.length
106
106
  ? endpoint.mountChain.map((item) => `${item.file}:${item.line} ${item.path}`).join(' → ')
107
107
  : 'no static router mount chain'
108
- return `${endpoint.method} ${endpoint.path} → ${endpoint.handler || 'inline/unknown'} (${endpoint.file}:${endpoint.line}; ${endpoint.mountState}/${endpoint.confidence}; declared ${endpoint.declaredPath}; ${mount})`
108
+ const activation = endpoint.conditional
109
+ ? `; conditional default ${endpoint.defaultActive === false ? 'inactive' : endpoint.defaultActive === true ? 'active' : 'unknown'}`
110
+ : ''
111
+ return `${endpoint.method} ${endpoint.path} → ${endpoint.handler || 'inline/unknown'} (${endpoint.file}:${endpoint.line}; ${endpoint.mountState}/${endpoint.confidence}${activation}; declared ${endpoint.declaredPath}; ${mount})`
109
112
  }
110
113
 
111
114
  export function tTraceEndpoint(graph, args, ctx) {
@@ -1,331 +1,17 @@
1
- // Graph query tools: stats, node lookup, neighbors, hubs, communities, exploratory traversal and
2
- // shortest path. Pure reads over the loaded graph no filesystem or process access beyond the
3
- // staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
4
- import {
5
- isSymbol, degreeOf, labelOf, connList,
6
- resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors, requestedPathClasses,
7
- graphStaleness, fileStalenessNote,
8
- } from './graph-context.mjs'
9
- import {summarizeEdgeProvenance} from '../graph/edge-provenance.js'
10
- import {createPathClassifier, hasPathClass} from '../path-classification.js'
11
-
12
- const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
13
-
14
- export function tGraphStats(g, ctx) {
15
- const files = g.nodes.filter((n) => !isSymbol(n.id)).length
16
- const symbols = g.nodes.length - files
17
- const relCount = {}
18
- const confCount = {}
19
- let typeOnlyEdges = 0
20
- let compileOnlyEdges = 0
21
- for (const e of g.links) {
22
- relCount[e.relation ?? '?'] = (relCount[e.relation ?? '?'] || 0) + 1
23
- if (e.confidence != null) confCount[e.confidence] = (confCount[e.confidence] || 0) + 1
24
- if (e.typeOnly === true) typeOnlyEdges++
25
- if (e.compileOnly === true) compileOnlyEdges++
26
- }
27
- const comm = new Map()
28
- for (const n of g.nodes) {
29
- const c = n.community ?? 'none'
30
- comm.set(c, (comm.get(c) || 0) + 1)
31
- }
32
- const topComm = [...comm.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
33
- const fmt = (o) =>
34
- Object.entries(o)
35
- .sort((a, b) => b[1] - a[1])
36
- .map(([k, v]) => `${k}: ${v}`)
37
- .join(', ')
38
- const freshness = ctx ? graphStaleness(ctx) : null
39
- const provenance = summarizeEdgeProvenance(g.links)
40
- const precision = g.precision || {state: 'UNAVAILABLE', verifiedEdges: 0, candidates: 0, queried: 0, reason: 'no revision-matched precision overlay'}
41
- return [
42
- `Graph summary`,
43
- ctx?.runtime ? `- Weavatrix runtime: v${ctx.runtime.version}; profile ${ctx.runtime.profile}; ${ctx.runtime.toolCount} registered tools; capabilities: ${ctx.runtime.capabilities.join(',') || '(none)'}` : null,
44
- ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
45
- ctx?.graphPath ? `- Graph: ${ctx.graphPath}` : null,
46
- `- Build mode: ${g.graphBuildMode || 'full'}`,
47
- `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
48
- `- Edges: ${g.links.length}`,
49
- g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : `- Typed-edge metadata: unavailable (rebuild_graph required)`,
50
- g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
51
- `- Semantic precision: ${precision.state}${precision.provider ? ` via ${precision.provider}${precision.providerVersion ? ` ${precision.providerVersion}` : ''}${precision.typescriptVersion ? ` (TypeScript ${precision.typescriptVersion})` : ''}` : ''}; ${precision.verifiedEdges || 0} EXACT_LSP edge(s), ${precision.queried || 0}/${precision.candidates || 0} bounded target(s) queried${precision.truncated ? ' (partial/truncated)' : ''}${precision.reason ? `; ${precision.reason}` : ''}`,
52
- g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
53
- g.reExportOccurrencesV ? `- Re-export occurrences: v${g.reExportOccurrencesV} (${g.reExportOccurrences.length} exact site(s))` : `- Re-export occurrences: unavailable (rebuild_graph required)`,
54
- g.symbolSpacesV ? `- TypeScript symbol spaces: v${g.symbolSpacesV} (type/value identities separated)` : `- TypeScript symbol spaces: unavailable (rebuild_graph required)`,
55
- `- Relations: ${fmt(relCount)}`,
56
- Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
57
- `- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
58
- freshness?.builtAt ? `- Built: ${freshness.builtAt.toISOString()}${freshness.headAt ? ` (repo HEAD committed ${freshness.headAt.toISOString()})` : ''}` : null,
59
- ]
60
- .filter(Boolean)
61
- .join('\n')
62
- }
63
-
64
- export function tGetNode(g, {label} = {}, ctx) {
65
- const info = resolveNodeInfo(g, label)
66
- const n = info.node
67
- if (!n) return `No node found matching "${label}".`
68
- const note = ambiguityNote(label, info)
69
- const id = String(n.id)
70
- const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
71
- const outs = g.out.get(id) || []
72
- const ins = g.inn.get(id) || []
73
- const semanticOuts = connList(outs)
74
- const semanticIns = connList(ins)
75
- const sample = (list, dir) =>
76
- list
77
- .slice(0, 12)
78
- .map((e) => ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} [${e.provenance || 'UNKNOWN'}] ${labelOf(g, e.id)} [${e.id}]`)
79
- .join('\n') || ' (none)'
80
- return [
81
- note,
82
- `Node: ${n.label ?? id}`,
83
- `- id: ${id}`,
84
- `- kind: ${isSymbol(id) ? 'symbol' : 'file'}${n.file_type ? ` (${n.file_type})` : ''}`,
85
- n.source_file ? `- source: ${n.source_file}${n.source_location ? ` ${n.source_location}` : ''}` : null,
86
- n.community != null ? `- community: ${n.community}` : null,
87
- `- semantic degree: ${semanticOuts.length + semanticIns.length} (out ${semanticOuts.length}, in ${semanticIns.length})${outs.length + ins.length !== semanticOuts.length + semanticIns.length ? `; ${outs.length + ins.length} physical/structural edges retained` : ''}`,
88
- `Outgoing:\n${sample(outs, 'out')}`,
89
- `Incoming:\n${sample(ins, 'in')}`,
90
- drift,
91
- ]
92
- .filter(Boolean)
93
- .join('\n')
94
- }
95
-
96
- // Collapse repeated edges to the same neighbor (one per call site in the graph) into `(N sites)` —
97
- // a hub function's caller list shrinks ~2-3x with no information loss.
98
- function dedupeEdges(list) {
99
- const grouped = new Map()
100
- for (const e of list) {
101
- const key = `${e.relation || 'rel'}|${compileKind(e) || 'runtime'}|${e.id}`
102
- const cur = grouped.get(key)
103
- if (cur) {
104
- cur.count += 1
105
- cur.provenance.add(e.provenance || 'UNKNOWN')
106
- } else grouped.set(key, {id: e.id, relation: e.relation, typeOnly: e.typeOnly === true, compileOnly: e.compileOnly === true, provenance: new Set([e.provenance || 'UNKNOWN']), count: 1})
107
- }
108
- return [...grouped.values()]
109
- }
110
-
111
- export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
112
- const info = resolveNodeInfo(g, label)
113
- const n = info.node
114
- if (!n) return `No node found matching "${label}".`
115
- const note = ambiguityNote(label, info)
116
- const id = String(n.id)
117
- const drift = ctx ? fileStalenessNote(ctx, n.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
118
- const rf = relation_filter ? String(relation_filter).toLowerCase() : null
119
- const match = (e) => !rf || String(e.relation ?? '').toLowerCase() === rf
120
- const outsRaw = (g.out.get(id) || []).filter(match)
121
- const insRaw = (g.inn.get(id) || []).filter(match)
122
- const outs = dedupeEdges(outsRaw)
123
- const ins = dedupeEdges(insRaw)
124
- const line = (e, dir) =>
125
- ` ${dir === 'out' ? '→' : '←'} ${compileKind(e) ? `${compileKind(e)} ` : ''}${e.relation || 'rel'} [${[...e.provenance].sort().join('+')}] ${labelOf(g, e.id)} [${e.id}]${e.count > 1 ? ` (${e.count} sites)` : ''}`
126
- return [
127
- note,
128
- `Neighbors of ${n.label ?? id}${rf ? ` (relation=${rf})` : ''}: ${outs.length + ins.length} unique (${outsRaw.length + insRaw.length} edges)`,
129
- `Outgoing (${outs.length}):`,
130
- ...outs.slice(0, 60).map((e) => line(e, 'out')),
131
- `Incoming (${ins.length}):`,
132
- ...ins.slice(0, 60).map((e) => line(e, 'in')),
133
- drift,
134
- ].filter(Boolean).join('\n')
135
- }
136
-
137
- export {tGodNodes} from './tools-graph-hubs.mjs'
138
-
139
-
140
- export function tGetCommunity(g, {community_id} = {}) {
141
- const groups = new Map()
142
- for (const node of g.nodes) {
143
- const c = node.community
144
- if (c == null) continue
145
- if (!groups.has(c)) groups.set(c, [])
146
- groups.get(c).push(node)
147
- }
148
- const ranked = [...groups.entries()].sort((a, b) => b[1].length - a[1].length) // 0-indexed by size
149
- const idx = Number(community_id)
150
- if (!Number.isInteger(idx) || idx < 0 || idx >= ranked.length)
151
- return `Invalid community_id ${community_id}. Valid range 0..${ranked.length - 1} (0 = largest).`
152
- const [rawId, members] = ranked[idx]
153
- const files = members.filter((m) => !isSymbol(m.id))
154
- return [
155
- `Community #${idx} (raw id ${rawId}) — ${members.length} nodes, ${files.length} files:`,
156
- ...members
157
- .slice()
158
- .sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id))
159
- .slice(0, 80)
160
- .map((m) => ` ${m.label ?? m.id} [${m.id}]`),
161
- members.length > 80 ? ` … +${members.length - 80} more` : null,
162
- ]
163
- .filter(Boolean)
164
- .join('\n')
165
- }
166
-
167
- // A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
168
- // Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
169
- // slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
170
- const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
171
- const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
172
- const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
173
- const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
174
-
175
- export function tQueryGraph(g, {
176
- question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false,
177
- include_classified = false, include_low_signal = false, token_budget = 2000,
178
- } = {}, toolCtx = {}) {
179
- const pinned = resolveSeedFiles(g, seed_files)
180
- // Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
181
- // Callers can opt back into augmentation when they explicitly want both behaviors.
182
- const automatic = pinned.seeds.length && augment_seeds !== true
183
- ? []
184
- : findSeeds(g, question, Math.max(0, 6 - pinned.seeds.length), {repoRoot: toolCtx.repoRoot || null})
185
- const seeds = [...pinned.seeds, ...automatic.filter((node) => !pinned.seeds.some((seed) => String(seed.id) === String(node.id)))]
186
- if (!seeds.length) return `No nodes matched "${question}".`
187
- const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
188
- const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
189
- const relOk = (rel) => !ctx || ctx.has(String(rel ?? '').toLowerCase())
190
- const requestedClasses = requestedPathClasses(question)
191
- const classifier = createPathClassifier(toolCtx.repoRoot || null)
192
- const classificationCache = new Map()
193
- const pinnedFiles = new Set(pinned.seeds.map(querySourceFile))
194
- const classifiedSuppressed = new Set()
195
- const pathPolicy = (id) => {
196
- const node = g.byId.get(String(id))
197
- const file = querySourceFile(node)
198
- if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
199
- if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
200
- const info = classificationCache.get(file)
201
- const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
202
- if (!classes.length && !info?.excluded) return {ok: true}
203
- if (classes.some((name) => requestedClasses.has(name))) return {ok: true}
204
- classifiedSuppressed.add(String(id))
205
- return {ok: false, bucket: 'classified'}
206
- }
207
- const questionTerms = queryWords(question)
208
- const isLowSignal = (id) => {
209
- if (include_low_signal === true || start.includes(String(id))) return false
210
- const node = g.byId.get(String(id))
211
- if (!node || !isSymbol(node.id) || !LOW_SIGNAL_SYMBOL_RE.test(String(node.symbol_kind || ''))) return false
212
- const labelTerms = queryWords(node.label || String(node.id || '').split('#').pop() || '')
213
- if ([...questionTerms].some((term) => labelTerms.has(term))) return false
214
- return degreeOf(g, id) === 0
215
- }
216
- const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
217
- // node budget scales gently with the token budget; edges follow the surviving nodes.
218
- const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
219
- const depthOf = new Map() // id -> shortest distance from any seed
220
- const start = seeds.map((s) => String(s.id))
221
- if (mode === 'dfs') {
222
- const stack = start.map((id) => ({id, d: 0}))
223
- const seen = new Set()
224
- while (stack.length) {
225
- const {id, d} = stack.pop()
226
- if (!depthOf.has(id) || d < depthOf.get(id)) depthOf.set(id, d)
227
- if (seen.has(id)) continue
228
- seen.add(id)
229
- if (d >= maxDepth) continue
230
- for (const [nid, rel] of undirectedNeighbors(g, id)) {
231
- if (!relOk(rel)) continue
232
- if (!pathPolicy(nid).ok) continue
233
- if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
234
- }
235
- }
236
- } else {
237
- let frontier = start.slice()
238
- start.forEach((id) => depthOf.set(id, 0))
239
- for (let d = 0; d < maxDepth && frontier.length; d++) {
240
- const next = []
241
- for (const id of frontier)
242
- for (const [nid, rel] of undirectedNeighbors(g, id)) {
243
- if (!relOk(rel)) continue
244
- if (!pathPolicy(nid).ok) continue
245
- if (!depthOf.has(nid)) {
246
- depthOf.set(nid, d + 1)
247
- next.push(nid)
248
- }
249
- }
250
- frontier = next
251
- }
252
- }
253
- // rank reached nodes: seeds first, then by proximity (depth asc), then connectivity (degree desc)
254
- const reachedBeforeSignalFilter = depthOf.size
255
- const lowSignalSuppressed = [...depthOf.keys()].filter(isLowSignal).length
256
- const ranked = [...depthOf.entries()]
257
- .filter(([id]) => !isLowSignal(id))
258
- .map(([id, d]) => ({id, d, deg: degreeOf(g, id)}))
259
- .sort((a, b) => a.d - b.d || b.deg - a.deg)
260
- const shown = ranked.slice(0, nodeBudget)
261
- const shownIds = new Set(shown.map((n) => n.id))
262
- const edgeSeen = new Set()
263
- const shownEdges = []
264
- for (const source of shownIds) {
265
- for (const edge of g.out.get(source) || []) {
266
- const target = String(edge.id)
267
- if (edge.barrelProxy === true || !shownIds.has(target) || !relOk(edge.relation)) continue
268
- const key = `${source}|${edge.relation}|${target}`
269
- if (edgeSeen.has(key)) continue
270
- edgeSeen.add(key)
271
- shownEdges.push([source, edge.relation, target])
272
- if (shownEdges.length >= 160) break
273
- }
274
- if (shownEdges.length >= 160) break
275
- }
276
- const head = [
277
- `Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
278
- `Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
279
- pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
280
- `Reached ${reachedBeforeSignalFilter} policy-eligible nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
281
- classifiedSuppressed.size ? `Suppressed ${classifiedSuppressed.size} classified/non-product traversal node(s); ask for that class or pass include_classified:true.` : null,
282
- lowSignalSuppressed ? `Suppressed ${lowSignalSuppressed} unreferenced constant/field node(s) with no query-term match; pass include_low_signal:true to inspect them.` : null,
283
- include_classified === true ? 'Path policy: classified/non-product traversal explicitly enabled.' : `Path policy: production-first${requestedClasses.size ? `; explicit question classes enabled: ${[...requestedClasses].join(', ')}` : ''}.`,
284
- ``,
285
- `Nodes:`,
286
- ]
287
- const nodeLines = shown.map((n) => ` [d${n.d}] ${labelOf(g, n.id)} (deg ${n.deg}) [${n.id}]`)
288
- const edgeLines = ['', 'Edges:', ...shownEdges.map(([s, r, t]) => ` ${labelOf(g, s)} --${r || 'rel'}--> ${labelOf(g, t)}`)]
289
- let text = [...head.filter(Boolean), ...nodeLines, ...edgeLines].join('\n')
290
- if (text.length > charBudget) text = text.slice(0, charBudget) + `\n... (truncated to ~${token_budget} tokens)`
291
- return text
292
- }
293
-
294
- export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
295
- const s = resolveNode(g, source)
296
- const t = resolveNode(g, target)
297
- if (!s) return `Source "${source}" not found.`
298
- if (!t) return `Target "${target}" not found.`
299
- const sid = String(s.id)
300
- const tid = String(t.id)
301
- if (sid === tid) return `Source and target are the same node: ${s.label ?? sid}.`
302
- const cap = Math.max(1, Math.min(20, Number(max_hops) || 8))
303
- const prev = new Map([[sid, null]])
304
- const relTo = new Map()
305
- let frontier = [sid]
306
- let hops = 0
307
- let found = false
308
- while (frontier.length && hops < cap && !found) {
309
- const next = []
310
- for (const id of frontier) {
311
- for (const [nid, rel] of undirectedNeighbors(g, id)) {
312
- if (prev.has(nid)) continue
313
- prev.set(nid, id)
314
- relTo.set(nid, rel)
315
- if (nid === tid) {
316
- found = true
317
- break
318
- }
319
- next.push(nid)
320
- }
321
- if (found) break
322
- }
323
- frontier = next
324
- hops++
325
- }
326
- if (!prev.has(tid)) return `No path found between "${s.label ?? sid}" and "${t.label ?? tid}" within ${cap} hops.`
327
- const path = []
328
- for (let cur = tid; cur != null; cur = prev.get(cur)) path.unshift(cur)
329
- const lines = path.map((id, i) => (i === 0 ? ` ${labelOf(g, id)}` : ` --${relTo.get(id) || 'rel'}--> ${labelOf(g, id)}`))
330
- return [`Shortest path (${path.length - 1} hops): ${s.label ?? sid} → ${t.label ?? tid}`, ...lines].join('\n')
331
- }
1
+ // Stable public facade for graph inspection and traversal tools. Propagate the catalog's
2
+ // cache-busting query into owner modules so edits go live without an MCP reconnect.
3
+ const version = new URL(import.meta.url).search
4
+ const load = (path) => import(new URL(`${path}${version}`, import.meta.url).href)
5
+ const [core, query, hubs] = await Promise.all([
6
+ load('./graph/tools-core.mjs'),
7
+ load('./graph/tools-query.mjs'),
8
+ load('./tools-graph-hubs.mjs'),
9
+ ])
10
+
11
+ export const tGraphStats = core.tGraphStats
12
+ export const tGetNode = core.tGetNode
13
+ export const tGetNeighbors = core.tGetNeighbors
14
+ export const tGetCommunity = core.tGetCommunity
15
+ export const tQueryGraph = query.tQueryGraph
16
+ export const tShortestPath = query.tShortestPath
17
+ export const tGodNodes = hubs.tGodNodes