weavatrix 0.1.3 → 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 (78) hide show
  1. package/README.md +168 -63
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +108 -39
  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 +13 -6
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +163 -0
  12. package/src/analysis/dep-check.js +69 -147
  13. package/src/analysis/dep-rules.js +47 -31
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints-rust.js +124 -0
  17. package/src/analysis/endpoints.js +18 -5
  18. package/src/analysis/findings.js +8 -1
  19. package/src/analysis/git-history.js +549 -0
  20. package/src/analysis/git-ref-graph.js +74 -0
  21. package/src/analysis/graph-analysis.aggregate.js +29 -28
  22. package/src/analysis/graph-analysis.edges.js +24 -0
  23. package/src/analysis/graph-analysis.js +1 -1
  24. package/src/analysis/graph-analysis.summaries.js +4 -1
  25. package/src/analysis/http-contracts.js +581 -0
  26. package/src/analysis/internal-audit.collect.js +43 -2
  27. package/src/analysis/internal-audit.reach.js +52 -1
  28. package/src/analysis/internal-audit.run.js +66 -13
  29. package/src/analysis/java-source.js +36 -0
  30. package/src/analysis/package-reachability.js +39 -0
  31. package/src/analysis/static-test-reachability.js +133 -0
  32. package/src/build-graph.js +72 -13
  33. package/src/graph/build-worker.js +3 -4
  34. package/src/graph/builder/lang-java.js +177 -14
  35. package/src/graph/builder/lang-js.js +71 -12
  36. package/src/graph/builder/lang-rust.js +129 -6
  37. package/src/graph/community.js +27 -0
  38. package/src/graph/file-lock.js +69 -0
  39. package/src/graph/freshness-probe.js +141 -0
  40. package/src/graph/graph-filter.js +28 -11
  41. package/src/graph/incremental-refresh.js +232 -0
  42. package/src/graph/internal-builder.barrels.js +169 -0
  43. package/src/graph/internal-builder.build.js +111 -16
  44. package/src/graph/internal-builder.java.js +33 -0
  45. package/src/graph/internal-builder.langs.js +2 -1
  46. package/src/graph/internal-builder.resolvers.js +109 -2
  47. package/src/graph/layout.js +21 -5
  48. package/src/graph/relations.js +4 -0
  49. package/src/graph/repo-registry.js +124 -0
  50. package/src/mcp/catalog.mjs +64 -21
  51. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  52. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  53. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  54. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  55. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  56. package/src/mcp/evidence-snapshot.mjs +50 -0
  57. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  58. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  59. package/src/mcp/graph-context.mjs +106 -181
  60. package/src/mcp/graph-diff.mjs +217 -0
  61. package/src/mcp/staleness-notice.mjs +20 -0
  62. package/src/mcp/sync-evidence.mjs +418 -0
  63. package/src/mcp/sync-payload.mjs +194 -8
  64. package/src/mcp/tool-result.mjs +51 -0
  65. package/src/mcp/tools-actions.mjs +114 -33
  66. package/src/mcp/tools-architecture.mjs +144 -0
  67. package/src/mcp/tools-company.mjs +273 -0
  68. package/src/mcp/tools-graph-hubs.mjs +58 -0
  69. package/src/mcp/tools-graph.mjs +36 -68
  70. package/src/mcp/tools-health.mjs +354 -20
  71. package/src/mcp/tools-history.mjs +22 -0
  72. package/src/mcp/tools-impact-change.mjs +261 -0
  73. package/src/mcp/tools-impact.mjs +35 -11
  74. package/src/mcp-server.mjs +100 -17
  75. package/src/mcp-source-tools.mjs +11 -3
  76. package/src/path-classification.js +201 -0
  77. package/src/path-ignore.js +69 -0
  78. 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
+ }
@@ -6,8 +6,8 @@ import {readFileSync, statSync} from 'node:fs'
6
6
  import {join} from 'node:path'
7
7
  import {spawnSync} from 'node:child_process'
8
8
  import {childProcessEnv} from '../child-env.js'
9
- import {buildFileImportGraph, findSccs} from '../analysis/dep-rules.js'
10
9
  import {resolveRepoPath} from '../repo-path.js'
10
+ import {isStructuralRelation} from '../graph/relations.js'
11
11
 
12
12
  // ---- graph load + indexes -----------------------------------------------------------------------
13
13
  export function loadGraph(path) {
@@ -37,8 +37,12 @@ export function loadGraph(path) {
37
37
  relation: e.relation,
38
38
  confidence: e.confidence,
39
39
  ...(e.typeOnly === true ? {typeOnly: true} : {}),
40
+ ...(e.compileOnly === true ? {compileOnly: true} : {}),
40
41
  ...(Number.isInteger(e.line) ? {line: e.line} : {}),
41
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} : {}),
42
46
  }
43
47
  push(out, s, {id: t, ...metadata})
44
48
  push(inn, t, {id: s, ...metadata})
@@ -47,19 +51,31 @@ export function loadGraph(path) {
47
51
  nodes, links, byId, byLabel, out, inn,
48
52
  repoBoundaryV: Number(raw.repoBoundaryV) || 0,
49
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,
50
66
  }
51
67
  }
52
68
 
53
69
  export const isSymbol = (id) => String(id).includes('#')
54
- export const degreeOf = (g, id) => (g.out.get(id)?.length || 0) + (g.inn.get(id)?.length || 0)
55
70
  export const labelOf = (g, id) => {
56
71
  const n = g.byId.get(String(id))
57
72
  return n ? String(n.label ?? n.id) : String(id)
58
73
  }
59
74
 
60
- // "connectivity" degree ignores structural `contains` (parent→symbol nesting) so god_nodes surfaces real
61
- // call/import/reference hubs, not just files that hold many symbols.
62
- export const connList = (list) => (list || []).filter((e) => e.relation !== 'contains')
75
+ // Connectivity ignores structural file/symbol and class/method ownership, so runtime/compile-time
76
+ // dependency ranks never treat nesting as a call or import.
77
+ export const connList = (list) => (list || []).filter((e) => !isStructuralRelation(e.relation) && e.barrelProxy !== true)
78
+ export const degreeOf = (g, id) => connList(g.out.get(id)).length + connList(g.inn.get(id)).length
63
79
  export const uniqueConnCount = (list) => new Set(connList(list).map((e) => String(e.id))).size
64
80
 
65
81
  // Resolve a user-supplied "label" to a node: exact id → exact label → ci label → substring (best degree).
@@ -100,28 +116,97 @@ export function ambiguityNote(query, info) {
100
116
  const bestByDegree = (g, list) =>
101
117
  list.reduce((best, n) => (degreeOf(g, n.id) > degreeOf(g, best.id) ? n : best), list[0])
102
118
 
103
- // 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.
104
170
  export function findSeeds(g, query, limit = 8) {
105
- const q = String(query ?? '').trim().toLowerCase()
106
- if (!q) return []
107
- const tokens = q.split(/[^a-z0-9_]+/i).filter((t) => t.length > 1)
108
- const scored = []
109
- for (const n of g.nodes) {
110
- const hay = `${String(n.id)} ${String(n.label ?? '')} ${String(n.source_file ?? '')}`.toLowerCase()
111
- let score = 0
112
- if (hay.includes(q)) score += 5
113
- for (const t of tokens) if (hay.includes(t)) score += 1
114
- 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)
115
201
  }
116
- scored.sort((a, b) => b.score - a.score)
117
- return scored.slice(0, limit).map((s) => s.n)
202
+ return {seeds, missing}
118
203
  }
119
204
 
120
205
  // undirected adjacency for reachability (query/shortest path)
121
206
  export function undirectedNeighbors(g, id) {
122
207
  const seen = new Map()
123
- for (const e of g.out.get(id) || []) seen.set(e.id, e.relation)
124
- 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)
125
210
  return seen
126
211
  }
127
212
 
@@ -203,164 +288,4 @@ export function rawGraph(ctx) {
203
288
  return rawGraphCache.data
204
289
  }
205
290
 
206
- // ---- graph diff ----------------------------------------------------------------------------------
207
- // One previous state is enough (4-13 MB per repo — cheap): rebuild_graph snapshots the outgoing
208
- // graph.json as graph.prev.json and reports the structural delta inline, at the exact moment the fix
209
- // is being verified. graph_diff re-queries the same pair later. Raw node/edge dumps would be noise —
210
- // the signal is aggregated: module-dependency drift, cycle count changes, newly orphaned symbols.
211
- export const prevGraphPathFor = (graphPath) => String(graphPath).replace(/\.json$/, '.prev.json')
212
- export const edgeEndpoint = (v) => String(v && typeof v === 'object' ? v.id : v)
213
- export const fileOfId = (id) => { const s = String(id); const h = s.indexOf('#'); return h < 0 ? s : s.slice(0, h) }
214
- const folderOfFile = (file) => {
215
- const dirs = String(file || '').split(/[\\/]/).filter(Boolean).slice(0, -1)
216
- return dirs.length ? dirs.slice(0, 2).join('/') : '(root)'
217
- }
218
-
219
- // Works on anything with {nodes, links}: the raw graph.json shape and the loadGraph struct alike.
220
- export function diffGraphs(oldG, newG) {
221
- const oldEdgeTypesV = Number(oldG.edgeTypesV) || 0
222
- const newEdgeTypesV = Number(newG.edgeTypesV) || 0
223
- const schemaMigration = oldEdgeTypesV !== newEdgeTypesV
224
- const nodeIds = (graph) => new Set((graph.nodes || []).map((n) => String(n.id)))
225
- const oldNodes = nodeIds(oldG)
226
- const newNodes = nodeIds(newG)
227
-
228
- const edgeKey = (l) => `${edgeEndpoint(l.source)}|${l.relation || ''}|${schemaMigration ? 'untyped' : l.typeOnly === true ? 'type' : 'runtime'}|${edgeEndpoint(l.target)}`
229
- const edgeSet = (graph) => new Set((graph.links || []).map(edgeKey))
230
- const oldEdges = edgeSet(oldG)
231
- const newEdges = edgeSet(newG)
232
-
233
- const moduleEdges = (graph, typeOnly) => {
234
- const set = new Set()
235
- for (const l of graph.links || []) {
236
- if (l.relation === 'contains') continue
237
- if ((l.typeOnly === true) !== typeOnly) continue
238
- const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
239
- const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
240
- if (a !== b) set.add(`${a} → ${b}`)
241
- }
242
- return set
243
- }
244
- const combinedModuleEdges = (graph) => {
245
- const set = new Set()
246
- for (const l of graph.links || []) {
247
- if (l.relation === 'contains') continue
248
- const a = folderOfFile(fileOfId(edgeEndpoint(l.source)))
249
- const b = folderOfFile(fileOfId(edgeEndpoint(l.target)))
250
- if (a !== b) set.add(`${a} → ${b}`)
251
- }
252
- return set
253
- }
254
- const oldMods = schemaMigration ? combinedModuleEdges(oldG) : moduleEdges(oldG, false)
255
- const newMods = schemaMigration ? combinedModuleEdges(newG) : moduleEdges(newG, false)
256
- const oldTypeMods = schemaMigration ? new Set() : moduleEdges(oldG, true)
257
- const newTypeMods = schemaMigration ? new Set() : moduleEdges(newG, true)
258
-
259
- const incoming = (graph) => {
260
- const m = new Map()
261
- for (const l of graph.links || []) {
262
- if (l.relation === 'contains') continue
263
- const t = edgeEndpoint(l.target)
264
- m.set(t, (m.get(t) || 0) + 1)
265
- }
266
- return m
267
- }
268
- const oldIn = incoming(oldG)
269
- const newIn = incoming(newG)
270
-
271
- const cycles = (graph, includeTypeOnly) => {
272
- try {
273
- const sccs = findSccs(buildFileImportGraph(graph, {includeTypeOnly}).adj)
274
- .map((members) => members.map(String).sort())
275
- .sort((a, b) => b.length - a.length || a.join('\n').localeCompare(b.join('\n')))
276
- return {
277
- count: sccs.length,
278
- largest: sccs[0]?.length || 0,
279
- groups: sccs,
280
- }
281
- } catch {
282
- return null
283
- }
284
- }
285
- const cycleDelta = (before, after) => {
286
- if (!before || !after) return null
287
- const key = (group) => group.join('|')
288
- const beforeKeys = new Set(before.groups.map(key))
289
- const afterKeys = new Set(after.groups.map(key))
290
- const overlap = (a, b) => {
291
- const bSet = new Set(b)
292
- return a.reduce((n, member) => n + (bSet.has(member) ? 1 : 0), 0)
293
- }
294
- const unmatchedBefore = before.groups.filter((group) => !afterKeys.has(key(group)))
295
- const unmatchedAfter = after.groups.filter((group) => !beforeKeys.has(key(group)))
296
- const changed = unmatchedAfter.filter((group) => unmatchedBefore.some((old) => overlap(group, old) >= 2))
297
- const introduced = unmatchedAfter.filter((group) => !unmatchedBefore.some((old) => overlap(group, old) >= 2))
298
- const resolved = unmatchedBefore.filter((group) => !unmatchedAfter.some((next) => overlap(group, next) >= 2))
299
- return {
300
- before: before.count,
301
- after: after.count,
302
- largestBefore: before.largest,
303
- largestAfter: after.largest,
304
- introduced: introduced.map(key),
305
- resolved: resolved.map(key),
306
- membershipChanged: changed.length,
307
- }
308
- }
309
-
310
- return {
311
- schemaMigration: schemaMigration ? {from: oldEdgeTypesV, to: newEdgeTypesV} : null,
312
- nodes: {
313
- added: [...newNodes].filter((id) => !oldNodes.has(id)),
314
- removed: [...oldNodes].filter((id) => !newNodes.has(id))
315
- },
316
- edges: {
317
- added: [...newEdges].filter((k) => !oldEdges.has(k)).length,
318
- removed: [...oldEdges].filter((k) => !newEdges.has(k)).length
319
- },
320
- moduleEdges: {
321
- added: [...newMods].filter((k) => !oldMods.has(k)),
322
- removed: [...oldMods].filter((k) => !newMods.has(k)),
323
- typeAdded: [...newTypeMods].filter((k) => !oldTypeMods.has(k)),
324
- typeRemoved: [...oldTypeMods].filter((k) => !newTypeMods.has(k)),
325
- },
326
- // survived the rebuild but lost every caller/importer — likely made dead by the change
327
- orphaned: [...oldIn.keys()].filter((id) => newNodes.has(id) && !newIn.has(id)),
328
- cycles: {
329
- runtime: schemaMigration ? null : cycleDelta(cycles(oldG, false), cycles(newG, false)),
330
- typeInclusive: schemaMigration ? null : cycleDelta(cycles(oldG, true), cycles(newG, true)),
331
- }
332
- }
333
- }
334
-
335
- export function formatGraphDiff(d) {
336
- if (!d.nodes.added.length && !d.nodes.removed.length && !d.edges.added && !d.edges.removed) {
337
- return d.schemaMigration
338
- ? `Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; typed baseline established. Runtime/type cycle and module classifications are intentionally not compared on this rebuild.`
339
- : 'No structural change between the two graph states.'
340
- }
341
- const cap = (list, n) => list.slice(0, n).map((x) => ` ${x}`).concat(list.length > n ? [` … +${list.length - n} more`] : [])
342
- const lines = [`Structural delta: nodes +${d.nodes.added.length}/−${d.nodes.removed.length}, edges +${d.edges.added}/−${d.edges.removed}.`]
343
- if (d.schemaMigration) lines.push(`Graph edge schema upgraded v${d.schemaMigration.from} → v${d.schemaMigration.to}; runtime/type cycle and module classifications are intentionally not compared until the next rebuild.`)
344
- const runtime = d.cycles?.runtime
345
- if (runtime && (runtime.before !== runtime.after || runtime.largestBefore !== runtime.largestAfter || runtime.introduced.length || runtime.resolved.length || runtime.membershipChanged)) {
346
- const changes = []
347
- if (runtime.introduced.length) changes.push(`${runtime.introduced.length} genuinely new runtime SCC(s) — review`)
348
- if (runtime.resolved.length) changes.push(`${runtime.resolved.length} runtime SCC(s) resolved`)
349
- if (runtime.membershipChanged) changes.push(`${runtime.membershipChanged} SCC membership change(s)`)
350
- const verdict = changes.length ? `; ${changes.join('; ')}` : ''
351
- lines.push(`Runtime import cycles: count ${runtime.before} → ${runtime.after}, largest SCC ${runtime.largestBefore} → ${runtime.largestAfter}${verdict}.`)
352
- }
353
- const all = d.cycles?.typeInclusive
354
- if (all && (all.before !== all.after || all.largestBefore !== all.largestAfter) &&
355
- (!runtime || all.before !== runtime.before || all.after !== runtime.after || all.largestBefore !== runtime.largestBefore || all.largestAfter !== runtime.largestAfter)) {
356
- lines.push(`Type-inclusive dependency SCCs: count ${all.before} → ${all.after}, largest ${all.largestBefore} → ${all.largestAfter} (compile-time coupling, not necessarily a runtime cycle).`)
357
- }
358
- if (d.moduleEdges.added.length) lines.push('NEW module dependencies (architecture drift — review):', ...cap(d.moduleEdges.added, 12))
359
- if (d.moduleEdges.removed.length) lines.push('Removed module dependencies (decoupling confirmed):', ...cap(d.moduleEdges.removed, 12))
360
- if (d.moduleEdges.typeAdded.length) lines.push('New type-only module dependencies (compile-time coupling):', ...cap(d.moduleEdges.typeAdded, 12))
361
- if (d.moduleEdges.typeRemoved.length) lines.push('Removed type-only module dependencies:', ...cap(d.moduleEdges.typeRemoved, 12))
362
- if (d.orphaned.length) lines.push('Symbols that lost their last caller/importer (now dead?):', ...cap(d.orphaned, 10))
363
- if (d.nodes.added.length) lines.push('Added nodes:', ...cap(d.nodes.added, 12))
364
- if (d.nodes.removed.length) lines.push('Removed nodes:', ...cap(d.nodes.removed, 12))
365
- return lines.join('\n')
366
- }
291
+ export {prevGraphPathFor, edgeEndpoint, fileOfId, diffGraphs, formatGraphDiff} from './graph-diff.mjs'