weavatrix 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -39
- package/SECURITY.md +2 -2
- package/docs/releases/v0.2.15.md +37 -0
- package/package.json +2 -2
- package/skill/SKILL.md +57 -38
- package/src/analysis/architecture/contract-graph.js +119 -0
- package/src/analysis/architecture/contract-schema.js +110 -0
- package/src/analysis/architecture/contract-storage.js +35 -0
- package/src/analysis/architecture/contract-verification.js +168 -0
- package/src/analysis/architecture-contract.js +16 -343
- package/src/analysis/change-classification/diff-parser.js +103 -0
- package/src/analysis/change-classification/options.js +40 -0
- package/src/analysis/change-classification/symbol-classifier.js +177 -0
- package/src/analysis/change-classification.js +120 -519
- package/src/analysis/dead-code-review/policy.js +23 -0
- package/src/analysis/dead-code-review.js +9 -19
- package/src/analysis/dep-check.js +14 -71
- package/src/analysis/dep-rules.js +10 -303
- package/src/analysis/dependency/conventions.js +16 -0
- package/src/analysis/dependency/scoped-dependencies.js +45 -0
- package/src/analysis/dependency/source-references.js +21 -0
- package/src/analysis/duplicates.tokenize.js +12 -2
- package/src/analysis/endpoints/common.js +62 -0
- package/src/analysis/endpoints/extract.js +107 -0
- package/src/analysis/endpoints/inventory.js +84 -0
- package/src/analysis/endpoints/mounts.js +129 -0
- package/src/analysis/endpoints-java.js +80 -3
- package/src/analysis/endpoints.js +3 -448
- package/src/analysis/git-history/analytics.js +177 -0
- package/src/analysis/git-history/collector.js +109 -0
- package/src/analysis/git-history/options.js +68 -0
- package/src/analysis/git-history.js +128 -577
- package/src/analysis/health-capabilities.js +82 -0
- package/src/analysis/http-contracts/analysis.js +169 -0
- package/src/analysis/http-contracts/client-call-detection.js +81 -0
- package/src/analysis/http-contracts/client-call-parser.js +255 -0
- package/src/analysis/http-contracts/graph-context.js +143 -0
- package/src/analysis/http-contracts/matching.js +61 -0
- package/src/analysis/http-contracts/shared.js +86 -0
- package/src/analysis/http-contracts.js +6 -822
- package/src/analysis/internal-audit/dependency-health.js +111 -0
- package/src/analysis/internal-audit/python-manifests.js +65 -0
- package/src/analysis/internal-audit/repo-files.js +55 -0
- package/src/analysis/internal-audit/supply-chain.js +132 -0
- package/src/analysis/internal-audit.collect.js +5 -106
- package/src/analysis/internal-audit.reach.js +20 -0
- package/src/analysis/internal-audit.run.js +114 -200
- package/src/analysis/jvm-dependency-evidence.js +69 -0
- package/src/analysis/source-correctness/retry-patterns.js +93 -0
- package/src/analysis/source-correctness.js +225 -0
- package/src/analysis/structure/dependency-graph.js +156 -0
- package/src/analysis/structure/findings.js +142 -0
- package/src/analysis/task-retrieval.js +3 -14
- package/src/graph/builder/go-receiver-resolution.js +112 -0
- package/src/graph/builder/js/queries.js +48 -0
- package/src/graph/builder/lang-go.js +89 -4
- package/src/graph/builder/lang-js.js +4 -44
- package/src/graph/builder/pass2-resolution.js +68 -0
- package/src/graph/freshness-probe.js +2 -1
- package/src/graph/incremental-refresh.js +3 -2
- package/src/graph/internal-builder.build.js +22 -183
- package/src/graph/internal-builder.pass2.js +161 -0
- package/src/graph/internal-builder.resolvers.js +2 -105
- package/src/graph/resolvers/rust.js +117 -0
- package/src/mcp/actions/advisories.mjs +18 -0
- package/src/mcp/actions/graph-lifecycle.mjs +148 -0
- package/src/mcp/actions/graph-sync.mjs +195 -0
- package/src/mcp/actions/hosted-architecture.mjs +57 -0
- package/src/mcp/architecture-bootstrap.mjs +168 -0
- package/src/mcp/architecture-starter.mjs +234 -0
- package/src/mcp/catalog.mjs +20 -6
- package/src/mcp/evidence/bun-lock-graph.mjs +209 -0
- package/src/mcp/evidence/duplicate-member-order.mjs +8 -0
- package/src/mcp/evidence/package-graph-common.mjs +115 -0
- package/src/mcp/evidence/package-lock-graph.mjs +92 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +3 -7
- package/src/mcp/evidence-snapshot.package-graph.mjs +5 -474
- package/src/mcp/git-output.mjs +10 -0
- package/src/mcp/graph/context-core.mjs +111 -0
- package/src/mcp/graph/context-seeds.mjs +197 -0
- package/src/mcp/graph/context-state.mjs +86 -0
- package/src/mcp/graph/reverse-reach.mjs +42 -0
- package/src/mcp/graph/tools-core.mjs +143 -0
- package/src/mcp/graph/tools-query.mjs +223 -0
- package/src/mcp/graph-context.mjs +4 -496
- package/src/mcp/health/audit-format.mjs +172 -0
- package/src/mcp/health/audit.mjs +171 -0
- package/src/mcp/health/dead-code.mjs +110 -0
- package/src/mcp/health/duplicates.mjs +83 -0
- package/src/mcp/health/endpoints.mjs +42 -0
- package/src/mcp/health/structure.mjs +219 -0
- package/src/mcp/runtime-version.mjs +32 -0
- package/src/mcp/server/auto-refresh.mjs +66 -0
- package/src/mcp/server/runtime-config.mjs +43 -0
- package/src/mcp/server/shutdown.mjs +40 -0
- package/src/mcp/sync/evidence-architecture.mjs +54 -0
- package/src/mcp/sync/evidence-common.mjs +88 -0
- package/src/mcp/sync/evidence-duplicates.mjs +97 -0
- package/src/mcp/sync/evidence-health.mjs +56 -0
- package/src/mcp/sync/evidence-packages.mjs +95 -0
- package/src/mcp/sync/payload-common.mjs +97 -0
- package/src/mcp/sync/payload-v2.mjs +53 -0
- package/src/mcp/sync/payload-v3.mjs +79 -0
- package/src/mcp/sync-evidence.mjs +7 -402
- package/src/mcp/sync-payload.mjs +10 -244
- package/src/mcp/tools-actions.mjs +18 -414
- package/src/mcp/tools-architecture.mjs +18 -70
- package/src/mcp/tools-context.mjs +56 -15
- package/src/mcp/tools-endpoints.mjs +4 -1
- package/src/mcp/tools-graph.mjs +17 -331
- package/src/mcp/tools-health.mjs +24 -705
- package/src/mcp/tools-impact-change.mjs +2 -38
- package/src/mcp/tools-impact.mjs +2 -43
- package/src/mcp-server.mjs +35 -146
- package/src/path-classification.js +14 -0
- package/src/precision/lsp-client/constants.js +12 -0
- package/src/precision/lsp-client/environment.js +20 -0
- package/src/precision/lsp-client/errors.js +15 -0
- package/src/precision/lsp-client/lifecycle.js +127 -0
- package/src/precision/lsp-client/message-parser.js +81 -0
- package/src/precision/lsp-client/protocol.js +212 -0
- package/src/precision/lsp-client/registry.js +58 -0
- package/src/precision/lsp-client/repo-uri.js +60 -0
- package/src/precision/lsp-client/stdio-client.js +151 -0
- package/src/precision/lsp-client.js +10 -682
- package/src/precision/lsp-overlay/build.js +238 -0
- package/src/precision/lsp-overlay/contract.js +61 -0
- package/src/precision/lsp-overlay/reference-results.js +108 -0
- package/src/precision/lsp-overlay/semantic-inputs.js +62 -0
- package/src/precision/lsp-overlay/source-session.js +134 -0
- package/src/precision/lsp-overlay/store.js +150 -0
- package/src/precision/lsp-overlay/target-index.js +147 -0
- package/src/precision/lsp-overlay/target-query.js +55 -0
- package/src/precision/lsp-overlay.js +11 -868
- package/src/precision/typescript-lsp-provider.js +12 -682
- package/src/precision/typescript-provider/client.js +69 -0
- package/src/precision/typescript-provider/discovery.js +124 -0
- package/src/precision/typescript-provider/project-host.js +249 -0
- package/src/precision/typescript-provider/project-safety.js +161 -0
- package/src/precision/typescript-provider/reference-usage.js +53 -0
- package/src/security/malware-heuristics.sweep.js +1 -1
- package/src/security/registry-sig.classify.js +2 -1
- package/src/security/registry-sig.rules.js +3 -3
- package/src/version.js +7 -0
- package/docs/releases/v0.2.13.md +0 -48
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// Adaptive, source-free architecture starter. It proposes component territories and separately
|
|
2
|
+
// reports observed dependency directions; observations never become enforceable policy implicitly.
|
|
3
|
+
import {normalizeArchitectureContract} from '../analysis/architecture-contract.js'
|
|
4
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
5
|
+
|
|
6
|
+
export const PROVISIONAL_BUDGETS = Object.freeze({
|
|
7
|
+
runtimeCycles: 0,
|
|
8
|
+
maxFileLoc: 300,
|
|
9
|
+
maxFunctionLoc: 120,
|
|
10
|
+
maxCyclomatic: 15,
|
|
11
|
+
maxModuleFiles: 80,
|
|
12
|
+
minModuleCohesion: .5,
|
|
13
|
+
maxModuleBoundaryRatio: .65,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const STARTER_BUDGETS = Object.freeze({
|
|
17
|
+
runtimeCycles: PROVISIONAL_BUDGETS.runtimeCycles,
|
|
18
|
+
maxFileLoc: PROVISIONAL_BUDGETS.maxFileLoc,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const BUDGET_PROPOSALS = Object.freeze(Object.entries(PROVISIONAL_BUDGETS)
|
|
22
|
+
.filter(([key]) => !(key in STARTER_BUDGETS))
|
|
23
|
+
.map(([key, value]) => ({
|
|
24
|
+
key, value, state: 'CANDIDATE_NOT_ENFORCED',
|
|
25
|
+
reason: 'Generic review threshold; adopt only after inspecting repository-specific evidence.',
|
|
26
|
+
})))
|
|
27
|
+
|
|
28
|
+
const SOURCE_ROOTS = new Set(['src', 'app', 'lib', 'packages', 'services'])
|
|
29
|
+
const CODE_EXTENSIONS = new Set([
|
|
30
|
+
'.cjs', '.cs', '.css', '.go', '.htm', '.html', '.java', '.js', '.jsx', '.less', '.mjs', '.py', '.pyi',
|
|
31
|
+
'.rs', '.scss', '.ts', '.tsx',
|
|
32
|
+
])
|
|
33
|
+
const CLASSIFIED = ['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp']
|
|
34
|
+
const EDGE_RELATIONS = new Set(['imports', 're_exports', 'calls', 'references'])
|
|
35
|
+
|
|
36
|
+
const slash = (value) => String(value || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
37
|
+
const extension = (file) => {
|
|
38
|
+
const name = slash(file).split('/').at(-1) || ''
|
|
39
|
+
const dot = name.lastIndexOf('.')
|
|
40
|
+
return dot >= 0 ? name.slice(dot).toLowerCase() : ''
|
|
41
|
+
}
|
|
42
|
+
const safeId = (value, fallback) => String(value || '').toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || fallback
|
|
44
|
+
|
|
45
|
+
function productFiles(graph, repoRoot) {
|
|
46
|
+
const classifier = createPathClassifier(repoRoot)
|
|
47
|
+
return [...new Set((graph?.nodes || [])
|
|
48
|
+
.filter((node) => !String(node.id).includes('#'))
|
|
49
|
+
.map((node) => slash(node.source_file || node.id))
|
|
50
|
+
.filter((file) => file && CODE_EXTENSIONS.has(extension(file))))]
|
|
51
|
+
.filter((file) => {
|
|
52
|
+
const explanation = classifier.explain(file)
|
|
53
|
+
return !explanation.excluded && !hasPathClass(explanation, ...CLASSIFIED)
|
|
54
|
+
})
|
|
55
|
+
.sort((a, b) => a.localeCompare(b))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function commonDirectoryPrefix(entries) {
|
|
59
|
+
if (!entries.length) return []
|
|
60
|
+
const first = entries[0].directories
|
|
61
|
+
let length = first.length
|
|
62
|
+
for (const entry of entries.slice(1)) {
|
|
63
|
+
let index = 0
|
|
64
|
+
while (index < length && index < entry.directories.length && first[index] === entry.directories[index]) index++
|
|
65
|
+
length = index
|
|
66
|
+
}
|
|
67
|
+
// Keep at least one package segment available as an actual component territory.
|
|
68
|
+
const shortest = Math.min(...entries.map((entry) => entry.directories.length))
|
|
69
|
+
return first.slice(0, Math.min(length, Math.max(0, shortest - 1)))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function javaTerritories(files) {
|
|
73
|
+
const byRoot = new Map()
|
|
74
|
+
for (const file of files) {
|
|
75
|
+
const match = /^(.*?)(?:\/)?src\/main\/java\/(.+\.java)$/i.exec(file)
|
|
76
|
+
if (!match) continue
|
|
77
|
+
const root = `${match[1] ? `${match[1]}/` : ''}src/main/java`
|
|
78
|
+
const tail = match[2].split('/')
|
|
79
|
+
const entry = {file, root, directories: tail.slice(0, -1)}
|
|
80
|
+
const entries = byRoot.get(root) || []
|
|
81
|
+
entries.push(entry)
|
|
82
|
+
byRoot.set(root, entries)
|
|
83
|
+
}
|
|
84
|
+
const paths = new Map()
|
|
85
|
+
for (const [root, entries] of byRoot) {
|
|
86
|
+
const common = commonDirectoryPrefix(entries)
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const next = entry.directories[common.length]
|
|
89
|
+
const path = next ? `${root}/${[...common, next].join('/')}` : entry.file
|
|
90
|
+
paths.set(entry.file, {key: path, name: path, path})
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
let refined = true
|
|
94
|
+
while (refined) {
|
|
95
|
+
refined = false
|
|
96
|
+
const groups = new Map()
|
|
97
|
+
for (const [file, territory] of paths) {
|
|
98
|
+
const entries = groups.get(territory.key) || []
|
|
99
|
+
entries.push({file, territory})
|
|
100
|
+
groups.set(territory.key, entries)
|
|
101
|
+
}
|
|
102
|
+
for (const entries of groups.values()) {
|
|
103
|
+
if (entries.length <= PROVISIONAL_BUDGETS.maxModuleFiles) continue
|
|
104
|
+
const base = entries[0].territory.path
|
|
105
|
+
if (!entries.every((entry) => entry.territory.path === base)) continue
|
|
106
|
+
const nested = entries.map((entry) => ({...entry, rest: entry.file.slice(base.length + 1).split('/')}))
|
|
107
|
+
const childDirectories = new Set(nested.filter((entry) => entry.rest.length > 1).map((entry) => entry.rest[0]))
|
|
108
|
+
if (childDirectories.size < 2) continue
|
|
109
|
+
for (const entry of nested) {
|
|
110
|
+
if (entry.rest.length > 1) {
|
|
111
|
+
const path = `${base}/${entry.rest[0]}`
|
|
112
|
+
paths.set(entry.file, {key: path, name: path, path})
|
|
113
|
+
} else {
|
|
114
|
+
paths.set(entry.file, {key: `${base}::root`, name: `${base} (root files)`, path: entry.file})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
refined = true
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return paths
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nestedSourceTerritory(file) {
|
|
124
|
+
const match = /^(.*?)\/src\/(.+)$/.exec(file)
|
|
125
|
+
if (!match || !match[1]) return null
|
|
126
|
+
const tail = match[2].split('/')
|
|
127
|
+
// Java's src/main/java package tree is handled with a package-aware common prefix above.
|
|
128
|
+
if (tail[0] === 'main' && tail[1] === 'java') return null
|
|
129
|
+
return tail.length > 1 ? `${match[1]}/src/${tail[0]}` : file
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function genericTerritory(file) {
|
|
133
|
+
const parts = file.split('/').filter(Boolean)
|
|
134
|
+
if (parts.length === 1) return {key: 'root-code', name: 'root code', path: file}
|
|
135
|
+
if (SOURCE_ROOTS.has(parts[0]) && parts.length === 2) {
|
|
136
|
+
return {key: `${parts[0]}-root`, name: `${parts[0]} (root files)`, path: file}
|
|
137
|
+
}
|
|
138
|
+
const path = parts.slice(0, SOURCE_ROOTS.has(parts[0]) ? 2 : 1).join('/')
|
|
139
|
+
return {key: path, name: path, path}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function componentsFor(graph, repoRoot) {
|
|
143
|
+
const files = productFiles(graph, repoRoot)
|
|
144
|
+
const java = javaTerritories(files)
|
|
145
|
+
const groups = new Map()
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const adaptive = java.get(file) || nestedSourceTerritory(file)
|
|
148
|
+
const territory = adaptive
|
|
149
|
+
? typeof adaptive === 'string' ? {key: adaptive, name: adaptive, path: adaptive} : adaptive
|
|
150
|
+
: genericTerritory(file)
|
|
151
|
+
const group = groups.get(territory.key) || {name: territory.name, paths: new Set(), files: 0}
|
|
152
|
+
group.paths.add(territory.path)
|
|
153
|
+
group.files++
|
|
154
|
+
groups.set(territory.key, group)
|
|
155
|
+
}
|
|
156
|
+
return [...groups]
|
|
157
|
+
.sort((a, b) => b[1].files - a[1].files || a[0].localeCompare(b[0]))
|
|
158
|
+
.slice(0, 200)
|
|
159
|
+
.map(([key, group], index) => ({
|
|
160
|
+
id: safeId(key, `component-${index + 1}`),
|
|
161
|
+
name: group.name,
|
|
162
|
+
paths: [...group.paths].sort((a, b) => a.localeCompare(b)),
|
|
163
|
+
}))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function componentFor(file, components) {
|
|
167
|
+
const normalized = slash(file)
|
|
168
|
+
let best = null
|
|
169
|
+
for (const component of components) for (const prefix of component.paths) {
|
|
170
|
+
if (normalized !== prefix && !normalized.startsWith(`${prefix}/`)) continue
|
|
171
|
+
if (!best || prefix.length > best.prefix.length) best = {id: component.id, prefix}
|
|
172
|
+
}
|
|
173
|
+
return best?.id || null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const endpoint = (value) => value && typeof value === 'object' ? String(value.id) : String(value || '')
|
|
177
|
+
function fileOf(id, byId) {
|
|
178
|
+
const node = byId.get(id)
|
|
179
|
+
return slash(node?.source_file || id.split('#')[0])
|
|
180
|
+
}
|
|
181
|
+
function edgeKind(link) {
|
|
182
|
+
return link.typeOnly === true ? 'type-only' : link.compileOnly === true ? 'compile-only' : 'runtime'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function observedDirections(graph, components) {
|
|
186
|
+
const byId = new Map((graph?.nodes || []).map((node) => [String(node.id), node]))
|
|
187
|
+
const pairs = new Set(), directions = new Map()
|
|
188
|
+
for (const link of graph?.links || []) {
|
|
189
|
+
if (!EDGE_RELATIONS.has(link.relation) || link.barrelProxy === true) continue
|
|
190
|
+
const fromFile = fileOf(endpoint(link.source), byId), toFile = fileOf(endpoint(link.target), byId)
|
|
191
|
+
const from = componentFor(fromFile, components), to = componentFor(toFile, components)
|
|
192
|
+
if (!from || !to || from === to) continue
|
|
193
|
+
const kind = edgeKind(link), pair = `${fromFile}\0${toFile}\0${kind}`
|
|
194
|
+
if (pairs.has(pair)) continue
|
|
195
|
+
pairs.add(pair)
|
|
196
|
+
const key = `${from}\0${to}\0${kind}`
|
|
197
|
+
const item = directions.get(key) || {from, to, kind, count: 0, samples: []}
|
|
198
|
+
item.count++
|
|
199
|
+
if (item.samples.length < 3) item.samples.push(`${fromFile} -> ${toFile}`)
|
|
200
|
+
directions.set(key, item)
|
|
201
|
+
}
|
|
202
|
+
return [...directions.values()]
|
|
203
|
+
.sort((a, b) => b.count - a.count || a.from.localeCompare(b.from) || a.to.localeCompare(b.to))
|
|
204
|
+
.slice(0, 100)
|
|
205
|
+
.map((item, index) => ({
|
|
206
|
+
...item,
|
|
207
|
+
state: 'OBSERVED_NOT_ENFORCED',
|
|
208
|
+
suggestedRule: {
|
|
209
|
+
id: safeId(`observed-${item.from}-to-${item.to}-${item.kind}`, `observed-${index + 1}`),
|
|
210
|
+
action: 'allow', kinds: [item.kind], from: [item.from], to: [item.to],
|
|
211
|
+
reason: `Observed in ${item.count} unique file dependency pair(s); review before adopting as policy.`,
|
|
212
|
+
},
|
|
213
|
+
}))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function proposeObservedDependencyDirections(graph, components) {
|
|
217
|
+
return observedDirections(graph, components)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createArchitectureStarter(graph, repoRoot) {
|
|
221
|
+
const components = componentsFor(graph, repoRoot)
|
|
222
|
+
const contract = normalizeArchitectureContract({
|
|
223
|
+
name: 'Proposed no-regressions baseline', style: 'custom', enforcement: 'ratchet', components,
|
|
224
|
+
dependencyRules: [], budgets: STARTER_BUDGETS,
|
|
225
|
+
technologies: {required: [], forbidden: []}, exceptions: [],
|
|
226
|
+
ratchet: {baseline: {fingerprints: [], metrics: {}}},
|
|
227
|
+
})
|
|
228
|
+
return {
|
|
229
|
+
contract,
|
|
230
|
+
observedDependencyProposals: proposeObservedDependencyDirections(graph, contract.components),
|
|
231
|
+
budgetProposals: BUDGET_PROPOSALS,
|
|
232
|
+
methodology: 'adaptive product territories; only universal runtime-cycle/file-size guards are active; observed directions and generic quality thresholds are review-only',
|
|
233
|
+
}
|
|
234
|
+
}
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -26,14 +26,28 @@ const PROFILE_CAPS = Object.freeze({
|
|
|
26
26
|
|
|
27
27
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
28
28
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
29
|
-
|
|
29
|
+
const HOT_FACADES = [
|
|
30
|
+
'tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs',
|
|
31
|
+
'tools-context.mjs', 'tools-endpoints.mjs', 'tools-actions.mjs',
|
|
32
|
+
'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs',
|
|
33
|
+
'tools-verified-change.mjs',
|
|
34
|
+
]
|
|
35
|
+
const HOT_OWNERS = [
|
|
36
|
+
'graph/tools-core.mjs', 'graph/tools-query.mjs', 'tools-graph-hubs.mjs',
|
|
37
|
+
'health/duplicates.mjs', 'health/dead-code.mjs', 'health/audit-format.mjs',
|
|
38
|
+
'health/audit.mjs', 'health/structure.mjs', 'health/endpoints.mjs',
|
|
39
|
+
'actions/graph-lifecycle.mjs', 'actions/advisories.mjs',
|
|
40
|
+
'actions/hosted-architecture.mjs', 'actions/graph-sync.mjs',
|
|
41
|
+
'architecture-starter.mjs', 'architecture-bootstrap.mjs',
|
|
42
|
+
]
|
|
43
|
+
export const HOT_FILES = [...HOT_FACADES, ...HOT_OWNERS, 'catalog.mjs']
|
|
30
44
|
|
|
31
45
|
function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
32
46
|
const tools = [
|
|
33
47
|
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
|
|
34
48
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
35
49
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
36
|
-
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned;
|
|
50
|
+
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept or exact symbols (BFS/DFS). Exact seed files/symbols stay pinned; relation_filter and flow_direction support bounded event/data-flow views without a separate tool. Classified paths and unreferenced constant/field leaves stay suppressed unless explicitly requested.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Optional natural-language question or keyword search when exact seeds are not sufficient'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact repo-relative file paths. Resolved exact seeds remain pinned unless augment_seeds is true'}, seed_symbols: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Exact node IDs or unambiguous symbol labels; enables focused flows without fuzzy query seeds'}, relation_filter: {oneOf: [{type: 'array', items: {type: 'string'}}, {type: 'string'}], description: 'Optional relation allow-list, e.g. calls,references,imports'}, flow_direction: {type: 'string', enum: ['forward', 'backward', 'both'], default: 'both', description: 'Traverse outgoing, incoming, or both directions'}, augment_seeds: {type: 'boolean', default: false, description: 'With exact seeds, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, anyOf: [{required: ['question']}, {required: ['seed_files']}, {required: ['seed_symbols']}]}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
37
51
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
38
52
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
39
53
|
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
@@ -105,19 +119,19 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
|
|
|
105
119
|
{cap: 'search', name: 'search_code', description: 'Full-text or regex search across the repo source (ripgrep-backed, Node fallback). The graph only stores structure — use this to find literal text/patterns, then get_node/get_neighbors for structure.', inputSchema: {type: 'object', properties: {query: {type: 'string', description: 'text or regex to search for'}, is_regex: {type: 'boolean', default: false}, glob: {type: 'string', description: 'optional path glob, e.g. "*.js" or "src/**"'}, max_results: {type: 'integer', default: 40}}, required: ['query']}, run: (g, a, ctx) => searchCode({repoRoot: ctx.repoRoot, resolveRg}, a)},
|
|
106
120
|
{cap: 'source', name: 'read_source', description: "Read the actual source of a node (by label/ID) or a repo-relative file path — the symbol's lines with context. The graph stores only locations, not source text. For a path read, pass start_line to anchor the window anywhere in the file (otherwise it shows the head).", inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'node label or ID'}, path: {type: 'string', description: 'or a repo-relative file path'}, start_line: {type: 'integer', description: 'anchor line: window = start_line-before .. start_line+after'}, before: {type: 'integer', default: 3}, after: {type: 'integer', default: 40}}}, run: (g, a, ctx) => readSource({repoRoot: ctx.repoRoot, resolveNode, isSymbol}, g, a)},
|
|
107
121
|
{cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
|
|
108
|
-
{cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition,
|
|
122
|
+
{cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, production-first inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and diverse excerpts around call sites. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp callers after production callers'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
|
|
109
123
|
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. Tests, classified non-product paths, and all-router framework boilerplate clone groups are excluded by default; opt them in explicitly.", inputSchema: {type: 'object', properties: {min_similarity: {type: 'integer', description: '50-100, default 80 (ignored in semantic mode)'}, min_tokens: {type: 'integer', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; default 50'}, mode: {type: 'string', enum: ['renamed', 'strict', 'semantic'], default: 'renamed'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, include_boilerplate: {type: 'boolean', default: false, description: 'Include clone groups made entirely of conventional *.router.js/ts router symbols'}, include_strings: {type: 'boolean', description: 'Also clone-check large multi-line string literals', default: false}, top_n: {type: 'integer', default: 15}}}, run: (g, a, ctx) => th.tFindDuplicates(g, a, ctx)},
|
|
110
124
|
{cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
|
|
111
|
-
{cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review
|
|
125
|
+
{cap: 'health', name: 'run_audit', description: 'Core production-first repository Health review with an explicit capability/completeness matrix for structure, dependencies, bounded runtime-correctness/concurrency patterns, advisories, malware and coverage. Unsupported Maven/Gradle import verification is NOT_SUPPORTED/PARTIAL, never a clean zero. Findings whose evidence is entirely test/e2e/generated/mock/story/docs/benchmark/temp or explicitly excluded are suppressed by default; opt them in with include_classified. category=dependencies is a dedicated dependency-health projection across missing, unused and duplicate declarations while preserving each finding\'s native category. With base_ref, builds and audits an immutable Git checkout and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['dependencies', 'unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category; dependencies selects dependency manifest/import findings across native categories'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_classified: {type: 'boolean', default: false, description: 'Include findings whose evidence is entirely tests/e2e/generated/mocks/stories/docs/benchmarks/temp or explicitly excluded'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
|
|
112
126
|
{cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
|
|
113
127
|
{cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
|
|
114
128
|
{cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
|
|
115
129
|
{cap: 'graph', name: 'module_map', description: 'First orientation view for understanding an unfamiliar application with little context: a production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
|
|
116
|
-
{cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler,
|
|
130
|
+
{cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): declared and reachable composed paths, static mount provenance, confidence, handler, file:line, and Spring conditional/default-active state.', inputSchema: {type: 'object', properties: {method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, path: {type: 'string', maxLength: 2048, description: 'Optional exact composed path or segment-aligned suffix'}, max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
117
131
|
{cap: 'source', refreshGraph: true, name: 'trace_endpoint', description: 'Resolve one exact reachable HTTP endpoint, prove its router mount chain, bind its handler symbol, and return a bounded production-only multi-hop call graph with call-site excerpts. This is a focused projection of the repository graph, not text-search inference.', inputSchema: {type: 'object', properties: {path: {type: 'string', minLength: 1, maxLength: 2048, description: 'Exact composed path; a suffix is accepted only when unambiguous'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'ALL', 'ANY']}, max_depth: {type: 'integer', minimum: 1, maximum: 4, default: 3}, max_nodes: {type: 'integer', minimum: 2, maximum: 40, default: 20}, max_excerpts: {type: 'integer', minimum: 0, maximum: 12, default: 6}, context_lines: {type: 'integer', minimum: 0, maximum: 6, default: 2}, include_classified: {type: 'boolean', default: false, description: 'Include test/e2e/generated/mock/story/docs/benchmark/temp and explicitly excluded targets'}}, required: ['path']}, run: (g, a, ctx) => te.tTraceEndpoint(g, a, ctx)},
|
|
118
132
|
{cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. Omitted mode/precision preserve the active graph; a first build uses full and the startup precision setting (lsp unless WEAVATRIX_PRECISION=off). The local TypeScript/JavaScript LSP overlay validates bounded ambiguous edges; precision:off is an explicit fallback. With scope, build an isolated diagnostic graph without replacing or diffing the full graph.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], description: 'Build mode; omit to preserve the active mode (or use full for a first build)'}, precision: {type: 'string', enum: ['lsp', 'off'], description: 'Semantic precision; omit to preserve the active mode (or use the startup setting for a first build)'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
|
|
119
133
|
{cap: 'graph', name: 'graph_diff', description: 'Structural graph diff: compare the current graph with an immutable Git-ref baseline (base_ref such as HEAD~1 or main), or with graph.prev.json from the last rebuild when base_ref is omitted. Reports architecture drift, cycle changes and symbols that lost their last caller.', inputSchema: {type: 'object', properties: {base_ref: {type: 'string', maxLength: 256, description: 'Optional immutable Git baseline to build in isolation, e.g. HEAD~1, main or origin/main; never checks out or mutates the working tree'}, path: {type: 'string', description: 'Optional node-id/path prefix to scope the diff, e.g. src/query'}}}, run: (g, a, ctx) => ti.tGraphDiff(g, a, ctx)},
|
|
120
|
-
{cap: 'graph', name: 'get_architecture_contract', description: '
|
|
134
|
+
{cap: 'graph', name: 'get_architecture_contract', description: 'Read the owner-approved architecture target or safely bootstrap one. With action=preview, returns an adaptive candidate, observed-but-not-enforced dependency directions, verification, exact file content/hash and a short-lived confirmation token. action=approve creates the local contract only after explicit token confirmation and never overwrites an active target.', inputSchema: {type: 'object', properties: {action: {type: 'string', enum: ['preview', 'approve'], description: 'Omit to read; preview is dry-run only; approve requires the preview token'}, candidate_contract: {type: 'object', description: 'Optional reviewed candidate to normalize and verify during preview'}, baseline_mode: {type: 'string', enum: ['none', 'accept-current'], default: 'none', description: 'Whether preview should materialize current violations as an explicit ratchet baseline'}, confirm_token: {type: 'string', description: 'One-time token returned by preview; required for approve'}}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
|
|
121
135
|
{cap: 'graph', name: 'prepare_change', description: 'Select active target-architecture rules for an intended set of changed files. Run before a non-trivial edit.', inputSchema: {type: 'object', properties: {intent: {type: 'string'}, files: {type: 'array', items: {type: 'string'}, maxItems: 200}}, required: ['files']}, run: (g, a, ctx) => tar.tPrepareChange(g, a, ctx)},
|
|
122
136
|
{cap: 'health', name: 'verify_architecture', description: 'Verify the fresh graph against the active target contract and ratchet; separates new, existing, fixed and excepted debt.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tVerifyArchitecture(g, a, ctx)},
|
|
123
137
|
{cap: 'health', name: 'explain_architecture_violation', description: 'Explain one active architecture violation and the governing rule.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}}, required: ['fingerprint']}, run: (g, a, ctx) => tar.tExplainArchitectureViolation(g, a, ctx)},
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import {compareText, safeToken, STATE} from '../evidence-snapshot.common.mjs'
|
|
2
|
+
import {
|
|
3
|
+
MAX_DEPENDENCY_DECLARATIONS, MAX_PACKAGE_RECORDS, PACKAGE_NAME,
|
|
4
|
+
dependencyDeclarations, emptyDeclarations, emptyGraph, finalizeGraph,
|
|
5
|
+
packageId, packageVersion,
|
|
6
|
+
} from './package-graph-common.mjs'
|
|
7
|
+
|
|
8
|
+
// Bun >= 1.2 writes JSONC: JSON plus comments and trailing commas.
|
|
9
|
+
export function parseJsonc(text) {
|
|
10
|
+
const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
|
|
11
|
+
const out = []
|
|
12
|
+
let i = 0
|
|
13
|
+
const length = source.length
|
|
14
|
+
const skipComment = (index) => {
|
|
15
|
+
if (source[index] !== '/') return index
|
|
16
|
+
if (source[index + 1] === '/') {
|
|
17
|
+
let cursor = index + 2
|
|
18
|
+
while (cursor < length && source[cursor] !== '\n') cursor++
|
|
19
|
+
return cursor
|
|
20
|
+
}
|
|
21
|
+
if (source[index + 1] === '*') {
|
|
22
|
+
let cursor = index + 2
|
|
23
|
+
while (cursor < length && !(source[cursor] === '*' && source[cursor + 1] === '/')) cursor++
|
|
24
|
+
return Math.min(cursor + 2, length)
|
|
25
|
+
}
|
|
26
|
+
return index
|
|
27
|
+
}
|
|
28
|
+
while (i < length) {
|
|
29
|
+
const char = source[i]
|
|
30
|
+
if (char === '"') {
|
|
31
|
+
const start = i
|
|
32
|
+
i++
|
|
33
|
+
while (i < length && source[i] !== '"') i += source[i] === '\\' ? 2 : 1
|
|
34
|
+
i = Math.min(i + 1, length)
|
|
35
|
+
out.push(source.slice(start, i))
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (char === '/') {
|
|
39
|
+
const next = skipComment(i)
|
|
40
|
+
if (next !== i) { i = next; continue }
|
|
41
|
+
}
|
|
42
|
+
if (char === ',') {
|
|
43
|
+
let ahead = i + 1
|
|
44
|
+
while (ahead < length) {
|
|
45
|
+
if (/\s/.test(source[ahead])) { ahead++; continue }
|
|
46
|
+
const next = skipComment(ahead)
|
|
47
|
+
if (next !== ahead) { ahead = next; continue }
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
if (ahead < length && (source[ahead] === '}' || source[ahead] === ']')) { i++; continue }
|
|
51
|
+
}
|
|
52
|
+
out.push(char)
|
|
53
|
+
i++
|
|
54
|
+
}
|
|
55
|
+
return JSON.parse(out.join(''))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bunKeySegments(key) {
|
|
59
|
+
if (typeof key !== 'string' || key.length === 0 || key.length > 4096 || /[\u0000-\u001f\u007f]/.test(key)) return null
|
|
60
|
+
const parts = key.split('/')
|
|
61
|
+
const segments = []
|
|
62
|
+
for (let index = 0; index < parts.length; index++) {
|
|
63
|
+
let segment = parts[index]
|
|
64
|
+
if (segment.startsWith('@')) {
|
|
65
|
+
if (index + 1 >= parts.length) return null
|
|
66
|
+
segment = `${segment}/${parts[++index]}`
|
|
67
|
+
}
|
|
68
|
+
if (segment.length > 256 || !PACKAGE_NAME.test(segment)) return null
|
|
69
|
+
segments.push(segment)
|
|
70
|
+
}
|
|
71
|
+
return segments
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function bunPackageRecord(entry) {
|
|
75
|
+
if (!Array.isArray(entry) || typeof entry[0] !== 'string' || entry[0].length > 1024) return null
|
|
76
|
+
const at = entry[0].lastIndexOf('@')
|
|
77
|
+
if (at <= 0) return null
|
|
78
|
+
const name = entry[0].slice(0, at)
|
|
79
|
+
if (name.length > 256 || !PACKAGE_NAME.test(name)) return null
|
|
80
|
+
const rawVersion = entry[0].slice(at + 1)
|
|
81
|
+
const meta = entry.slice(1).find((value) => value && typeof value === 'object' && !Array.isArray(value)) || {}
|
|
82
|
+
return {name, rawVersion, workspace: rawVersion.startsWith('workspace:'), meta}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function bunDeclarationRecord(meta) {
|
|
86
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}
|
|
87
|
+
const peerDependenciesMeta = {}
|
|
88
|
+
if (Array.isArray(meta.optionalPeers)) {
|
|
89
|
+
for (const name of meta.optionalPeers) if (typeof name === 'string') peerDependenciesMeta[name] = {optional: true}
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
dependencies: meta.dependencies,
|
|
93
|
+
devDependencies: meta.devDependencies,
|
|
94
|
+
optionalDependencies: meta.optionalDependencies,
|
|
95
|
+
peerDependencies: meta.peerDependencies,
|
|
96
|
+
peerDependenciesMeta,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveBunDependency(sourceSegments, name, records) {
|
|
101
|
+
for (let depth = sourceSegments.length; depth >= 0; depth--) {
|
|
102
|
+
const key = [...sourceSegments.slice(0, depth), name].join('/')
|
|
103
|
+
const record = records.get(key)
|
|
104
|
+
if (record) return {key, record}
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function applyBunReachabilityFlags(nodes, edges) {
|
|
110
|
+
const adjacency = new Map()
|
|
111
|
+
for (const edge of edges) {
|
|
112
|
+
if (!adjacency.has(edge.from)) adjacency.set(edge.from, [])
|
|
113
|
+
adjacency.get(edge.from).push(edge)
|
|
114
|
+
}
|
|
115
|
+
const reach = (excluded) => {
|
|
116
|
+
const seen = new Set(['(root)'])
|
|
117
|
+
const queue = ['(root)']
|
|
118
|
+
while (queue.length) {
|
|
119
|
+
for (const edge of adjacency.get(queue.pop()) || []) {
|
|
120
|
+
if (excluded.has(edge.kind) || seen.has(edge.to)) continue
|
|
121
|
+
seen.add(edge.to)
|
|
122
|
+
queue.push(edge.to)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return seen
|
|
126
|
+
}
|
|
127
|
+
const withoutDev = reach(new Set(['dev']))
|
|
128
|
+
const withoutOptional = reach(new Set(['optional', 'optional-peer']))
|
|
129
|
+
const withoutPeer = reach(new Set(['peer', 'optional-peer']))
|
|
130
|
+
for (const node of nodes) {
|
|
131
|
+
node.dev = !withoutDev.has(node.id)
|
|
132
|
+
node.optional = !withoutOptional.has(node.id)
|
|
133
|
+
node.peer = !withoutPeer.has(node.id)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function parseBunLock(lock, lockfile) {
|
|
138
|
+
const lockfileVersion = Number(lock?.lockfileVersion)
|
|
139
|
+
const extras = {lockfile, lockfileVersion: Number.isFinite(lockfileVersion) ? Math.trunc(lockfileVersion) : 0}
|
|
140
|
+
if (!lock?.packages || typeof lock.packages !== 'object' || Array.isArray(lock.packages)) {
|
|
141
|
+
return emptyGraph(STATE.PARTIAL, 'BUN_LOCK_PACKAGES_REQUIRED', extras)
|
|
142
|
+
}
|
|
143
|
+
const allKeys = Object.keys(lock.packages).sort(compareText)
|
|
144
|
+
const selectedKeys = allKeys.slice(0, MAX_PACKAGE_RECORDS)
|
|
145
|
+
const reasons = []
|
|
146
|
+
if (selectedKeys.length < allKeys.length) reasons.push('LOCKFILE_PACKAGE_RECORD_LIMIT_REACHED')
|
|
147
|
+
const records = new Map()
|
|
148
|
+
let invalidRecords = 0
|
|
149
|
+
for (const key of selectedKeys) {
|
|
150
|
+
const segments = bunKeySegments(key)
|
|
151
|
+
const record = segments ? bunPackageRecord(lock.packages[key]) : null
|
|
152
|
+
if (!record) { invalidRecords++; continue }
|
|
153
|
+
records.set(key, {...record, segments})
|
|
154
|
+
}
|
|
155
|
+
if (invalidRecords > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_RECORDS')
|
|
156
|
+
const externalNodes = new Map()
|
|
157
|
+
let invalidPackageVersions = 0
|
|
158
|
+
for (const [key, record] of records) {
|
|
159
|
+
if (record.workspace) continue
|
|
160
|
+
const version = packageVersion(record.rawVersion)
|
|
161
|
+
if (!version) { invalidPackageVersions++; continue }
|
|
162
|
+
externalNodes.set(key, {
|
|
163
|
+
id: packageId(record.name, version, key), name: record.name, version,
|
|
164
|
+
direct: false, dev: false, optional: false, peer: false,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (invalidPackageVersions > 0) reasons.push('INVALID_LOCKFILE_PACKAGE_VERSIONS')
|
|
168
|
+
const workspaces = lock?.workspaces && typeof lock.workspaces === 'object' && !Array.isArray(lock.workspaces)
|
|
169
|
+
? lock.workspaces : null
|
|
170
|
+
if (!workspaces) reasons.push('BUN_LOCK_WORKSPACES_MISSING')
|
|
171
|
+
const sources = []
|
|
172
|
+
for (const path of Object.keys(workspaces || {}).sort(compareText)) {
|
|
173
|
+
const meta = workspaces[path]
|
|
174
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) continue
|
|
175
|
+
const workspaceName = path === '' ? null : safeToken(meta.name)
|
|
176
|
+
const segments = workspaceName && records.has(workspaceName) ? [workspaceName] : []
|
|
177
|
+
sources.push({sourceId: '(root)', segments, declarationRecord: bunDeclarationRecord(meta)})
|
|
178
|
+
}
|
|
179
|
+
for (const [key, record] of [...records].sort(([a], [b]) => compareText(a, b))) {
|
|
180
|
+
const node = externalNodes.get(key)
|
|
181
|
+
if (node) sources.push({sourceId: node.id, segments: record.segments, declarationRecord: bunDeclarationRecord(record.meta)})
|
|
182
|
+
}
|
|
183
|
+
const edgeMap = new Map()
|
|
184
|
+
const declarations = emptyDeclarations()
|
|
185
|
+
let declarationLimitReached = false
|
|
186
|
+
outer: for (const source of sources) {
|
|
187
|
+
for (const declaration of dependencyDeclarations(source.declarationRecord)) {
|
|
188
|
+
if (declarations.total >= MAX_DEPENDENCY_DECLARATIONS) { declarationLimitReached = true; break outer }
|
|
189
|
+
declarations.total++
|
|
190
|
+
const resolved = resolveBunDependency(source.segments, declaration.name, records)
|
|
191
|
+
if (resolved?.record.workspace) { declarations.local++; continue }
|
|
192
|
+
const node = resolved ? externalNodes.get(resolved.key) : null
|
|
193
|
+
if (!node) {
|
|
194
|
+
if (declaration.kind === 'optional' || declaration.kind === 'optional-peer') declarations.optionalMissing++
|
|
195
|
+
else declarations.unresolved++
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
declarations.resolved++
|
|
199
|
+
if (source.sourceId === '(root)') node.direct = true
|
|
200
|
+
if (source.sourceId === node.id) continue
|
|
201
|
+
const edge = {from: source.sourceId, to: node.id, kind: declaration.kind}
|
|
202
|
+
edgeMap.set(`${edge.from}\0${edge.to}\0${edge.kind}`, edge)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (declarationLimitReached) reasons.push('LOCKFILE_DEPENDENCY_DECLARATION_LIMIT_REACHED')
|
|
206
|
+
if (declarations.unresolved > 0) reasons.push('UNRESOLVED_LOCKFILE_DEPENDENCIES')
|
|
207
|
+
applyBunReachabilityFlags(externalNodes.values(), edgeMap.values())
|
|
208
|
+
return finalizeGraph({externalNodes, edgeMap, declarations, reasons, lockfile, lockfileVersion: extras.lockfileVersion})
|
|
209
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
const compareText = (left, right) => String(left).localeCompare(String(right), 'en')
|
|
2
|
+
|
|
3
|
+
// Snapshot construction and hosted-payload validation must use the same stable
|
|
4
|
+
// member order or equivalent evidence can hash differently across the boundary.
|
|
5
|
+
export function compareDuplicateMember(left, right) {
|
|
6
|
+
return compareText(left.file, right.file) || left.startLine - right.startLine ||
|
|
7
|
+
left.endLine - right.endLine || compareText(left.graphNodeId || '', right.graphNodeId || '')
|
|
8
|
+
}
|