weavatrix 0.2.12 → 0.2.14

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