weavatrix 0.2.13 → 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 (130) hide show
  1. package/README.md +83 -39
  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 +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 +10 -57
  18. package/src/analysis/dep-rules.js +10 -303
  19. package/src/analysis/dependency/scoped-dependencies.js +40 -0
  20. package/src/analysis/endpoints/common.js +62 -0
  21. package/src/analysis/endpoints/extract.js +107 -0
  22. package/src/analysis/endpoints/inventory.js +84 -0
  23. package/src/analysis/endpoints/mounts.js +129 -0
  24. package/src/analysis/endpoints-java.js +80 -3
  25. package/src/analysis/endpoints.js +3 -448
  26. package/src/analysis/git-history/analytics.js +177 -0
  27. package/src/analysis/git-history/collector.js +109 -0
  28. package/src/analysis/git-history/options.js +68 -0
  29. package/src/analysis/git-history.js +128 -577
  30. package/src/analysis/health-capabilities.js +82 -0
  31. package/src/analysis/http-contracts/analysis.js +169 -0
  32. package/src/analysis/http-contracts/client-call-detection.js +81 -0
  33. package/src/analysis/http-contracts/client-call-parser.js +255 -0
  34. package/src/analysis/http-contracts/graph-context.js +143 -0
  35. package/src/analysis/http-contracts/matching.js +61 -0
  36. package/src/analysis/http-contracts/shared.js +86 -0
  37. package/src/analysis/http-contracts.js +6 -822
  38. package/src/analysis/internal-audit/dependency-health.js +111 -0
  39. package/src/analysis/internal-audit/python-manifests.js +65 -0
  40. package/src/analysis/internal-audit/repo-files.js +55 -0
  41. package/src/analysis/internal-audit/supply-chain.js +132 -0
  42. package/src/analysis/internal-audit.collect.js +5 -106
  43. package/src/analysis/internal-audit.run.js +113 -200
  44. package/src/analysis/jvm-dependency-evidence.js +69 -0
  45. package/src/analysis/source-correctness/retry-patterns.js +93 -0
  46. package/src/analysis/source-correctness.js +225 -0
  47. package/src/analysis/structure/dependency-graph.js +156 -0
  48. package/src/analysis/structure/findings.js +142 -0
  49. package/src/graph/builder/go-receiver-resolution.js +112 -0
  50. package/src/graph/builder/js/queries.js +48 -0
  51. package/src/graph/builder/lang-go.js +89 -4
  52. package/src/graph/builder/lang-js.js +4 -44
  53. package/src/graph/builder/pass2-resolution.js +68 -0
  54. package/src/graph/freshness-probe.js +2 -1
  55. package/src/graph/incremental-refresh.js +3 -2
  56. package/src/graph/internal-builder.build.js +22 -183
  57. package/src/graph/internal-builder.pass2.js +161 -0
  58. package/src/graph/internal-builder.resolvers.js +2 -105
  59. package/src/graph/resolvers/rust.js +117 -0
  60. package/src/mcp/actions/advisories.mjs +18 -0
  61. package/src/mcp/actions/graph-lifecycle.mjs +148 -0
  62. package/src/mcp/actions/graph-sync.mjs +195 -0
  63. package/src/mcp/actions/hosted-architecture.mjs +57 -0
  64. package/src/mcp/architecture-bootstrap.mjs +168 -0
  65. package/src/mcp/architecture-starter.mjs +234 -0
  66. package/src/mcp/catalog.mjs +20 -6
  67. package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
  68. package/src/mcp/evidence/package-graph-common.mjs +115 -0
  69. package/src/mcp/evidence/package-lock-graph.mjs +92 -0
  70. package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
  71. package/src/mcp/graph/context-core.mjs +111 -0
  72. package/src/mcp/graph/context-seeds.mjs +208 -0
  73. package/src/mcp/graph/context-state.mjs +86 -0
  74. package/src/mcp/graph/tools-core.mjs +143 -0
  75. package/src/mcp/graph/tools-query.mjs +223 -0
  76. package/src/mcp/graph-context.mjs +4 -496
  77. package/src/mcp/health/audit-format.mjs +172 -0
  78. package/src/mcp/health/audit.mjs +171 -0
  79. package/src/mcp/health/dead-code.mjs +110 -0
  80. package/src/mcp/health/duplicates.mjs +83 -0
  81. package/src/mcp/health/endpoints.mjs +42 -0
  82. package/src/mcp/health/structure.mjs +219 -0
  83. package/src/mcp/runtime-version.mjs +32 -0
  84. package/src/mcp/server/auto-refresh.mjs +66 -0
  85. package/src/mcp/server/runtime-config.mjs +43 -0
  86. package/src/mcp/server/shutdown.mjs +40 -0
  87. package/src/mcp/sync/evidence-architecture.mjs +54 -0
  88. package/src/mcp/sync/evidence-common.mjs +88 -0
  89. package/src/mcp/sync/evidence-duplicates.mjs +100 -0
  90. package/src/mcp/sync/evidence-health.mjs +56 -0
  91. package/src/mcp/sync/evidence-packages.mjs +95 -0
  92. package/src/mcp/sync/payload-common.mjs +97 -0
  93. package/src/mcp/sync/payload-v2.mjs +53 -0
  94. package/src/mcp/sync/payload-v3.mjs +79 -0
  95. package/src/mcp/sync-evidence.mjs +7 -402
  96. package/src/mcp/sync-payload.mjs +10 -244
  97. package/src/mcp/tools-actions.mjs +18 -414
  98. package/src/mcp/tools-architecture.mjs +18 -70
  99. package/src/mcp/tools-context.mjs +56 -15
  100. package/src/mcp/tools-endpoints.mjs +4 -1
  101. package/src/mcp/tools-graph.mjs +17 -331
  102. package/src/mcp/tools-health.mjs +24 -705
  103. package/src/mcp-server.mjs +35 -146
  104. package/src/precision/lsp-client/constants.js +12 -0
  105. package/src/precision/lsp-client/environment.js +20 -0
  106. package/src/precision/lsp-client/errors.js +15 -0
  107. package/src/precision/lsp-client/lifecycle.js +127 -0
  108. package/src/precision/lsp-client/message-parser.js +81 -0
  109. package/src/precision/lsp-client/protocol.js +212 -0
  110. package/src/precision/lsp-client/registry.js +58 -0
  111. package/src/precision/lsp-client/repo-uri.js +60 -0
  112. package/src/precision/lsp-client/stdio-client.js +151 -0
  113. package/src/precision/lsp-client.js +10 -682
  114. package/src/precision/lsp-overlay/build.js +238 -0
  115. package/src/precision/lsp-overlay/contract.js +61 -0
  116. package/src/precision/lsp-overlay/reference-results.js +108 -0
  117. package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
  118. package/src/precision/lsp-overlay/source-session.js +134 -0
  119. package/src/precision/lsp-overlay/store.js +150 -0
  120. package/src/precision/lsp-overlay/target-index.js +147 -0
  121. package/src/precision/lsp-overlay/target-query.js +55 -0
  122. package/src/precision/lsp-overlay.js +11 -868
  123. package/src/precision/typescript-lsp-provider.js +12 -682
  124. package/src/precision/typescript-provider/client.js +69 -0
  125. package/src/precision/typescript-provider/discovery.js +124 -0
  126. package/src/precision/typescript-provider/project-host.js +249 -0
  127. package/src/precision/typescript-provider/project-safety.js +161 -0
  128. package/src/precision/typescript-provider/reference-usage.js +53 -0
  129. package/src/version.js +7 -0
  130. package/docs/releases/v0.2.13.md +0 -48
@@ -0,0 +1,208 @@
1
+ import {createPathClassifier, hasPathClass} from '../../path-classification.js'
2
+ import {degreeOf, isSymbol, uniqueConnCount} from './context-core.mjs'
3
+
4
+ const QUERY_STOP = new Set('a an and are around architecture best code do does exact explain find focus focused for from how identify in inspect inspection is logic me of or path production project repository request requests rest show symbol symbols the through to trace what where which with'.split(' '))
5
+ const QUERY_INTENTS = [
6
+ ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'application', 'applications', 'index', 'server', 'cli']],
7
+ ['tool-execution', ['tool', 'tools', 'tooling', 'mcp', 'execution', 'execute', 'invocation', 'invoke', 'dispatch', 'dispatcher', 'handler', 'catalog', 'registry']],
8
+ ['auth', ['auth', 'authentication', 'authorization', 'login', 'session', 'authgate']],
9
+ ['routing', ['routing', 'router', 'routes', 'route', 'navigation']],
10
+ ['layout', ['layout', 'layouts', 'shell']],
11
+ ['api', ['api', 'apis', 'endpoint', 'endpoints', 'client']],
12
+ ['state', ['state', 'store', 'stores', 'reducer', 'context']],
13
+ ]
14
+ const INTENT_BY_TERM = new Map(QUERY_INTENTS.filter(([id]) => id !== 'tool-execution')
15
+ .flatMap(([id, terms]) => terms.map((term) => [term, {id, terms}])))
16
+ const TOOL_EXECUTION_TERMS = QUERY_INTENTS.find(([id]) => id === 'tool-execution')[1]
17
+ const TOOL_EXECUTION_TRIGGERS = new Set(['tool', 'tools', 'tooling', 'mcp'])
18
+ const wordsOf = (value) => String(value ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
19
+ const normPath = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
20
+ const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
21
+ const CLASS_QUERY_TERMS = Object.freeze({
22
+ test: ['test', 'tests', 'testing', 'unit'],
23
+ e2e: ['e2e', 'playwright', 'cypress'],
24
+ generated: ['generated', 'autogenerated', 'dist'],
25
+ mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
26
+ story: ['story', 'stories', 'storybook'],
27
+ docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
28
+ benchmark: ['benchmark', 'benchmarks', 'bench'],
29
+ temp: ['temp', 'temporary', 'tmp'],
30
+ })
31
+ const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
32
+ const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
33
+ const LANGUAGE_EXTENSIONS = Object.freeze({
34
+ rust: ['rs'], python: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'],
35
+ javascript: ['js', 'jsx', 'mjs', 'cjs'], go: ['go'], java: ['java'], csharp: ['cs'],
36
+ })
37
+
38
+ function requestedLanguages(query) {
39
+ const raw = String(query || ''), words = new Set(wordsOf(query)), requested = new Set()
40
+ if (words.has('rust')) requested.add('rust')
41
+ if (words.has('python') || words.has('py')) requested.add('python')
42
+ if (words.has('typescript') || words.has('ts') || /typescript/i.test(raw)) requested.add('typescript')
43
+ if (words.has('javascript') || words.has('js') || words.has('nodejs') || /javascript|node\.?js/i.test(raw)) requested.add('javascript')
44
+ if (words.has('golang') || /(?:^|[^A-Za-z])Go(?:[^A-Za-z]|$)/.test(raw)) requested.add('go')
45
+ if (words.has('java')) requested.add('java')
46
+ if (words.has('csharp') || words.has('dotnet') || /csharp|(?:^|[^A-Za-z])C#(?:[^A-Za-z]|$)/i.test(raw)) requested.add('csharp')
47
+ return new Set([...requested].flatMap((language) => LANGUAGE_EXTENSIONS[language]))
48
+ }
49
+
50
+ const sourceFileOf = (node) => normPath(node?.source_file || String(node?.id ?? '').split('#', 1)[0])
51
+ const matchesLanguage = (node, extensions) => {
52
+ if (!extensions.size) return true
53
+ const match = /\.([^.\/]+)$/.exec(sourceFileOf(node))
54
+ return !!match && extensions.has(match[1].toLowerCase())
55
+ }
56
+
57
+ export function requestedPathClasses(query) {
58
+ const words = new Set(wordsOf(query)), requested = new Set()
59
+ for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) if (terms.some((term) => words.has(term))) requested.add(category)
60
+ if (requested.has('test')) requested.add('e2e')
61
+ if (requested.has('e2e')) requested.add('test')
62
+ return requested
63
+ }
64
+
65
+ function isQueryEligible(node, requestedClasses, classificationCache, classifier) {
66
+ const source = sourceFileOf(node)
67
+ if (!source) return true
68
+ if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
69
+ const info = classificationCache.get(source)
70
+ const classified = NON_PRODUCT_CLASSES.filter((category) => hasPathClass(info, category))
71
+ return classified.length === 0 || classified.some((category) => requestedClasses.has(category))
72
+ }
73
+
74
+ function entrypointSignal(g, node, source, stem) {
75
+ if (isSymbol(node.id) || !CODE_FILE_RE.test(source)) return 0
76
+ const depth = source.split('/').length
77
+ let score = 0
78
+ if (node.entrypoint === true || node.is_entrypoint === true || node.declared_entry === true) score = 72
79
+ if (/^bin\//.test(source) || /\/(?:bin|cmd)\//.test(source)) score = Math.max(score, 62)
80
+ if (depth <= 2 && /^(?:index|main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 60)
81
+ if (depth <= 2 && /(?:^|[-_.])(?:main|server|cli|bootstrap|entry)(?:$|[-_.])/.test(stem)) score = Math.max(score, 57)
82
+ if (depth <= 3 && /^(?:main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 52)
83
+ const incoming = uniqueConnCount(g.inn.get(String(node.id))), outgoing = uniqueConnCount(g.out.get(String(node.id)))
84
+ if (score && incoming === 0 && outgoing > 0) score += Math.min(7, 2 + outgoing)
85
+ return score
86
+ }
87
+
88
+ function toolExecutionSignal(node, source, words, stem) {
89
+ if (!CODE_FILE_RE.test(source)) return 0
90
+ let score = 0
91
+ if (/(^|\/)(?:mcp(?:[-_.][^/]*)?|tools?)(?:\/|[-_.])/.test(source)) score = Math.max(score, 51)
92
+ if (/^(?:catalog|dispatch(?:er)?|registry)$/.test(stem)) score = Math.max(score, 68)
93
+ if (/^(?:tool[-_.]?(?:handler|runner|executor)|tools?[-_.])/.test(stem)) score = Math.max(score, 55)
94
+ if ([...words].some((word) => /^(?:dispatch|dispatcher|toolcall|toolhandler|executetool|invoketool|calltool)$/.test(word))) score = Math.max(score, 64)
95
+ if (source.includes('/mcp/') || stem.includes('mcp-server')) score = Math.max(score, 48)
96
+ return score
97
+ }
98
+
99
+ function queryConcepts(query) {
100
+ const tokens = wordsOf(query)
101
+ const toolExecution = tokens.some((token) => TOOL_EXECUTION_TRIGGERS.has(token))
102
+ const explanatoryWork = tokens.some((token) => token === 'how' || token === 'explain')
103
+ const seen = new Set(), concepts = []
104
+ for (const raw of tokens) {
105
+ if (raw.length < 2 || QUERY_STOP.has(raw)) continue
106
+ if (explanatoryWork && (raw === 'work' || raw === 'works' || raw === 'working')) continue
107
+ if (toolExecution && TOOL_EXECUTION_TERMS.includes(raw)) {
108
+ if (seen.has('tool-execution')) continue
109
+ const trigger = tokens.find((token) => TOOL_EXECUTION_TRIGGERS.has(token)) || raw
110
+ seen.add('tool-execution')
111
+ concepts.push({id: 'tool-execution', raw: trigger, terms: [trigger, ...TOOL_EXECUTION_TERMS.filter((term) => term !== trigger)]})
112
+ continue
113
+ }
114
+ const intent = INTENT_BY_TERM.get(raw), id = intent?.id || raw
115
+ if (seen.has(id)) continue
116
+ seen.add(id)
117
+ concepts.push({id, raw, terms: intent ? [raw, ...intent.terms.filter((term) => term !== raw)] : [raw]})
118
+ }
119
+ return concepts
120
+ }
121
+
122
+ function exactIdentifierSeeds(g, query, limit, {repoRoot = null} = {}) {
123
+ const identifiers = [...new Set((String(query || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
124
+ .filter((item) => /(?:[a-z0-9][A-Z]|_)/.test(item)).map((item) => item.toLowerCase()))]
125
+ if (!identifiers.length) return []
126
+ const requestedClasses = requestedPathClasses(query), languageExtensions = requestedLanguages(query)
127
+ const classifier = createPathClassifier(repoRoot), classificationCache = new Map(), matches = []
128
+ for (const identifier of identifiers) {
129
+ const candidates = g.nodes.filter((node) => String(node.label || '').replace(/\(\)$/, '').toLowerCase() === identifier
130
+ && matchesLanguage(node, languageExtensions) && isQueryEligible(node, requestedClasses, classificationCache, classifier))
131
+ .sort((left, right) => degreeOf(g, right.id) - degreeOf(g, left.id) || String(left.id).localeCompare(String(right.id)))
132
+ for (const node of candidates) {
133
+ if (!matches.some((existing) => String(existing.id) === String(node.id))) matches.push(node)
134
+ if (matches.length >= limit) return matches
135
+ }
136
+ }
137
+ return matches
138
+ }
139
+
140
+ function conceptScore(g, node, concept, queryContext) {
141
+ const id = normPath(node.id), label = String(node.label ?? '').toLowerCase(), source = sourceFileOf(node)
142
+ const stem = (label.split('/').pop() || '').replace(/\.[^.]+$/, '')
143
+ const words = new Set(wordsOf(`${node.id} ${node.label ?? ''} ${node.source_file ?? ''}`))
144
+ const segments = new Set(source.split('/').flatMap((part) => wordsOf(part.replace(/\.[^.]+$/, ''))))
145
+ let match = 0
146
+ concept.terms.forEach((term, index) => {
147
+ const primary = index === 0
148
+ if (label === term || stem === term) match = Math.max(match, primary ? 60 : 42)
149
+ else if (segments.has(term)) match = Math.max(match, primary ? 48 : 36)
150
+ else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
151
+ else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
152
+ })
153
+ const extension = (/\.([^.\/]+)$/.exec(source) || [])[1] || ''
154
+ const languageConcept = {rust: ['rs'], python: ['py', 'pyi'], py: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'], ts: ['ts', 'tsx', 'mts', 'cts'], javascript: ['js', 'jsx', 'mjs', 'cjs'], js: ['js', 'jsx', 'mjs', 'cjs'], nodejs: ['js', 'jsx', 'mjs', 'cjs'], golang: ['go'], go: ['go'], java: ['java'], csharp: ['cs'], dotnet: ['cs']}[concept.raw]
155
+ if (languageConcept?.includes(extension) && queryContext.languageExtensions.has(extension)) match = Math.max(match, 58)
156
+ const fileNode = !isSymbol(node.id), depth = source ? source.split('/').length : 9
157
+ if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
158
+ if (concept.id === 'tool-execution') match = Math.max(match, toolExecutionSignal(node, source, words, stem))
159
+ if (!match) return 0
160
+ let score = match + (fileNode ? 7 : 0) + Math.max(0, 4 - depth) + Math.min(2, degreeOf(g, node.id) / 40)
161
+ if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && DATA_OR_PROSE_RE.test(source)) score -= 34
162
+ if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && !fileNode) score -= 18
163
+ if (queryContext.runtimeIntent && !queryContext.maintenanceIntent && /^(?:scripts?|tools?\/scripts?)\//.test(source)) score -= 32
164
+ if (queryContext.runtimeIntent && /^(?:site|website|public|static|assets)\//.test(source)) score -= 28
165
+ return Math.max(0, score)
166
+ }
167
+
168
+ export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
169
+ const exact = exactIdentifierSeeds(g, query, limit, {repoRoot})
170
+ if (exact.length) return exact
171
+ const concepts = queryConcepts(query)
172
+ if (!concepts.length || limit <= 0) return []
173
+ const requestedClasses = requestedPathClasses(query), languageExtensions = requestedLanguages(query)
174
+ const queryContext = {runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'), maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)), languageExtensions}
175
+ const classifier = createPathClassifier(repoRoot), classificationCache = new Map()
176
+ const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions) && isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
177
+ const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
178
+ return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
179
+ })
180
+ const chosen = [], used = new Set()
181
+ for (let index = 0; index < concepts.length && chosen.length < limit; index++) {
182
+ const best = rows.filter((row) => !used.has(String(row.node.id)) && row.scores[index] > 0)
183
+ .sort((a, b) => b.scores[index] - a.scores[index] || String(a.node.id).localeCompare(String(b.node.id)))[0]
184
+ if (best) { chosen.push(best.node); used.add(String(best.node.id)) }
185
+ }
186
+ rows.filter((row) => row.total > 0 && !used.has(String(row.node.id)))
187
+ .sort((a, b) => b.total - a.total || String(a.node.id).localeCompare(String(b.node.id)))
188
+ .slice(0, Math.max(0, limit - chosen.length)).forEach((row) => chosen.push(row.node))
189
+ return chosen
190
+ }
191
+
192
+ export function resolveSeedFiles(g, requested, limit = 12) {
193
+ const files = Array.isArray(requested) ? requested.slice(0, limit) : [], seeds = [], missing = []
194
+ for (const raw of files) {
195
+ const wanted = normPath(raw)
196
+ const node = g.nodes.find((candidate) => !isSymbol(candidate.id) && (normPath(candidate.id) === wanted || normPath(candidate.source_file) === wanted))
197
+ if (!node) missing.push(String(raw))
198
+ else if (!seeds.some((seed) => String(seed.id) === String(node.id))) seeds.push(node)
199
+ }
200
+ return {seeds, missing}
201
+ }
202
+
203
+ export function undirectedNeighbors(g, id) {
204
+ const seen = new Map()
205
+ for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
206
+ for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
207
+ return seen
208
+ }
@@ -0,0 +1,86 @@
1
+ import {readFileSync, statSync} from 'node:fs'
2
+ import {join} from 'node:path'
3
+ import {spawnSync} from 'node:child_process'
4
+ import {childProcessEnv} from '../../child-env.js'
5
+ import {resolveRepoPath} from '../../repo-path.js'
6
+ import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../../precision/lsp-overlay.js'
7
+
8
+ let stalenessCache = {key: '', checkedAt: 0, info: null}
9
+
10
+ export function graphStaleness(ctx) {
11
+ const now = Date.now()
12
+ if (stalenessCache.info && stalenessCache.key === ctx.graphPath && now - stalenessCache.checkedAt < 60_000) return stalenessCache.info
13
+ const info = {builtAt: null, headAt: null, stale: false, behind: null}
14
+ try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file */ }
15
+ if (ctx.repoRoot && info.builtAt) {
16
+ try {
17
+ const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
18
+ const iso = (head.stdout || '').trim()
19
+ if (head.status === 0 && iso) {
20
+ info.headAt = new Date(iso)
21
+ if (info.headAt > info.builtAt) {
22
+ info.stale = true
23
+ const count = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
24
+ if (count.status === 0) info.behind = Number(count.stdout.trim()) || null
25
+ }
26
+ }
27
+ } catch { /* git unavailable */ }
28
+ try {
29
+ const status = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
30
+ if (status.status === 0) {
31
+ let newer = 0
32
+ for (const line of String(status.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
33
+ const path = line.slice(3).trim().replace(/^"|"$/g, '')
34
+ try { if (statSync(join(ctx.repoRoot, path)).mtime > info.builtAt) newer++ } catch { newer++ }
35
+ }
36
+ info.dirtyNewer = newer
37
+ if (newer > 0) info.stale = true
38
+ }
39
+ } catch { /* git unavailable */ }
40
+ }
41
+ stalenessCache = {key: ctx.graphPath, checkedAt: now, info}
42
+ return info
43
+ }
44
+
45
+ export const resetStalenessCache = () => { stalenessCache = {key: '', checkedAt: 0, info: null} }
46
+
47
+ export function stalenessLine(ctx) {
48
+ const state = graphStaleness(ctx)
49
+ if (!state.stale) return null
50
+ const bits = []
51
+ if (state.headAt && state.headAt > state.builtAt) bits.push(`${state.behind != null ? `${state.behind} commit${state.behind === 1 ? '' : 's'}` : 'commits'} newer than the graph`)
52
+ if (state.dirtyNewer) bits.push(`${state.dirtyNewer} uncommitted file(s) edited after the build`)
53
+ return `Warning: graph may be stale — the repo has ${bits.join(' and ')} (built ${state.builtAt.toISOString()}). Line numbers may have drifted; call rebuild_graph.`
54
+ }
55
+
56
+ export function fileStalenessNote(ctx, sourceFile) {
57
+ if (!ctx?.repoRoot || !sourceFile) return null
58
+ const state = graphStaleness(ctx)
59
+ if (!state.builtAt) return null
60
+ try {
61
+ const resolved = resolveRepoPath(ctx.repoRoot, String(sourceFile))
62
+ if (resolved.ok && statSync(resolved.path).mtime > state.builtAt) {
63
+ return `Note: ${sourceFile} changed after the graph was built — line numbers above may have drifted (rebuild_graph refreshes them).`
64
+ }
65
+ } catch { /* missing file */ }
66
+ return null
67
+ }
68
+
69
+ let rawGraphCache = {path: '', mtimeMs: 0, data: null}
70
+
71
+ export function rawGraph(ctx) {
72
+ const mtimeMs = statSync(ctx.graphPath).mtimeMs
73
+ if (!rawGraphCache.data || rawGraphCache.path !== ctx.graphPath || rawGraphCache.mtimeMs !== mtimeMs) {
74
+ rawGraphCache = {path: ctx.graphPath, mtimeMs, data: JSON.parse(readFileSync(ctx.graphPath, 'utf8'))}
75
+ }
76
+ return rawGraphCache.data
77
+ }
78
+
79
+ export function effectiveRawGraph(ctx) {
80
+ const raw = rawGraph(ctx)
81
+ const overlay = readPrecisionOverlay(ctx.graphPath, raw)
82
+ const safeOverlay = ctx?.repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
83
+ && !precisionSemanticInputsMatch(overlay, ctx.repoRoot, raw)
84
+ ? null : overlay
85
+ return mergePrecisionOverlay(raw, safeOverlay)
86
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ isSymbol, degreeOf, labelOf, connList, resolveNodeInfo, ambiguityNote,
3
+ graphStaleness, fileStalenessNote,
4
+ } from '../graph-context.mjs'
5
+ import {summarizeEdgeProvenance} from '../../graph/edge-provenance.js'
6
+
7
+ const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
8
+
9
+ export function tGraphStats(g, ctx) {
10
+ const files = g.nodes.filter((node) => !isSymbol(node.id)).length
11
+ const symbols = g.nodes.length - files
12
+ const relCount = {}
13
+ const confCount = {}
14
+ let typeOnlyEdges = 0
15
+ let compileOnlyEdges = 0
16
+ for (const edge of g.links) {
17
+ relCount[edge.relation ?? '?'] = (relCount[edge.relation ?? '?'] || 0) + 1
18
+ if (edge.confidence != null) confCount[edge.confidence] = (confCount[edge.confidence] || 0) + 1
19
+ if (edge.typeOnly === true) typeOnlyEdges++
20
+ if (edge.compileOnly === true) compileOnlyEdges++
21
+ }
22
+ const communities = new Map()
23
+ for (const node of g.nodes) {
24
+ const community = node.community ?? 'none'
25
+ communities.set(community, (communities.get(community) || 0) + 1)
26
+ }
27
+ const topCommunities = [...communities.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8)
28
+ const formatCounts = (counts) => Object.entries(counts)
29
+ .sort((a, b) => b[1] - a[1])
30
+ .map(([key, value]) => `${key}: ${value}`)
31
+ .join(', ')
32
+ const freshness = ctx ? graphStaleness(ctx) : null
33
+ const provenance = summarizeEdgeProvenance(g.links)
34
+ const precision = g.precision || {state: 'UNAVAILABLE', verifiedEdges: 0, candidates: 0, queried: 0, reason: 'no revision-matched precision overlay'}
35
+ return [
36
+ 'Graph summary',
37
+ ctx?.runtime ? `- Weavatrix runtime: v${ctx.runtime.version}; disk v${ctx.runtime.diskVersion || 'unavailable'}; stale ${ctx.runtime.staleRuntime ? 'YES' : 'no'}; profile ${ctx.runtime.profile}; ${ctx.runtime.toolCount} registered tools; capabilities: ${ctx.runtime.capabilities.join(',') || '(none)'}` : null,
38
+ ctx?.repoRoot ? `- Repo: ${ctx.repoRoot}` : null,
39
+ ctx?.graphPath ? `- Graph: ${ctx.graphPath}` : null,
40
+ `- Build mode: ${g.graphBuildMode || 'full'}`,
41
+ `- Nodes: ${g.nodes.length} (${files} files, ${symbols} symbols)`,
42
+ `- Edges: ${g.links.length}`,
43
+ g.edgeTypesV ? `- Typed-edge metadata: v${g.edgeTypesV} (${typeOnlyEdges} type-only, ${compileOnlyEdges} compile-only edges)` : '- Typed-edge metadata: unavailable (rebuild_graph required)',
44
+ g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${formatCounts(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : '- Edge provenance: unavailable (rebuild_graph required)',
45
+ `- 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}` : ''}`,
46
+ 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)',
47
+ g.reExportOccurrencesV ? `- Re-export occurrences: v${g.reExportOccurrencesV} (${g.reExportOccurrences.length} exact site(s))` : '- Re-export occurrences: unavailable (rebuild_graph required)',
48
+ g.symbolSpacesV ? `- TypeScript symbol spaces: v${g.symbolSpacesV} (type/value identities separated)` : '- TypeScript symbol spaces: unavailable (rebuild_graph required)',
49
+ `- Relations: ${formatCounts(relCount)}`,
50
+ Object.keys(confCount).length ? `- Legacy confidence: ${formatCounts(confCount)}` : null,
51
+ `- Communities: ${communities.size} (top by size: ${topCommunities.map(([community, count]) => `#${community}=${count}`).join(', ')})`,
52
+ freshness?.builtAt ? `- Built: ${freshness.builtAt.toISOString()}${freshness.headAt ? ` (repo HEAD committed ${freshness.headAt.toISOString()})` : ''}` : null,
53
+ ].filter(Boolean).join('\n')
54
+ }
55
+
56
+ export function tGetNode(g, {label} = {}, ctx) {
57
+ const info = resolveNodeInfo(g, label)
58
+ const node = info.node
59
+ if (!node) return `No node found matching "${label}".`
60
+ const note = ambiguityNote(label, info)
61
+ const id = String(node.id)
62
+ const drift = ctx ? fileStalenessNote(ctx, node.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
63
+ const outgoing = g.out.get(id) || []
64
+ const incoming = g.inn.get(id) || []
65
+ const semanticOutgoing = connList(outgoing)
66
+ const semanticIncoming = connList(incoming)
67
+ const sample = (edges, direction) => edges.slice(0, 12)
68
+ .map((edge) => ` ${direction === 'out' ? '→' : '←'} ${compileKind(edge) ? `${compileKind(edge)} ` : ''}${edge.relation || 'rel'} [${edge.provenance || 'UNKNOWN'}] ${labelOf(g, edge.id)} [${edge.id}]`)
69
+ .join('\n') || ' (none)'
70
+ return [
71
+ note,
72
+ `Node: ${node.label ?? id}`,
73
+ `- id: ${id}`,
74
+ `- kind: ${isSymbol(id) ? 'symbol' : 'file'}${node.file_type ? ` (${node.file_type})` : ''}`,
75
+ node.source_file ? `- source: ${node.source_file}${node.source_location ? ` ${node.source_location}` : ''}` : null,
76
+ node.community != null ? `- community: ${node.community}` : null,
77
+ `- semantic degree: ${semanticOutgoing.length + semanticIncoming.length} (out ${semanticOutgoing.length}, in ${semanticIncoming.length})${outgoing.length + incoming.length !== semanticOutgoing.length + semanticIncoming.length ? `; ${outgoing.length + incoming.length} physical/structural edges retained` : ''}`,
78
+ `Outgoing:\n${sample(outgoing, 'out')}`,
79
+ `Incoming:\n${sample(incoming, 'in')}`,
80
+ drift,
81
+ ].filter(Boolean).join('\n')
82
+ }
83
+
84
+ function dedupeEdges(edges) {
85
+ const grouped = new Map()
86
+ for (const edge of edges) {
87
+ const key = `${edge.relation || 'rel'}|${compileKind(edge) || 'runtime'}|${edge.id}`
88
+ const current = grouped.get(key)
89
+ if (current) {
90
+ current.count += 1
91
+ current.provenance.add(edge.provenance || 'UNKNOWN')
92
+ } else grouped.set(key, {
93
+ id: edge.id, relation: edge.relation, typeOnly: edge.typeOnly === true,
94
+ compileOnly: edge.compileOnly === true, provenance: new Set([edge.provenance || 'UNKNOWN']), count: 1,
95
+ })
96
+ }
97
+ return [...grouped.values()]
98
+ }
99
+
100
+ export function tGetNeighbors(g, {label, relation_filter} = {}, ctx) {
101
+ const info = resolveNodeInfo(g, label)
102
+ const node = info.node
103
+ if (!node) return `No node found matching "${label}".`
104
+ const note = ambiguityNote(label, info)
105
+ const id = String(node.id)
106
+ const drift = ctx ? fileStalenessNote(ctx, node.source_file || (isSymbol(id) ? id.split('#')[0] : id)) : null
107
+ const filter = relation_filter ? String(relation_filter).toLowerCase() : null
108
+ const matches = (edge) => !filter || String(edge.relation ?? '').toLowerCase() === filter
109
+ const outgoingRaw = (g.out.get(id) || []).filter(matches)
110
+ const incomingRaw = (g.inn.get(id) || []).filter(matches)
111
+ const outgoing = dedupeEdges(outgoingRaw)
112
+ const incoming = dedupeEdges(incomingRaw)
113
+ const line = (edge, direction) => ` ${direction === 'out' ? '→' : '←'} ${compileKind(edge) ? `${compileKind(edge)} ` : ''}${edge.relation || 'rel'} [${[...edge.provenance].sort().join('+')}] ${labelOf(g, edge.id)} [${edge.id}]${edge.count > 1 ? ` (${edge.count} sites)` : ''}`
114
+ return [
115
+ note,
116
+ `Neighbors of ${node.label ?? id}${filter ? ` (relation=${filter})` : ''}: ${outgoing.length + incoming.length} unique (${outgoingRaw.length + incomingRaw.length} edges)`,
117
+ `Outgoing (${outgoing.length}):`,
118
+ ...outgoing.slice(0, 60).map((edge) => line(edge, 'out')),
119
+ `Incoming (${incoming.length}):`,
120
+ ...incoming.slice(0, 60).map((edge) => line(edge, 'in')),
121
+ drift,
122
+ ].filter(Boolean).join('\n')
123
+ }
124
+
125
+ export function tGetCommunity(g, {community_id} = {}) {
126
+ const groups = new Map()
127
+ for (const node of g.nodes) {
128
+ if (node.community == null) continue
129
+ if (!groups.has(node.community)) groups.set(node.community, [])
130
+ groups.get(node.community).push(node)
131
+ }
132
+ const ranked = [...groups.entries()].sort((a, b) => b[1].length - a[1].length)
133
+ const index = Number(community_id)
134
+ if (!Number.isInteger(index) || index < 0 || index >= ranked.length) return `Invalid community_id ${community_id}. Valid range 0..${ranked.length - 1} (0 = largest).`
135
+ const [rawId, members] = ranked[index]
136
+ const files = members.filter((member) => !isSymbol(member.id))
137
+ return [
138
+ `Community #${index} (raw id ${rawId}) — ${members.length} nodes, ${files.length} files:`,
139
+ ...members.slice().sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id)).slice(0, 80)
140
+ .map((member) => ` ${member.label ?? member.id} [${member.id}]`),
141
+ members.length > 80 ? ` … +${members.length - 80} more` : null,
142
+ ].filter(Boolean).join('\n')
143
+ }