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.
- package/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- 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 +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- 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 +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- 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-js.js +71 -12
- package/src/graph/community.js +10 -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 +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- 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 +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
107
|
-
if (!
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
package/src/mcp/graph-diff.mjs
CHANGED
|
@@ -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
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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: [...
|
|
106
|
-
removed: [...
|
|
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()]
|
|
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
|
|
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
|
|
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
|
+
}
|