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
@@ -1,497 +1,5 @@
1
- // Shared graph core for the MCP tool modules: graph loading + indexes, node resolution, staleness,
2
- // the raw-graph cache, and the structural diff helpers. This module holds process-lifetime CACHES
3
- // (staleness, raw graph), so every tool module imports it STATICALLY — it is the one part of src/mcp
4
- // that does NOT hot-reload (editing it needs an MCP reconnect, same as the analysis engines).
5
- import {readFileSync, statSync} from 'node:fs'
6
- import {join} from 'node:path'
7
- import {spawnSync} from 'node:child_process'
8
- import {childProcessEnv} from '../child-env.js'
9
- import {resolveRepoPath} from '../repo-path.js'
10
- import {isStructuralRelation} from '../graph/relations.js'
11
- import {edgeProvenance} from '../graph/edge-provenance.js'
12
- import {mergePrecisionOverlay, precisionSemanticInputsMatch, readPrecisionOverlay} from '../precision/lsp-overlay.js'
13
- import {createPathClassifier, hasPathClass} from '../path-classification.js'
14
-
15
- // ---- graph load + indexes -----------------------------------------------------------------------
16
- export function loadGraph(path, {repoRoot = null} = {}) {
17
- const saved = JSON.parse(readFileSync(path, 'utf8'))
18
- const overlay = readPrecisionOverlay(path, saved)
19
- const safeOverlay = repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
20
- && !precisionSemanticInputsMatch(overlay, repoRoot, saved)
21
- ? null : overlay
22
- const raw = mergePrecisionOverlay(saved, safeOverlay)
23
- const nodes = Array.isArray(raw.nodes) ? raw.nodes : []
24
- const links = Array.isArray(raw.links) ? raw.links : []
25
- const byId = new Map()
26
- const byLabel = new Map()
27
- for (const n of nodes) {
28
- if (!n || n.id == null) continue
29
- byId.set(String(n.id), n)
30
- const key = String(n.label ?? n.id).toLowerCase()
31
- if (!byLabel.has(key)) byLabel.set(key, [])
32
- byLabel.get(key).push(n)
33
- }
34
- const out = new Map() // id -> [{id, relation, confidence}]
35
- const inn = new Map()
36
- const push = (map, k, v) => {
37
- if (!map.has(k)) map.set(k, [])
38
- map.get(k).push(v)
39
- }
40
- for (const e of links) {
41
- if (!e || e.source == null || e.target == null) continue
42
- const s = String(e.source)
43
- const t = String(e.target)
44
- const metadata = {
45
- relation: e.relation,
46
- confidence: e.confidence,
47
- provenance: edgeProvenance(e),
48
- ...(e.typeOnly === true ? {typeOnly: true} : {}),
49
- ...(e.compileOnly === true ? {compileOnly: true} : {}),
50
- ...(Number.isInteger(e.line) ? {line: e.line} : {}),
51
- ...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
52
- ...(e.barrelProxy === true ? {barrelProxy: true} : {}),
53
- ...(e.semanticOrigin === true ? {semanticOrigin: true} : {}),
54
- ...(typeof e.viaBarrel === 'string' ? {viaBarrel: e.viaBarrel} : {}),
55
- }
56
- push(out, s, {id: t, ...metadata})
57
- push(inn, t, {id: s, ...metadata})
58
- }
59
- return {
60
- nodes, links, byId, byLabel, out, inn,
61
- repoBoundaryV: Number(raw.repoBoundaryV) || 0,
62
- edgeTypesV: Number(raw.edgeTypesV) || 0,
63
- edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
64
- barrelResolutionV: Number(raw.barrelResolutionV) || 0,
65
- reExportOccurrencesV: Number(raw.reExportOccurrencesV) || 0,
66
- symbolSpacesV: Number(raw.symbolSpacesV) || 0,
67
- reExportOccurrences: Array.isArray(raw.reExportOccurrences) ? raw.reExportOccurrences : [],
68
- jsExportRecords: raw.jsExportRecords && typeof raw.jsExportRecords === 'object' ? raw.jsExportRecords : {},
69
- extractorSchemaV: Number(raw.extractorSchemaV) || 0,
70
- extImportsV: Number(raw.extImportsV) || 0,
71
- complexityV: Number(raw.complexityV) || 0,
72
- graphBuildMode: ['full', 'no-tests', 'tests-only'].includes(raw.graphBuildMode) ? raw.graphBuildMode : 'full',
73
- graphBuildScope: typeof raw.graphBuildScope === 'string' ? raw.graphBuildScope : null,
74
- graphRevision: typeof raw.graphRevision === 'string' ? raw.graphRevision : null,
75
- repositoryFreshnessProbeV: Number(raw.repositoryFreshnessProbeV) || 0,
76
- repositoryFreshnessBuilderSchemaV: Number(raw.repositoryFreshnessBuilderSchemaV) || 0,
77
- repositoryFreshnessBuilderVersion: typeof raw.repositoryFreshnessBuilderVersion === 'string' ? raw.repositoryFreshnessBuilderVersion : null,
78
- repositoryFreshnessProbe: typeof raw.repositoryFreshnessProbe === 'string' ? raw.repositoryFreshnessProbe : null,
79
- repositoryFreshnessMode: typeof raw.repositoryFreshnessMode === 'string' ? raw.repositoryFreshnessMode : null,
80
- graphPrecisionMode: raw.graphPrecisionMode === 'off' ? 'off' : 'lsp',
81
- precisionOverlayV: Number(raw.precisionOverlayV) || 0,
82
- precision: raw.precision || null,
83
- }
84
- }
85
-
86
- export const isSymbol = (id) => String(id).includes('#')
87
- export const labelOf = (g, id) => {
88
- const n = g.byId.get(String(id))
89
- return n ? String(n.label ?? n.id) : String(id)
90
- }
91
-
92
- // Connectivity ignores structural file/symbol and class/method ownership, so runtime/compile-time
93
- // dependency ranks never treat nesting as a call or import.
94
- export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation) && e.barrelProxy !== true)
95
- export const degreeOf = (g, id) => connList(g.out.get(id)).length + connList(g.inn.get(id)).length
96
- export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
97
-
98
- // Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
99
- // Returns {node, matches, alternates} so callers can disclose ambiguity instead of silently picking one.
100
- export function resolveNodeInfo(g, query) {
101
- const q = String(query ?? '').trim()
102
- if (!q) return {node: null, matches: 0, alternates: []}
103
- if (g.byId.has(q)) return {node: g.byId.get(q), matches: 1, alternates: []}
104
- const lc = q.toLowerCase()
105
- const exactLabel = g.byLabel.get(lc)
106
- if (exactLabel?.length) return pickBest(g, exactLabel)
107
- // substring over id + label
108
- const hits = []
109
- for (const n of g.nodes) {
110
- const id = String(n.id).toLowerCase()
111
- const lbl = String(n.label ?? '').toLowerCase()
112
- if (id.includes(lc) || lbl.includes(lc)) hits.push(n)
113
- if (hits.length > 500) break
114
- }
115
- return hits.length ? pickBest(g, hits) : {node: null, matches: 0, alternates: []}
116
- }
117
- export const resolveNode = (g, query) => resolveNodeInfo(g, query).node
118
- function pickBest(g, list) {
119
- const node = bestByDegree(g, list)
120
- const alternates = list
121
- .filter((n) => n !== node)
122
- .sort((a, b) => degreeOf(g, b.id) - degreeOf(g, a.id))
123
- .slice(0, 4)
124
- .map((n) => `${n.label ?? n.id} [${n.id}]`)
125
- return {node, matches: list.length, alternates}
126
- }
127
- // One-line disclosure when a fuzzy label matched several nodes — silently picking one hides wrong-node errors.
128
- export function ambiguityNote(query, info) {
129
- if (!info.node || info.matches <= 1) return null
130
- const more = info.matches - 1 - info.alternates.length
131
- return `Note: "${query}" matched ${info.matches} nodes; using the best-connected. Others: ${info.alternates.join(', ')}${more > 0 ? ` (+${more} more)` : ''}`
132
- }
133
- const bestByDegree = (g, list) =>
134
- list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
135
-
136
- // Instructional words describe the requested answer, not repository concepts. Letting them become
137
- // fuzzy seeds made broad prompts select symbols such as `jest.config.cjs#path` for an HTTP request
138
- // path. Exact file/symbol questions still have seed_files / inspect_symbol as explicit controls.
139
- 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(' '))
140
- const QUERY_INTENTS = [
141
- ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'application', 'applications', 'index', 'server', 'cli']],
142
- ['tool-execution', ['tool', 'tools', 'tooling', 'mcp', 'execution', 'execute', 'invocation', 'invoke', 'dispatch', 'dispatcher', 'handler', 'catalog', 'registry']],
143
- ['auth', ['auth', 'authentication', 'authorization', 'login', 'session', 'authgate']],
144
- ['routing', ['routing', 'router', 'routes', 'route', 'navigation']],
145
- ['layout', ['layout', 'layouts', 'shell']],
146
- ['api', ['api', 'apis', 'endpoint', 'endpoints', 'client']],
147
- ['state', ['state', 'store', 'stores', 'reducer', 'context']],
148
- ]
149
- const INTENT_BY_TERM = new Map(QUERY_INTENTS
150
- .filter(([id]) => id !== 'tool-execution')
151
- .flatMap(([id, terms]) => terms.map((term) => [term, {id, terms}])))
152
- const TOOL_EXECUTION_TERMS = QUERY_INTENTS.find(([id]) => id === 'tool-execution')[1]
153
- const TOOL_EXECUTION_TRIGGERS = new Set(['tool', 'tools', 'tooling', 'mcp'])
154
- const wordsOf = (value) => String(value ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
155
- const normPath = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
156
- const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
157
- const CLASS_QUERY_TERMS = Object.freeze({
158
- test: ['test', 'tests', 'testing', 'spec', 'specs', 'unit'],
159
- e2e: ['e2e', 'playwright', 'cypress'],
160
- generated: ['generated', 'autogenerated', 'dist'],
161
- mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
162
- story: ['story', 'stories', 'storybook'],
163
- docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
164
- benchmark: ['benchmark', 'benchmarks', 'bench'],
165
- temp: ['temp', 'temporary', 'tmp'],
166
- })
167
- const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
168
- const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
169
- const LANGUAGE_EXTENSIONS = Object.freeze({
170
- rust: ['rs'], python: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'],
171
- javascript: ['js', 'jsx', 'mjs', 'cjs'], go: ['go'], java: ['java'], csharp: ['cs'],
172
- })
173
-
174
- function requestedLanguages(query) {
175
- const raw = String(query || '')
176
- const words = new Set(wordsOf(query))
177
- const requested = new Set()
178
- if (words.has('rust')) requested.add('rust')
179
- if (words.has('python') || words.has('py')) requested.add('python')
180
- if (words.has('typescript') || words.has('ts') || /typescript/i.test(raw)) requested.add('typescript')
181
- if (words.has('javascript') || words.has('js') || words.has('nodejs') || /javascript|node\.?js/i.test(raw)) requested.add('javascript')
182
- if (words.has('golang') || /(?:^|[^A-Za-z])Go(?:[^A-Za-z]|$)/.test(raw)) requested.add('go')
183
- if (words.has('java')) requested.add('java')
184
- if (words.has('csharp') || words.has('dotnet') || /csharp|(?:^|[^A-Za-z])C#(?:[^A-Za-z]|$)/i.test(raw)) requested.add('csharp')
185
- return new Set([...requested].flatMap((language) => LANGUAGE_EXTENSIONS[language]))
186
- }
187
-
188
- const matchesLanguage = (node, extensions) => {
189
- if (!extensions.size) return true
190
- const match = /\.([^.\/]+)$/.exec(sourceFileOf(node))
191
- return !!match && extensions.has(match[1].toLowerCase())
192
- }
193
-
194
- const sourceFileOf = (node) => {
195
- const source = node?.source_file || String(node?.id ?? '').split('#', 1)[0]
196
- return normPath(source)
197
- }
198
-
199
- export function requestedPathClasses(query) {
200
- const words = new Set(wordsOf(query))
201
- const requested = new Set()
202
- for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) {
203
- if (terms.some((term) => words.has(term))) requested.add(category)
204
- }
205
- // E2E is also classified as test. Conversely, a broad request for tests should be allowed to
206
- // surface all test kinds instead of silently hiding integration/e2e files.
207
- if (requested.has('test')) requested.add('e2e')
208
- if (requested.has('e2e')) requested.add('test')
209
- return requested
210
- }
211
-
212
- function isQueryEligible(node, requestedClasses, classificationCache, classifier) {
213
- const source = sourceFileOf(node)
214
- if (!source) return true
215
- // Query ranking needs path/config classes, not generated-header inspection. Supplying bounded
216
- // content avoids opening every source file in a large repository merely to choose a few seeds.
217
- if (!classificationCache.has(source)) classificationCache.set(source, classifier.explain(source, {content: ''}))
218
- const info = classificationCache.get(source)
219
- const classified = NON_PRODUCT_CLASSES.filter((category) => hasPathClass(info, category))
220
- return classified.length === 0 || classified.some((category) => requestedClasses.has(category))
221
- }
222
-
223
- // A graph query has no repository filesystem context, so it cannot safely execute package scripts or
224
- // guess a package.json beside an arbitrary graph path. Prefer evidence already present in the graph:
225
- // conventional executable paths, explicit entry metadata when a builder provides it, and entry-like
226
- // topology (few importers, real outgoing runtime links).
227
- function entrypointSignal(g, node, source, stem) {
228
- if (isSymbol(node.id) || !CODE_FILE_RE.test(source)) return 0
229
- const segments = source.split('/')
230
- const depth = segments.length
231
- let score = 0
232
- if (node.entrypoint === true || node.is_entrypoint === true || node.declared_entry === true) score = 72
233
- if (/^bin\//.test(source) || /\/(?:bin|cmd)\//.test(source)) score = Math.max(score, 62)
234
- if (depth <= 2 && /^(?:index|main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 60)
235
- if (depth <= 2 && /(?:^|[-_.])(?:main|server|cli|bootstrap|entry)(?:$|[-_.])/.test(stem)) score = Math.max(score, 57)
236
- if (depth <= 3 && /^(?:main|app|server|cli|bootstrap|entry|run)$/.test(stem)) score = Math.max(score, 52)
237
- const incoming = uniqueConnCount(g.inn.get(String(node.id)))
238
- const outgoing = uniqueConnCount(g.out.get(String(node.id)))
239
- if (score && incoming === 0 && outgoing > 0) score += Math.min(7, 2 + outgoing)
240
- return score
241
- }
242
-
243
- function toolExecutionSignal(node, source, words, stem) {
244
- if (!CODE_FILE_RE.test(source)) return 0
245
- let score = 0
246
- if (/(^|\/)(?:mcp(?:[-_.][^/]*)?|tools?)(?:\/|[-_.])/.test(source)) score = Math.max(score, 51)
247
- // Dispatch/catalog files explain how the tool set is assembled and invoked; individual
248
- // `tools-*` modules explain only one capability. Prefer the control plane for broad questions.
249
- if (/^(?:catalog|dispatch(?:er)?|registry)$/.test(stem)) score = Math.max(score, 68)
250
- if (/^(?:tool[-_.]?(?:handler|runner|executor)|tools?[-_.])/.test(stem)) score = Math.max(score, 55)
251
- if ([...words].some((word) => /^(?:dispatch|dispatcher|toolcall|toolhandler|executetool|invoketool|calltool)$/.test(word))) score = Math.max(score, 64)
252
- if (source.includes('/mcp/') || stem.includes('mcp-server')) score = Math.max(score, 48)
253
- return score
254
- }
255
-
256
- function queryConcepts(query) {
257
- const tokens = wordsOf(query)
258
- const toolExecution = tokens.some((token) => TOOL_EXECUTION_TRIGGERS.has(token))
259
- const explanatoryWork = tokens.some((token) => token === 'how' || token === 'explain')
260
- const seen = new Set()
261
- const concepts = []
262
- for (const raw of tokens) {
263
- if (raw.length < 2 || QUERY_STOP.has(raw)) continue
264
- if (explanatoryWork && (raw === 'work' || raw === 'works' || raw === 'working')) continue
265
- if (toolExecution && TOOL_EXECUTION_TERMS.includes(raw)) {
266
- if (seen.has('tool-execution')) continue
267
- const trigger = tokens.find((token) => TOOL_EXECUTION_TRIGGERS.has(token)) || raw
268
- seen.add('tool-execution')
269
- concepts.push({id: 'tool-execution', raw: trigger, terms: [trigger, ...TOOL_EXECUTION_TERMS.filter((term) => term !== trigger)]})
270
- continue
271
- }
272
- const intent = INTENT_BY_TERM.get(raw)
273
- const id = intent?.id || raw
274
- if (seen.has(id)) continue
275
- seen.add(id)
276
- concepts.push({id, raw, terms: intent ? [raw, ...intent.terms.filter((term) => term !== raw)] : [raw]})
277
- }
278
- return concepts
279
- }
280
-
281
- // A code-shaped identifier in prose is stronger evidence than surrounding architecture words.
282
- // For example, `startMitigate` should seed its exact controller/service/messaging declarations,
283
- // not one arbitrary file for each of "controller", "service", and "flow". This remains bounded
284
- // label matching rather than semantic search; ambiguous identifiers are deliberately all retained.
285
- function exactIdentifierSeeds(g, query, limit, {repoRoot = null} = {}) {
286
- const identifiers = [...new Set((String(query || '').match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || [])
287
- .filter((token) => /(?:[a-z0-9][A-Z]|_)/.test(token))
288
- .map((token) => token.toLowerCase()))]
289
- if (!identifiers.length) return []
290
- const requestedClasses = requestedPathClasses(query)
291
- const languageExtensions = requestedLanguages(query)
292
- const classifier = createPathClassifier(repoRoot)
293
- const classificationCache = new Map()
294
- const matches = []
295
- for (const identifier of identifiers) {
296
- const candidates = g.nodes.filter((node) => {
297
- const label = String(node.label || '').replace(/\(\)$/, '').toLowerCase()
298
- return label === identifier
299
- && matchesLanguage(node, languageExtensions)
300
- && isQueryEligible(node, requestedClasses, classificationCache, classifier)
301
- }).sort((left, right) => degreeOf(g, right.id) - degreeOf(g, left.id)
302
- || String(left.id).localeCompare(String(right.id)))
303
- for (const node of candidates) {
304
- if (!matches.some((existing) => String(existing.id) === String(node.id))) matches.push(node)
305
- if (matches.length >= limit) return matches
306
- }
307
- }
308
- return matches
309
- }
310
-
311
- function conceptScore(g, node, concept, queryContext) {
312
- const id = normPath(node.id)
313
- const label = String(node.label ?? '').toLowerCase()
314
- const source = sourceFileOf(node)
315
- const stem = (label.split('/').pop() || '').replace(/\.[^.]+$/, '')
316
- const words = new Set(wordsOf(`${node.id} ${node.label ?? ''} ${node.source_file ?? ''}`))
317
- const segments = new Set(source.split('/').flatMap((part) => wordsOf(part.replace(/\.[^.]+$/, ''))))
318
- let match = 0
319
- concept.terms.forEach((term, index) => {
320
- const primary = index === 0
321
- if (label === term || stem === term) match = Math.max(match, primary ? 60 : 42)
322
- else if (segments.has(term)) match = Math.max(match, primary ? 48 : 36)
323
- else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
324
- else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
325
- })
326
- const extension = (/\.([^.\/]+)$/.exec(source) || [])[1] || ''
327
- const languageConcept = {
328
- rust: ['rs'], python: ['py', 'pyi'], py: ['py', 'pyi'],
329
- typescript: ['ts', 'tsx', 'mts', 'cts'], ts: ['ts', 'tsx', 'mts', 'cts'],
330
- javascript: ['js', 'jsx', 'mjs', 'cjs'], js: ['js', 'jsx', 'mjs', 'cjs'], nodejs: ['js', 'jsx', 'mjs', 'cjs'],
331
- golang: ['go'], go: ['go'], java: ['java'], csharp: ['cs'], dotnet: ['cs'],
332
- }[concept.raw]
333
- if (languageConcept?.includes(extension) && queryContext.languageExtensions.has(extension)) match = Math.max(match, 58)
334
- const fileNode = !isSymbol(node.id)
335
- const depth = source ? source.split('/').length : 9
336
- if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
337
- if (concept.id === 'tool-execution') match = Math.max(match, toolExecutionSignal(node, source, words, stem))
338
- if (!match) return 0
339
- let score = match + (fileNode ? 7 : 0) + Math.max(0, 4 - depth) + Math.min(2, degreeOf(g, node.id) / 40)
340
- if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && DATA_OR_PROSE_RE.test(source)) score -= 34
341
- if ((concept.id === 'bootstrap' || concept.id === 'tool-execution') && !fileNode) score -= 18
342
- if (queryContext.runtimeIntent && !queryContext.maintenanceIntent && /^(?:scripts?|tools?\/scripts?)\//.test(source)) score -= 32
343
- // A web/demo index can be a legitimate entry point, but it is not the server/tool entry point of
344
- // a backend/tool-execution question. Keep it queryable for explicit UI/site questions while
345
- // preventing it from beating the executable and dispatcher merely because its basename is index.
346
- if (queryContext.runtimeIntent && /^(?:site|website|public|static|assets)\//.test(source)) score -= 28
347
- return Math.max(0, score)
348
- }
349
-
350
- // Natural-language graph search keeps one strong candidate per concept before filling by aggregate
351
- // score. This prevents a broad architecture question from spending every seed on one dense API area.
352
- export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
353
- const exact = exactIdentifierSeeds(g, query, limit, {repoRoot})
354
- if (exact.length) return exact
355
- const concepts = queryConcepts(query)
356
- if (!concepts.length || limit <= 0) return []
357
- const requestedClasses = requestedPathClasses(query)
358
- const languageExtensions = requestedLanguages(query)
359
- const queryContext = {
360
- runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'),
361
- maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)),
362
- languageExtensions,
363
- }
364
- const classifier = createPathClassifier(repoRoot)
365
- const classificationCache = new Map()
366
- const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions)
367
- && isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
368
- const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
369
- return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
370
- })
371
- const chosen = []
372
- const used = new Set()
373
- for (let index = 0; index < concepts.length && chosen.length < limit; index++) {
374
- const best = rows.filter((row) => !used.has(String(row.node.id)) && row.scores[index] > 0)
375
- .sort((a, b) => b.scores[index] - a.scores[index] || String(a.node.id).localeCompare(String(b.node.id)))[0]
376
- if (best) { chosen.push(best.node); used.add(String(best.node.id)) }
377
- }
378
- rows.filter((row) => row.total > 0 && !used.has(String(row.node.id)))
379
- .sort((a, b) => b.total - a.total || String(a.node.id).localeCompare(String(b.node.id)))
380
- .slice(0, Math.max(0, limit - chosen.length))
381
- .forEach((row) => chosen.push(row.node))
382
- return chosen
383
- }
384
-
385
- export function resolveSeedFiles(g, requested, limit = 12) {
386
- const files = Array.isArray(requested) ? requested.slice(0, limit) : []
387
- const seeds = []
388
- const missing = []
389
- for (const raw of files) {
390
- const wanted = normPath(raw)
391
- const node = g.nodes.find((candidate) => !isSymbol(candidate.id)
392
- && (normPath(candidate.id) === wanted || normPath(candidate.source_file) === wanted))
393
- if (!node) missing.push(String(raw))
394
- else if (!seeds.some((seed) => String(seed.id) === String(node.id))) seeds.push(node)
395
- }
396
- return {seeds, missing}
397
- }
398
-
399
- // undirected adjacency for reachability (query/shortest path)
400
- export function undirectedNeighbors(g, id) {
401
- const seen = new Map()
402
- for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
403
- for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
404
- return seen
405
- }
406
-
407
- // ---- staleness ----------------------------------------------------------------------------------
408
- // The graph is a point-in-time build of graph.json; without a freshness signal an agent cannot tell
409
- // whether answers reflect the current code. Compare graph.json mtime with the repo's latest commit
410
- // (cheap `git log -1`), cached for 60s so per-tool warnings don't spawn git on every call.
411
- let stalenessCache = {key: '', checkedAt: 0, info: null}
412
- export function graphStaleness(ctx) {
413
- const now = Date.now()
414
- if (stalenessCache.info && stalenessCache.key === ctx.graphPath && now - stalenessCache.checkedAt < 60_000) return stalenessCache.info
415
- const info = {builtAt: null, headAt: null, stale: false, behind: null}
416
- try { info.builtAt = statSync(ctx.graphPath).mtime } catch { /* no graph file — nothing to report */ }
417
- if (ctx.repoRoot && info.builtAt) {
418
- try {
419
- const head = spawnSync('git', ['-C', ctx.repoRoot, 'log', '-1', '--format=%cI'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
420
- const iso = (head.stdout || '').trim()
421
- if (head.status === 0 && iso) {
422
- info.headAt = new Date(iso)
423
- if (info.headAt > info.builtAt) {
424
- info.stale = true
425
- const cnt = spawnSync('git', ['-C', ctx.repoRoot, 'rev-list', '--count', `--since=${info.builtAt.toISOString()}`, 'HEAD'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
426
- if (cnt.status === 0) info.behind = Number(cnt.stdout.trim()) || null
427
- }
428
- }
429
- } catch { /* git unavailable — degrade to builtAt only */ }
430
- // Uncommitted work drifts line numbers just as hard as commits do (that is how agents get bitten:
431
- // they edit, then re-query). Count dirty files actually TOUCHED after the build — a dirty file
432
- // older than the graph was already part of it.
433
- try {
434
- const st = spawnSync('git', ['-C', ctx.repoRoot, 'status', '--porcelain'], {encoding: 'utf8', timeout: 4000, env: childProcessEnv()})
435
- if (st.status === 0) {
436
- let newer = 0
437
- for (const ln of String(st.stdout || '').split(/\r?\n/).filter(Boolean).slice(0, 200)) {
438
- const p = ln.slice(3).trim().replace(/^"|"$/g, '')
439
- try { if (statSync(join(ctx.repoRoot, p)).mtime > info.builtAt) newer++ } catch { newer++ } // deleted counts as drift
440
- }
441
- info.dirtyNewer = newer
442
- if (newer > 0) info.stale = true
443
- }
444
- } catch { /* git unavailable */ }
445
- }
446
- stalenessCache = {key: ctx.graphPath, checkedAt: now, info}
447
- return info
448
- }
449
- export const resetStalenessCache = () => { stalenessCache = {key: '', checkedAt: 0, info: null} }
450
- export function stalenessLine(ctx) {
451
- const s = graphStaleness(ctx)
452
- if (!s.stale) return null
453
- const bits = []
454
- if (s.headAt && s.headAt > s.builtAt) bits.push(`${s.behind != null ? `${s.behind} commit${s.behind === 1 ? '' : 's'}` : 'commits'} newer than the graph`)
455
- if (s.dirtyNewer) bits.push(`${s.dirtyNewer} uncommitted file(s) edited after the build`)
456
- return `Warning: graph may be stale — the repo has ${bits.join(' and ')} (built ${s.builtAt.toISOString()}). Line numbers may have drifted; call rebuild_graph.`
457
- }
458
-
459
- // Per-file drift check for tools that print exact line numbers: the global warning says the REPO
460
- // moved; this says THIS file moved — the difference between "be careful" and "these numbers are off".
461
- export function fileStalenessNote(ctx, sourceFile) {
462
- if (!ctx?.repoRoot || !sourceFile) return null
463
- const s = graphStaleness(ctx)
464
- if (!s.builtAt) return null
465
- try {
466
- const resolved = resolveRepoPath(ctx.repoRoot, String(sourceFile))
467
- if (resolved.ok && statSync(resolved.path).mtime > s.builtAt) {
468
- return `Note: ${sourceFile} changed after the graph was built — line numbers above may have drifted (rebuild_graph refreshes them).`
469
- }
470
- } catch { /* file gone — the read tools will surface that themselves */ }
471
- return null
472
- }
473
-
474
- // Raw graph.json (with externalImports, file_type, source_end …) for the analysis modules — the MCP's
475
- // own loadGraph struct strips those fields. Cached by mtime; rebuild_graph changes the mtime → refresh.
476
- let rawGraphCache = {path: '', mtimeMs: 0, data: null}
477
- export function rawGraph(ctx) {
478
- const mtimeMs = statSync(ctx.graphPath).mtimeMs
479
- if (!rawGraphCache.data || rawGraphCache.path !== ctx.graphPath || rawGraphCache.mtimeMs !== mtimeMs) {
480
- rawGraphCache = {path: ctx.graphPath, mtimeMs, data: JSON.parse(readFileSync(ctx.graphPath, 'utf8'))}
481
- }
482
- return rawGraphCache.data
483
- }
484
-
485
- // Consumers that need semantic precision use the revision-matched effective graph. Structural Git
486
- // baselines and graph_diff deliberately keep using rawGraph so provider availability cannot fabricate
487
- // architecture drift.
488
- export function effectiveRawGraph(ctx) {
489
- const raw = rawGraph(ctx)
490
- const overlay = readPrecisionOverlay(ctx.graphPath, raw)
491
- const safeOverlay = ctx?.repoRoot && typeof overlay?.semanticInputFingerprint === 'string'
492
- && !precisionSemanticInputsMatch(overlay, ctx.repoRoot, raw)
493
- ? null : overlay
494
- return mergePrecisionOverlay(raw, safeOverlay)
495
- }
496
-
1
+ // Stable public facade for graph loading, query seeding and graph state helpers.
2
+ export * from './graph/context-core.mjs'
3
+ export * from './graph/context-seeds.mjs'
4
+ export * from './graph/context-state.mjs'
497
5
  export {prevGraphPathFor, edgeEndpoint, fileOfId, diffGraphs, formatGraphDiff} from './graph-diff.mjs'