weavatrix 0.1.4 → 0.2.0

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 (70) hide show
  1. package/README.md +138 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +92 -30
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +509 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +549 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +581 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +62 -19
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +51 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +144 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph.mjs +25 -14
  62. package/src/mcp/tools-health.mjs +336 -14
  63. package/src/mcp/tools-history.mjs +22 -0
  64. package/src/mcp/tools-impact-change.mjs +261 -0
  65. package/src/mcp/tools-impact.mjs +25 -6
  66. package/src/mcp-server.mjs +100 -17
  67. package/src/mcp-source-tools.mjs +11 -3
  68. package/src/path-classification.js +201 -0
  69. package/src/path-ignore.js +69 -0
  70. package/src/security/typosquat.js +1 -1
@@ -0,0 +1,249 @@
1
+ import {createHash} from 'node:crypto'
2
+ import {readFileSync, statSync} from 'node:fs'
3
+ import {createRepoBoundary} from '../repo-path.js'
4
+ import {CAPS, STATE, compareText, safeToken} from './evidence-snapshot.common.mjs'
5
+
6
+ const LOCKFILES = ['npm-shrinkwrap.json', 'package-lock.json']
7
+ const MAX_LOCKFILE_BYTES = 64 * 1024 * 1024
8
+ const MAX_PACKAGE_RECORDS = 50_000
9
+ const MAX_DEPENDENCY_DECLARATIONS = 200_000
10
+ const PACKAGE_NAME = /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/i
11
+ const PACKAGE_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+~-]*$/
12
+
13
+ const packageVersion = (value) => {
14
+ const version = safeToken(value, 128)
15
+ return version && PACKAGE_VERSION.test(version) ? version : null
16
+ }
17
+
18
+ const emptyCount = () => ({total: 0, returned: 0, truncated: false})
19
+ const emptyDeclarations = () => ({total: 0, resolved: 0, unresolved: 0, local: 0, optionalMissing: 0})
20
+
21
+ function emptyGraph(state, reason, extras = {}) {
22
+ return {
23
+ state,
24
+ ecosystem: 'npm',
25
+ root: '(root)',
26
+ ...extras,
27
+ completeness: {
28
+ nodes: emptyCount(),
29
+ edges: emptyCount(),
30
+ declarations: emptyDeclarations(),
31
+ reasons: reason ? [reason] : [],
32
+ },
33
+ nodes: [],
34
+ edges: [],
35
+ }
36
+ }
37
+
38
+ function normalizeLockPath(value) {
39
+ if (typeof value !== 'string' || value.length > 4096 || /[\u0000-\u001f\u007f]/.test(value)) return null
40
+ const raw = value.replace(/\\/g, '/')
41
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(raw)) return null
42
+ const normalized = raw.replace(/\/$/, '')
43
+ if (normalized === '') return ''
44
+ const parts = normalized.split('/')
45
+ return parts.every((part) => part && part !== '.' && part !== '..') ? normalized : null
46
+ }
47
+
48
+ function packageNameFromPath(packagePath) {
49
+ const match = packagePath.match(/(?:^|\/)node_modules\/((?:@[^/]+\/)?[^/]+)$/)
50
+ const name = match?.[1]
51
+ return name && name.length <= 256 && PACKAGE_NAME.test(name) ? name : null
52
+ }
53
+
54
+ function packageId(name, version, packagePath) {
55
+ const location = createHash('sha256').update(packagePath).digest('hex').slice(0, 12)
56
+ return `npm:${name}@${version}:${location}`
57
+ }
58
+
59
+ function parentInstallPath(packagePath) {
60
+ const marker = packagePath.lastIndexOf('/node_modules/')
61
+ return marker >= 0 ? packagePath.slice(0, marker) : ''
62
+ }
63
+
64
+ function dependencyDeclarations(record) {
65
+ const declarations = new Map()
66
+ const add = (values, kind, replace = false) => {
67
+ if (!values || typeof values !== 'object' || Array.isArray(values)) return
68
+ for (const name of Object.keys(values)) {
69
+ if (name.length > 256 || !PACKAGE_NAME.test(name)) continue
70
+ if (replace || !declarations.has(name)) declarations.set(name, kind)
71
+ }
72
+ }
73
+ add(record?.dependencies, 'runtime')
74
+ add(record?.devDependencies, 'dev')
75
+ add(record?.optionalDependencies, 'optional', true)
76
+ if (record?.peerDependencies && typeof record.peerDependencies === 'object' && !Array.isArray(record.peerDependencies)) {
77
+ for (const name of Object.keys(record.peerDependencies)) {
78
+ if (name.length > 256 || !PACKAGE_NAME.test(name) || declarations.has(name)) continue
79
+ const optional = record?.peerDependenciesMeta?.[name]?.optional === true
80
+ declarations.set(name, optional ? 'optional-peer' : 'peer')
81
+ }
82
+ }
83
+ return [...declarations].map(([name, kind]) => ({name, kind}))
84
+ .sort((a, b) => compareText(a.name, b.name) || compareText(a.kind, b.kind))
85
+ }
86
+
87
+ function resolveDependency(sourcePath, name, records, externalNodes) {
88
+ let cursor = sourcePath
89
+ const seen = new Set()
90
+ while (!seen.has(cursor)) {
91
+ seen.add(cursor)
92
+ const candidate = cursor ? `${cursor}/node_modules/${name}` : `node_modules/${name}`
93
+ const record = records.get(candidate)
94
+ if (record) {
95
+ if (record.link === true) return {kind: 'local'}
96
+ const node = externalNodes.get(candidate)
97
+ return node ? {kind: 'external', node} : {kind: 'unresolved'}
98
+ }
99
+ if (!cursor) break
100
+ cursor = parentInstallPath(cursor)
101
+ }
102
+ return {kind: 'unresolved'}
103
+ }
104
+
105
+ function parseLock(lock, lockfile) {
106
+ const lockfileVersion = Number(lock?.lockfileVersion || 0)
107
+ const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
108
+ if (![2, 3].includes(lockfileVersion) || !lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
109
+ return emptyGraph(STATE.PARTIAL, 'PACKAGE_LOCK_V2_V3_REQUIRED', extras)
110
+ }
111
+
112
+ const allKeys = Object.keys(lock.packages).sort(compareText)
113
+ const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
114
+ const reasons = []
115
+ if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
116
+
117
+ const records = new Map()
118
+ let invalidRecords = 0
119
+ for (const rawPath of selectedKeys) {
120
+ const packagePath = normalizeLockPath(rawPath)
121
+ const record = lock.packages[rawPath]
122
+ if (packagePath == null || !record || typeof record !== 'object' || Array.isArray(record)) {
123
+ invalidRecords++
124
+ continue
125
+ }
126
+ const insideNodeModules = packagePath.startsWith('node_modules/') || packagePath.includes('/node_modules/')
127
+ if (insideNodeModules && !packageNameFromPath(packagePath)) {
128
+ invalidRecords++
129
+ continue
130
+ }
131
+ if (records.has(packagePath)) {
132
+ invalidRecords++
133
+ continue
134
+ }
135
+ records.set(packagePath, record)
136
+ }
137
+ if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
138
+
139
+ const externalNodes = new Map()
140
+ let invalidPackageVersions = 0
141
+ for (const [packagePath, record] of records) {
142
+ const name = packageNameFromPath(packagePath)
143
+ const version = packageVersion(record.version)
144
+ if (!name || record.link === true) continue
145
+ if (!version) { invalidPackageVersions++; continue }
146
+ externalNodes.set(packagePath, {
147
+ id: packageId(name, version, packagePath),
148
+ name,
149
+ version,
150
+ direct: false,
151
+ dev: record.dev === true || record.devOptional === true,
152
+ optional: record.optional === true || record.devOptional === true,
153
+ peer: record.peer === true,
154
+ })
155
+ }
156
+ if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
157
+
158
+ const edgeMap = new Map()
159
+ const declarations = emptyDeclarations()
160
+ let declarationLimitReached = false
161
+ const sources = [...records].filter(([packagePath]) =>
162
+ packageNameFromPath(packagePath) == null || externalNodes.has(packagePath))
163
+ .sort(([a], [b]) => compareText(a, b))
164
+
165
+ outer: for (const [sourcePath, record] of sources) {
166
+ const sourceNode = externalNodes.get(sourcePath)
167
+ const sourceId = sourceNode?.id || '(root)'
168
+ for (const declaration of dependencyDeclarations(record)) {
169
+ if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) {
170
+ declarationLimitReached = true
171
+ break outer
172
+ }
173
+ declarations.total++
174
+ const resolved = resolveDependency(sourcePath, declaration.name, records, externalNodes)
175
+ if (resolved.kind === 'local') {
176
+ declarations.local++
177
+ continue
178
+ }
179
+ if (resolved.kind !== 'external') {
180
+ if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
181
+ else declarations.unresolved++
182
+ continue
183
+ }
184
+ declarations.resolved++
185
+ if (sourceId === '(root)') resolved.node.direct = true
186
+ if (sourceId === resolved.node.id) continue
187
+ const edge = {from: sourceId, to: resolved.node.id, kind: declaration.kind}
188
+ edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
189
+ }
190
+ }
191
+ if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
192
+ if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
193
+
194
+ const allNodes = [...externalNodes.values()].sort((a, b) =>
195
+ Number(b.direct) - Number(a.direct) || compareText(a.name, b.name) ||
196
+ compareText(a.version, b.version) || compareText(a.id, b.id))
197
+ const nodes = allNodes.slice(0, CAPS.packageGraphNodes)
198
+ const nodeIds = new Set(nodes.map((node) => node.id))
199
+ const allEdges = [...edgeMap.values()].sort((a, b) =>
200
+ compareText(a.from, b.from) || compareText(a.to, b.to) || compareText(a.kind, b.kind))
201
+ const eligibleEdges = allEdges.filter((edge) =>
202
+ (edge.from === '(root)' || nodeIds.has(edge.from)) && nodeIds.has(edge.to))
203
+ const edges = eligibleEdges.slice(0, CAPS.packageGraphEdges)
204
+ const nodesTruncated = nodes.length < allNodes.length
205
+ const edgesTruncated = edges.length < allEdges.length
206
+ if (nodesTruncated) reasons.push('PACKAGE_NODE_LIMIT_REACHED')
207
+ if (edgesTruncated) reasons.push('PACKAGE_EDGE_LIMIT_REACHED')
208
+
209
+ return {
210
+ state: reasons.length ? STATE.PARTIAL : STATE.COMPLETE,
211
+ ecosystem: 'npm',
212
+ lockfile,
213
+ lockfileVersion,
214
+ root: '(root)',
215
+ completeness: {
216
+ nodes: {total: allNodes.length, returned: nodes.length, truncated: nodesTruncated},
217
+ edges: {total: allEdges.length, returned: edges.length, truncated: edgesTruncated},
218
+ declarations,
219
+ reasons: [...new Set(reasons)].sort(compareText),
220
+ },
221
+ nodes,
222
+ edges,
223
+ }
224
+ }
225
+
226
+ export function buildPackageDependencyGraph(repoRoot) {
227
+ const boundary = createRepoBoundary(repoRoot)
228
+ if (!boundary.root) return emptyGraph(STATE.ERROR, 'INVALID_REPOSITORY_ROOT')
229
+
230
+ let selected = null
231
+ for (const lockfile of LOCKFILES) {
232
+ const resolved = boundary.resolve(lockfile)
233
+ if (resolved.ok) {
234
+ selected = {lockfile, path: resolved.path}
235
+ break
236
+ }
237
+ }
238
+ if (!selected) return emptyGraph(STATE.NOT_APPLICABLE, 'PACKAGE_LOCK_V2_V3_NOT_FOUND')
239
+
240
+ try {
241
+ if (statSync(selected.path).size > MAX_LOCKFILE_BYTES) {
242
+ return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_SIZE_LIMIT_REACHED', {lockfile: selected.lockfile})
243
+ }
244
+ const lock = JSON.parse(readFileSync(selected.path, 'utf8'))
245
+ return parseLock(lock, selected.lockfile)
246
+ } catch {
247
+ return emptyGraph(STATE.ERROR, 'PACKAGE_LOCK_READ_ERROR', {lockfile: selected.lockfile})
248
+ }
249
+ }
@@ -0,0 +1,81 @@
1
+ import {createHash} from 'node:crypto'
2
+ import {readFileSync} from 'node:fs'
3
+ import {buildFileImportGraph, checkBoundaries, findSccs, representativeCycle} from '../analysis/dep-rules.js'
4
+ import {createRepoBoundary} from '../repo-path.js'
5
+ import {CAPS, bounded, compareText, repoRelativePath, safeToken} from './evidence-snapshot.common.mjs'
6
+
7
+ const MAX_CYCLE_MEMBERS = 200
8
+ const SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info'])
9
+ const hash = (value) => createHash('sha256').update(value).digest('hex').slice(0, 24)
10
+
11
+ function sortedSccs(adjacency) {
12
+ return findSccs(adjacency)
13
+ .map((members) => members.map((member) => repoRelativePath(member)).filter(Boolean).sort(compareText))
14
+ .filter((members) => members.length > 1)
15
+ .sort((a, b) => b.length - a.length || compareText(a.join('\0'), b.join('\0')))
16
+ }
17
+
18
+ function cycleFact(kind, adjacency, members) {
19
+ const representative = representativeCycle(adjacency, members).map((member) => repoRelativePath(member)).filter(Boolean)
20
+ return {
21
+ id: hash(`${kind}\0${members.join('\0')}`),
22
+ kind,
23
+ size: members.length,
24
+ members: members.slice(0, MAX_CYCLE_MEMBERS),
25
+ membersTruncated: members.length > MAX_CYCLE_MEMBERS,
26
+ representativePath: representative.slice(0, MAX_CYCLE_MEMBERS + 1),
27
+ }
28
+ }
29
+
30
+ function readRules(repoRoot) {
31
+ try {
32
+ const boundary = createRepoBoundary(repoRoot)
33
+ const resolved = boundary.resolve('.weavatrix-deps.json')
34
+ if (!resolved.ok) return {rules: {}, state: 'NOT_APPLICABLE'}
35
+ const parsed = JSON.parse(readFileSync(resolved.path, 'utf8'))
36
+ const hasRules = Array.isArray(parsed?.forbidden) && parsed.forbidden.length > 0 ||
37
+ Array.isArray(parsed?.allowedOnly) && parsed.allowedOnly.length > 0
38
+ return {rules: parsed && typeof parsed === 'object' ? parsed : {}, state: hasRules ? 'COMPLETE' : 'NOT_APPLICABLE'}
39
+ } catch {
40
+ return {rules: {}, state: 'ERROR'}
41
+ }
42
+ }
43
+
44
+ function boundaryFact(value) {
45
+ const from = repoRelativePath(value?.from), to = repoRelativePath(value?.to)
46
+ if (!from || !to || !['forbidden', 'allowedOnly'].includes(value?.kind)) return null
47
+ const name = safeToken(value?.name, 96)
48
+ return {
49
+ kind: value.kind,
50
+ ruleId: name || hash(String(value?.name || 'unnamed-rule')),
51
+ severity: SEVERITIES.has(value?.severity) ? value.severity : 'medium',
52
+ from,
53
+ to,
54
+ }
55
+ }
56
+
57
+ export function buildStructureEvidence(graph, repoRoot) {
58
+ try {
59
+ const imports = buildFileImportGraph(graph)
60
+ const runtimeSccs = sortedSccs(imports.runtimeAdj)
61
+ const runtimeKeys = new Set(runtimeSccs.map((members) => members.join('\0')))
62
+ const compileSccs = sortedSccs(imports.allAdj).filter((members) => !runtimeKeys.has(members.join('\0')))
63
+ const allCycles = [
64
+ ...runtimeSccs.map((members) => cycleFact('runtime', imports.runtimeAdj, members)),
65
+ ...compileSccs.map((members) => cycleFact('compile-time', imports.allAdj, members)),
66
+ ].sort((a, b) => a.kind === b.kind ? b.size - a.size || compareText(a.id, b.id) : a.kind === 'runtime' ? -1 : 1)
67
+ const cycles = bounded(allCycles, CAPS.architectureFindings)
68
+ const rules = readRules(repoRoot)
69
+ const allBoundaries = checkBoundaries(imports.runtimeEdges, rules.rules)
70
+ .map(boundaryFact).filter(Boolean)
71
+ .sort((a, b) => compareText(a.ruleId, b.ruleId) || compareText(a.from, b.from) || compareText(a.to, b.to))
72
+ const boundaries = bounded(allBoundaries, CAPS.architectureFindings)
73
+ return {state: 'COMPLETE', rulesState: rules.state, cycles, boundaries}
74
+ } catch {
75
+ return {
76
+ state: 'ERROR', rulesState: 'ERROR',
77
+ cycles: {items: [], completeness: {total: 0, returned: 0, truncated: false}},
78
+ boundaries: {items: [], completeness: {total: 0, returned: 0, truncated: false}},
79
+ }
80
+ }
81
+ }
@@ -40,6 +40,9 @@ export function loadGraph(path) {
40
40
  ...(e.compileOnly === true ? {compileOnly: true} : {}),
41
41
  ...(Number.isInteger(e.line) ? {line: e.line} : {}),
42
42
  ...(typeof e.specifier === 'string' ? {specifier: e.specifier} : {}),
43
+ ...(e.barrelProxy === true ? {barrelProxy: true} : {}),
44
+ ...(e.semanticOrigin === true ? {semanticOrigin: true} : {}),
45
+ ...(typeof e.viaBarrel === 'string' ? {viaBarrel: e.viaBarrel} : {}),
43
46
  }
44
47
  push(out, s, {id: t, ...metadata})
45
48
  push(inn, t, {id: s, ...metadata})
@@ -48,6 +51,18 @@ export function loadGraph(path) {
48
51
  nodes, links, byId, byLabel, out, inn,
49
52
  repoBoundaryV: Number(raw.repoBoundaryV) || 0,
50
53
  edgeTypesV: Number(raw.edgeTypesV) || 0,
54
+ barrelResolutionV: Number(raw.barrelResolutionV) || 0,
55
+ extractorSchemaV: Number(raw.extractorSchemaV) || 0,
56
+ extImportsV: Number(raw.extImportsV) || 0,
57
+ complexityV: Number(raw.complexityV) || 0,
58
+ graphBuildMode: ['full', 'no-tests', 'tests-only'].includes(raw.graphBuildMode) ? raw.graphBuildMode : 'full',
59
+ graphBuildScope: typeof raw.graphBuildScope === 'string' ? raw.graphBuildScope : null,
60
+ graphRevision: typeof raw.graphRevision === 'string' ? raw.graphRevision : null,
61
+ repositoryFreshnessProbeV: Number(raw.repositoryFreshnessProbeV) || 0,
62
+ repositoryFreshnessBuilderSchemaV: Number(raw.repositoryFreshnessBuilderSchemaV) || 0,
63
+ repositoryFreshnessBuilderVersion: typeof raw.repositoryFreshnessBuilderVersion === 'string' ? raw.repositoryFreshnessBuilderVersion : null,
64
+ repositoryFreshnessProbe: typeof raw.repositoryFreshnessProbe === 'string' ? raw.repositoryFreshnessProbe : null,
65
+ repositoryFreshnessMode: typeof raw.repositoryFreshnessMode === 'string' ? raw.repositoryFreshnessMode : null,
51
66
  }
52
67
  }
53
68
 
@@ -59,7 +74,7 @@ export const labelOf = (g, id) => {
59
74
 
60
75
  // Connectivity ignores structural file/symbol and class/method ownership, so runtime/compile-time
61
76
  // dependency ranks never treat nesting as a call or import.
62
- export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation))
77
+ export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation) && e.barrelProxy !== true)
63
78
  export const degreeOf = (g, id) => connList(g.out.get(id)).length + connList(g.inn.get(id)).length
64
79
  export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
65
80
 
@@ -101,28 +116,97 @@ export function ambiguityNote(query, info) {
101
116
  const bestByDegree = (g, list) =>
102
117
  list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
103
118
 
104
- // seeds for traversal/search: rank substring/token matches by degree
119
+ const QUERY_STOP = new Set('a an and are around architecture code do does explain find for from how in is me of or project repository show the through to trace what where which with'.split(' '))
120
+ const QUERY_INTENTS = [
121
+ ['bootstrap', ['bootstrap', 'startup', 'entrypoint', 'entry', 'main', 'root', 'app', 'index']],
122
+ ['auth', ['auth', 'authentication', 'authorization', 'login', 'session', 'authgate']],
123
+ ['routing', ['routing', 'router', 'routes', 'route', 'navigation']],
124
+ ['layout', ['layout', 'layouts', 'shell']],
125
+ ['api', ['api', 'apis', 'endpoint', 'endpoints', 'client']],
126
+ ['state', ['state', 'store', 'stores', 'reducer', 'context']],
127
+ ]
128
+ const INTENT_BY_TERM = new Map(QUERY_INTENTS.flatMap(([id, terms]) => terms.map((term) => [term, {id, terms}])))
129
+ const wordsOf = (value) => String(value ?? '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean)
130
+ const normPath = (value) => String(value ?? '').replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
131
+
132
+ function queryConcepts(query) {
133
+ const seen = new Set()
134
+ const concepts = []
135
+ for (const raw of wordsOf(query)) {
136
+ if (raw.length < 2 || QUERY_STOP.has(raw)) continue
137
+ const intent = INTENT_BY_TERM.get(raw)
138
+ const id = intent?.id || raw
139
+ if (seen.has(id)) continue
140
+ seen.add(id)
141
+ concepts.push({id, raw, terms: intent ? [raw, ...intent.terms.filter((term) => term !== raw)] : [raw]})
142
+ }
143
+ return concepts
144
+ }
145
+
146
+ function conceptScore(g, node, concept) {
147
+ const id = normPath(node.id)
148
+ const label = String(node.label ?? '').toLowerCase()
149
+ const source = normPath(node.source_file)
150
+ const stem = (label.split('/').pop() || '').replace(/\.[^.]+$/, '')
151
+ const words = new Set(wordsOf(`${node.id} ${node.label ?? ''} ${node.source_file ?? ''}`))
152
+ const segments = new Set(source.split('/').flatMap((part) => wordsOf(part.replace(/\.[^.]+$/, ''))))
153
+ let match = 0
154
+ concept.terms.forEach((term, index) => {
155
+ const primary = index === 0
156
+ if (label === term || stem === term) match = Math.max(match, primary ? 60 : 42)
157
+ else if (segments.has(term)) match = Math.max(match, primary ? 48 : 36)
158
+ else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
159
+ else if (term.length >= 4 && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
160
+ })
161
+ if (!match) return 0
162
+ const fileNode = !isSymbol(node.id)
163
+ const depth = source ? source.split('/').length : 9
164
+ const entryBoost = concept.id === 'bootstrap' && /^(bootstrap|main|app|index|root)$/.test(stem) ? 10 : 0
165
+ return match + (fileNode ? 7 : 0) + Math.max(0, 4 - depth) + entryBoost + Math.min(2, degreeOf(g, node.id) / 40)
166
+ }
167
+
168
+ // Natural-language graph search keeps one strong candidate per concept before filling by aggregate
169
+ // score. This prevents a broad architecture question from spending every seed on one dense API area.
105
170
  export function findSeeds(g, query, limit = 8) {
106
- const q = String(query ?? '').trim().toLowerCase()
107
- if (!q) return []
108
- const tokens = q.split(/[^a-z0-9_]+/i).filter((t) => t.length > 1)
109
- const scored = []
110
- for (const n of g.nodes) {
111
- const hay = `${String(n.id)} ${String(n.label ?? '')} ${String(n.source_file ?? '')}`.toLowerCase()
112
- let score = 0
113
- if (hay.includes(q)) score += 5
114
- for (const t of tokens) if (hay.includes(t)) score += 1
115
- if (score > 0) scored.push({n, score: score + Math.min(3, degreeOf(g, n.id) / 20)})
171
+ const concepts = queryConcepts(query)
172
+ if (!concepts.length || limit <= 0) return []
173
+ const rows = g.nodes.map((node) => {
174
+ const scores = concepts.map((concept) => conceptScore(g, node, concept))
175
+ return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
176
+ })
177
+ const chosen = []
178
+ const used = new Set()
179
+ for (let index = 0; index < concepts.length && chosen.length < limit; index++) {
180
+ const best = rows.filter((row) => !used.has(String(row.node.id)) && row.scores[index] > 0)
181
+ .sort((a, b) => b.scores[index] - a.scores[index] || String(a.node.id).localeCompare(String(b.node.id)))[0]
182
+ if (best) { chosen.push(best.node); used.add(String(best.node.id)) }
183
+ }
184
+ rows.filter((row) => row.total > 0 && !used.has(String(row.node.id)))
185
+ .sort((a, b) => b.total - a.total || String(a.node.id).localeCompare(String(b.node.id)))
186
+ .slice(0, Math.max(0, limit - chosen.length))
187
+ .forEach((row) => chosen.push(row.node))
188
+ return chosen
189
+ }
190
+
191
+ export function resolveSeedFiles(g, requested, limit = 12) {
192
+ const files = Array.isArray(requested) ? requested.slice(0, limit) : []
193
+ const seeds = []
194
+ const missing = []
195
+ for (const raw of files) {
196
+ const wanted = normPath(raw)
197
+ const node = g.nodes.find((candidate) => !isSymbol(candidate.id)
198
+ && (normPath(candidate.id) === wanted || normPath(candidate.source_file) === wanted))
199
+ if (!node) missing.push(String(raw))
200
+ else if (!seeds.some((seed) => String(seed.id) === String(node.id))) seeds.push(node)
116
201
  }
117
- scored.sort((a, b) => b.score - a.score)
118
- return scored.slice(0, limit).map((s) => s.n)
202
+ return {seeds, missing}
119
203
  }
120
204
 
121
205
  // undirected adjacency for reachability (query/shortest path)
122
206
  export function undirectedNeighbors(g, id) {
123
207
  const seen = new Map()
124
- for (const e of g.out.get(id) || []) seen.set(e.id, e.relation)
125
- for (const e of g.inn.get(id) || []) if (!seen.has(e.id)) seen.set(e.id, e.relation)
208
+ for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
209
+ for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
126
210
  return seen
127
211
  }
128
212
 
@@ -13,25 +13,77 @@ export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
13
13
  export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
14
14
  const folderOfFile = folderModuleOf
15
15
 
16
+ const terminalLineSuffix = (id) => {
17
+ const raw = String(id)
18
+ return raw.includes('#') ? raw.replace(/@\d+$/, '') : raw
19
+ }
20
+
21
+ const sourceLine = (node) => {
22
+ const explicit = String(node?.source_location || '').match(/^L(\d+)/i)
23
+ if (explicit) return Number(explicit[1])
24
+ const suffix = String(node?.id || '').match(/@(\d+)$/)
25
+ return suffix ? Number(suffix[1]) : Number.MAX_SAFE_INTEGER
26
+ }
27
+
28
+ // Node ids retain source lines for precise read_source jumps, but location is not identity. This
29
+ // comparison-only index keeps file/name/kind/export/signature shape stable across line shifts and
30
+ // uses an ordinal only for true same-name overloads. Raw ids remain the user-facing evidence.
31
+ function stableNodeIndex(graph) {
32
+ const groups = new Map()
33
+ for (const node of graph.nodes || []) {
34
+ const raw = String(node?.id ?? '')
35
+ if (!raw) continue
36
+ const stem = terminalLineSuffix(raw)
37
+ const params = Number.isInteger(node?.complexity?.params) ? node.complexity.params : ''
38
+ const signature = [stem, node?.symbol_kind || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
39
+ if (!groups.has(signature)) groups.set(signature, [])
40
+ groups.get(signature).push(node)
41
+ }
42
+ const byRaw = new Map()
43
+ const rawByStable = new Map()
44
+ for (const [signature, nodes] of groups) {
45
+ nodes.sort((a, b) => sourceLine(a) - sourceLine(b) || String(a.id).localeCompare(String(b.id)))
46
+ nodes.forEach((node, ordinal) => {
47
+ const stable = `${signature}\u0000${ordinal}`
48
+ const raw = String(node.id)
49
+ byRaw.set(raw, stable)
50
+ rawByStable.set(stable, raw)
51
+ })
52
+ }
53
+ return {byRaw, rawByStable, keys: new Set(rawByStable.keys())}
54
+ }
55
+
16
56
  // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
17
57
  export function diffGraphs(oldG, newG) {
18
58
  const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
19
59
  const newEdgeTypesV = Number(newG.edgeTypesV) || 0
20
- const schemaMigration = oldEdgeTypesV !== newEdgeTypesV
21
- const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
22
- const oldNodes = nodeIds(oldG)
23
- const newNodes = nodeIds(newG)
60
+ const oldBarrelResolutionV = Number(oldG.barrelResolutionV) || 0
61
+ const newBarrelResolutionV = Number(newG.barrelResolutionV) || 0
62
+ const oldExtractorSchemaV = Number(oldG.extractorSchemaV) || 0
63
+ const newExtractorSchemaV = Number(newG.extractorSchemaV) || 0
64
+ const schemaChanges = [
65
+ oldEdgeTypesV !== newEdgeTypesV ? `edges v${oldEdgeTypesV}→v${newEdgeTypesV}` : null,
66
+ oldBarrelResolutionV !== newBarrelResolutionV ? `barrels v${oldBarrelResolutionV}→v${newBarrelResolutionV}` : null,
67
+ oldExtractorSchemaV !== newExtractorSchemaV ? `extractor v${oldExtractorSchemaV}→v${newExtractorSchemaV}` : null,
68
+ ].filter(Boolean)
69
+ const schemaMigration = schemaChanges.length > 0
70
+ const oldNodeIndex = stableNodeIndex(oldG)
71
+ const newNodeIndex = stableNodeIndex(newG)
24
72
 
25
73
  const edgeClass = (l) => l.typeOnly === true ? 'type' : l.compileOnly === true ? 'compile' : 'runtime'
26
- const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : edgeClass(l)}|${edgeEndpoint(l.target)}`
27
- const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
28
- const oldEdges = edgeSet(oldG)
29
- const newEdges = edgeSet(newG)
74
+ const stableEndpoint = (index, value) => {
75
+ const raw = edgeEndpoint(value)
76
+ return index.byRaw.get(raw) || raw
77
+ }
78
+ const edgeKey = (l, index) => `${stableEndpoint(index, l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : edgeClass(l)}|${stableEndpoint(index, l.target)}`
79
+ const edgeSet = (graph, index) => new Set((graph.links || []).map((l) => edgeKey(l, index)))
80
+ const oldEdges = edgeSet(oldG, oldNodeIndex)
81
+ const newEdges = edgeSet(newG, newNodeIndex)
30
82
 
31
83
  const moduleEdges = (graph, classification) => {
32
84
  const set = new Set()
33
85
  for (const l of graph.links || []) {
34
- if (isStructuralRelation(l.relation)) continue
86
+ if (isStructuralRelation(l.relation) || l.barrelProxy === true) continue
35
87
  if (edgeClass(l) !== classification) continue
36
88
  const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
37
89
  const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
@@ -48,17 +100,17 @@ export function diffGraphs(oldG, newG) {
48
100
  const oldCompileMods = schemaMigration ? new Set() : moduleEdges(oldG, 'compile')
49
101
  const newCompileMods = schemaMigration ? new Set() : moduleEdges(newG, 'compile')
50
102
 
51
- const incoming = (graph) => {
103
+ const incoming = (graph, index) => {
52
104
  const m = new Map()
53
105
  for (const l of graph.links || []) {
54
- if (isStructuralRelation(l.relation)) continue
55
- const t = edgeEndpoint(l.target)
106
+ if (isStructuralRelation(l.relation) || l.barrelProxy === true) continue
107
+ const t = stableEndpoint(index, l.target)
56
108
  m.set(t, (m.get(t) || 0) + 1)
57
109
  }
58
110
  return m
59
111
  }
60
- const oldIn = incoming(oldG)
61
- const newIn = incoming(newG)
112
+ const oldIn = incoming(oldG, oldNodeIndex)
113
+ const newIn = incoming(newG, newNodeIndex)
62
114
 
63
115
  const cycles = (graph, includeTypeOnly) => {
64
116
  try {
@@ -100,10 +152,10 @@ export function diffGraphs(oldG, newG) {
100
152
  }
101
153
 
102
154
  return {
103
- schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
155
+ schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV, changes: schemaChanges} : null,
104
156
  nodes: {
105
- added: [...newNodes].filter((id) => !oldNodes.has(id)),
106
- removed: [...oldNodes].filter((id) => !newNodes.has(id))
157
+ added: [...newNodeIndex.keys].filter((key) => !oldNodeIndex.keys.has(key)).map((key) => newNodeIndex.rawByStable.get(key)),
158
+ removed: [...oldNodeIndex.keys].filter((key) => !newNodeIndex.keys.has(key)).map((key) => oldNodeIndex.rawByStable.get(key))
107
159
  },
108
160
  edges: {
109
161
  added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
@@ -118,7 +170,9 @@ export function diffGraphs(oldG, newG) {
118
170
  compileRemoved: [...oldCompileMods].filter((k) => !newCompileMods.has(k)),
119
171
  },
120
172
  // survived the rebuild but lost every caller/importer — likely made dead by the change
121
- orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
173
+ orphaned: [...oldIn.keys()]
174
+ .filter((key) => newNodeIndex.keys.has(key) && !newIn.has(key))
175
+ .map((key) => newNodeIndex.rawByStable.get(key) || oldNodeIndex.rawByStable.get(key)),
122
176
  cycles: {
123
177
  runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
124
178
  // Backward-compatible field name: since edgeTypesV 2 this includes typeOnly and compileOnly.
@@ -130,12 +184,12 @@ export function diffGraphs(oldG, newG) {
130
184
  export function formatGraphDiff(d) {
131
185
  if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
132
186
  return d.schemaMigration
133
- ? `Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; compile-time baseline established. Runtime/compile-time cycle and module classifications are intentionally not compared on this rebuild.`
187
+ ? `Graph extractor/schema upgraded (${d.schemaMigration.changes.join(', ')}); compile-time baseline established. Runtime/compile-time cycle and module classifications are intentionally not compared on this rebuild.`
134
188
  : 'No structural change between the two graph states.'
135
189
  }
136
190
  const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
137
191
  const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
138
- if (d.schemaMigration) lines.push(`Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; runtime/compile-time cycle and module classifications are intentionally not compared until the next rebuild.`)
192
+ if (d.schemaMigration) lines.push(`Graph extractor/schema upgraded (${d.schemaMigration.changes.join(', ')}); runtime/compile-time cycle and module classifications are intentionally not compared until the next rebuild.`)
139
193
  const runtime = d.cycles?.runtime
140
194
  if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
141
195
  const changes = []
@@ -0,0 +1,20 @@
1
+ // Staleness is important, but repeating an identical paragraph after every graph call drowns the
2
+ // actual answer. Surface a changed condition immediately, then remind periodically. graph_stats can
3
+ // force the notice because freshness is the purpose of that tool.
4
+ export function createStalenessNoticeGate(cooldownMs = 5 * 60_000) {
5
+ let lastKey = ''
6
+ let lastShownAt = 0
7
+ return {
8
+ shouldShow({line, graphPath = '', force = false, now = Date.now()} = {}) {
9
+ if (!line) return false
10
+ const key = `${graphPath}\u0000${line}`
11
+ if (force || key !== lastKey || now - lastShownAt >= cooldownMs) {
12
+ lastKey = key
13
+ lastShownAt = now
14
+ return true
15
+ }
16
+ return false
17
+ },
18
+ reset() { lastKey = ''; lastShownAt = 0 },
19
+ }
20
+ }