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.
- package/README.md +168 -63
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +108 -39
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +13 -6
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +163 -0
- package/src/analysis/dep-check.js +69 -147
- package/src/analysis/dep-rules.js +47 -31
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints-rust.js +124 -0
- package/src/analysis/endpoints.js +18 -5
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +29 -28
- package/src/analysis/graph-analysis.edges.js +24 -0
- package/src/analysis/graph-analysis.js +1 -1
- package/src/analysis/graph-analysis.summaries.js +4 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +43 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +66 -13
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-java.js +177 -14
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/builder/lang-rust.js +129 -6
- package/src/graph/community.js +27 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +111 -16
- package/src/graph/internal-builder.java.js +33 -0
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/internal-builder.resolvers.js +109 -2
- package/src/graph/layout.js +21 -5
- package/src/graph/relations.js +4 -0
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +64 -21
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +106 -181
- package/src/mcp/graph-diff.mjs +217 -0
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +194 -8
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +114 -33
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph-hubs.mjs +58 -0
- package/src/mcp/tools-graph.mjs +36 -68
- package/src/mcp/tools-health.mjs +354 -20
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +35 -11
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- 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
|
-
//
|
|
61
|
-
//
|
|
62
|
-
export const connList = (list) => (list || []).filter((e) => e.relation !==
|
|
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
|
-
|
|
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
|
|
106
|
-
if (!
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|