weavatrix 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +156 -53
  2. package/SECURITY.md +25 -8
  3. package/package.json +2 -2
  4. package/skill/SKILL.md +102 -31
  5. package/src/analysis/architecture-contract.js +343 -0
  6. package/src/analysis/audit-debt.js +94 -0
  7. package/src/analysis/change-classification.js +534 -0
  8. package/src/analysis/cycle-route.js +14 -0
  9. package/src/analysis/dead-check.js +5 -3
  10. package/src/analysis/dead-code-review.js +245 -0
  11. package/src/analysis/dep-check-ecosystems.js +6 -0
  12. package/src/analysis/dep-check.js +40 -6
  13. package/src/analysis/dep-rules.js +12 -9
  14. package/src/analysis/duplicates.compute.js +47 -7
  15. package/src/analysis/endpoints-java.js +186 -0
  16. package/src/analysis/endpoints.js +8 -2
  17. package/src/analysis/findings.js +8 -1
  18. package/src/analysis/git-history.js +591 -0
  19. package/src/analysis/git-ref-graph.js +74 -0
  20. package/src/analysis/graph-analysis.aggregate.js +2 -2
  21. package/src/analysis/graph-analysis.edges.js +3 -0
  22. package/src/analysis/graph-analysis.summaries.js +1 -1
  23. package/src/analysis/http-contracts.js +633 -0
  24. package/src/analysis/internal-audit.collect.js +3 -2
  25. package/src/analysis/internal-audit.reach.js +52 -1
  26. package/src/analysis/internal-audit.run.js +58 -10
  27. package/src/analysis/java-source.js +36 -0
  28. package/src/analysis/package-reachability.js +39 -0
  29. package/src/analysis/static-test-reachability.js +133 -0
  30. package/src/build-graph.js +72 -13
  31. package/src/graph/build-worker.js +3 -4
  32. package/src/graph/builder/lang-js.js +71 -12
  33. package/src/graph/community.js +10 -0
  34. package/src/graph/file-lock.js +69 -0
  35. package/src/graph/freshness-probe.js +141 -0
  36. package/src/graph/graph-filter.js +28 -11
  37. package/src/graph/incremental-refresh.js +232 -0
  38. package/src/graph/internal-builder.barrels.js +169 -0
  39. package/src/graph/internal-builder.build.js +82 -11
  40. package/src/graph/internal-builder.langs.js +2 -1
  41. package/src/graph/layout.js +21 -5
  42. package/src/graph/repo-registry.js +124 -0
  43. package/src/mcp/catalog.mjs +64 -20
  44. package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
  45. package/src/mcp/evidence-snapshot.common.mjs +160 -0
  46. package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
  47. package/src/mcp/evidence-snapshot.health.mjs +95 -0
  48. package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
  49. package/src/mcp/evidence-snapshot.mjs +50 -0
  50. package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
  51. package/src/mcp/evidence-snapshot.structure.mjs +81 -0
  52. package/src/mcp/graph-context.mjs +100 -16
  53. package/src/mcp/graph-diff.mjs +74 -20
  54. package/src/mcp/staleness-notice.mjs +20 -0
  55. package/src/mcp/sync-evidence.mjs +418 -0
  56. package/src/mcp/sync-payload.mjs +164 -0
  57. package/src/mcp/tool-result.mjs +54 -0
  58. package/src/mcp/tools-actions.mjs +111 -30
  59. package/src/mcp/tools-architecture.mjs +200 -0
  60. package/src/mcp/tools-company.mjs +273 -0
  61. package/src/mcp/tools-graph-hubs.mjs +22 -3
  62. package/src/mcp/tools-graph.mjs +25 -14
  63. package/src/mcp/tools-health.mjs +336 -14
  64. package/src/mcp/tools-history.mjs +28 -0
  65. package/src/mcp/tools-impact-change.mjs +261 -0
  66. package/src/mcp/tools-impact.mjs +25 -6
  67. package/src/mcp-server.mjs +102 -17
  68. package/src/mcp-source-tools.mjs +11 -3
  69. package/src/path-classification.js +206 -0
  70. package/src/path-ignore.js +69 -0
  71. package/src/security/typosquat.js +1 -1
@@ -1,29 +1,56 @@
1
1
  // Action tools: rebuild the graph, the explicit 'retarget' group, and the explicit 'online' group
2
2
  // (the ONLY tools that ever touch the network).
3
3
  // Hot-reloadable (re-imported by catalog.mjs on change).
4
- import {readFileSync, writeFileSync, existsSync, readdirSync, statSync, realpathSync} from 'node:fs'
5
- import {join, dirname, isAbsolute} from 'node:path'
6
- import {prevGraphPathFor, diffGraphs, formatGraphDiff} from './graph-context.mjs'
4
+ import {readFileSync, writeFileSync, existsSync, statSync, realpathSync} from 'node:fs'
5
+ import {dirname, join, isAbsolute} from 'node:path'
6
+ import {prevGraphPathFor, diffGraphs, formatGraphDiff, graphStaleness} from './graph-context.mjs'
7
7
  import {buildGraphForRepo} from '../build-graph.js'
8
- import {graphOutDirForRepo} from '../graph/layout.js'
8
+ import {graphHomeDir, graphOutDirForModule, graphOutDirForRepo} from '../graph/layout.js'
9
+ import {liveRepositoryRecords, registerRepository, repositoryRecord} from '../graph/repo-registry.js'
9
10
  import {refreshAdvisories, storeMeta, DEFAULT_STORE} from '../security/advisory-store.js'
10
11
  import {collectInstalled} from '../security/installed.js'
11
- import {createSyncPayload} from './sync-payload.mjs'
12
+ import {createSyncPayload, createSyncPayloadV3, MAX_SYNC_BODY_BYTES} from './sync-payload.mjs'
13
+ import {createEvidenceSnapshot} from './evidence-snapshot.mjs'
14
+ import {writeCachedArchitectureContract} from '../analysis/architecture-contract.js'
15
+
16
+ const MAX_SYNC_GRAPH_FILE_BYTES = 64 * 1024 * 1024
17
+
18
+ function syncRepoLabel(repoRoot) {
19
+ const basename = String(repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
20
+ const safe = basename.normalize('NFKC').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '')
21
+ return (safe || 'repo').slice(0, 128)
22
+ }
12
23
 
13
24
  export async function tRebuildGraph(g, args, ctx) {
14
25
  if (!ctx.repoRoot) return 'Rebuild needs the repo root (not provided to this server).'
15
26
  const mode = ['no-tests', 'tests-only', 'full'].includes(args.mode) ? args.mode : 'full'
27
+ const scope = String(args.scope || '').replace(/\\/g, '/').replace(/^\/+|\/+$/g, '')
28
+ if (scope) {
29
+ const scopedDir = graphOutDirForModule(ctx.repoRoot, scope)
30
+ const scoped = await buildGraphForRepo(ctx.repoRoot, {mode, scope, outDir: scopedDir})
31
+ if (!scoped || !scoped.ok) return `Scoped graph build failed: ${(scoped && scoped.error) || 'unknown error'}`
32
+ return [
33
+ `Built an isolated scoped graph for ${scope} (${mode}). ${scoped.log || ''}.`,
34
+ `The active full-repository graph was not replaced, so this operation cannot report a false full→scope structural delta.`,
35
+ `Scoped graph: ${join(scopedDir, 'graph.json')}`,
36
+ ].join('\n')
37
+ }
16
38
  // snapshot the outgoing state: bytes → graph.prev.json (for graph_diff later), struct → inline delta
17
39
  let prevBytes = null
18
40
  try { prevBytes = readFileSync(ctx.graphPath) } catch { /* first build — nothing to diff against */ }
19
- const before = g?.nodes ? {nodes: g.nodes, links: g.links, edgeTypesV: g.edgeTypesV || 0} : null
20
- const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: args.scope || ''})
41
+ const before = g?.nodes ? {
42
+ nodes: g.nodes, links: g.links,
43
+ edgeTypesV: g.edgeTypesV || 0,
44
+ barrelResolutionV: g.barrelResolutionV || 0,
45
+ extractorSchemaV: g.extractorSchemaV || 0,
46
+ } : null
47
+ const res = await buildGraphForRepo(ctx.repoRoot, {mode, scope: '', outDir: ctx.graphPath ? dirname(ctx.graphPath) : undefined})
21
48
  if (!res || !res.ok) return `Graph rebuild failed: ${(res && res.error) || 'unknown error'}`
22
49
  if (prevBytes) { try { writeFileSync(prevGraphPathFor(ctx.graphPath), prevBytes) } catch { /* snapshot is best-effort */ } }
23
50
  const fresh = ctx.reload() // refresh THIS server's in-memory graph so subsequent tool calls see the new graph
24
51
  const delta = before && fresh ? formatGraphDiff(diffGraphs(before, fresh)) : null
25
52
  return [
26
- `Rebuilt the graph (${mode}${args.scope ? `, scope=${args.scope}` : ''}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
53
+ `Rebuilt the graph (${mode}). ${res.log || ''}. In-memory graph reloaded — graph tools now reflect it.`,
27
54
  delta
28
55
  ].filter(Boolean).join('\n\n')
29
56
  }
@@ -71,31 +98,23 @@ export async function tOpenRepo(g, args, ctx) {
71
98
  ctx.reload()
72
99
  return `Failed to load ${graphPath} — still targeting the previous repo (${prev.repoRoot || 'none'}).`
73
100
  }
101
+ registerRepository({repoPath, graphDir: graphOutDirForRepo(repoPath), graphHome: graphHomeDir()})
74
102
  const buildNote = built ? (upgrade ? ' (graph upgraded to edge metadata v2)' : ' (graph built fresh)') : ''
75
103
  return `Opened ${repoPath}${buildNote}: ${loaded.nodes.length} nodes / ${loaded.links.length} edges. All tools now target this repo.`
76
104
  }
77
105
 
78
106
  // Sibling repos that already have a built graph in the central weavatrix-graphs folder — open_repo candidates.
79
107
  export function tListKnownRepos(g, args, ctx) {
80
- if (!ctx.repoRoot) return 'No repo root cannot locate the central graphs folder.'
81
- const parent = dirname(ctx.repoRoot)
82
- const root = join(parent, 'weavatrix-graphs')
108
+ const root = graphHomeDir()
83
109
  const norm = (p) => String(p).replace(/[\\/]+/g, '/').toLowerCase()
84
- let entries = []
85
- try { entries = readdirSync(root, {withFileTypes: true}) } catch { return `No central graphs folder at ${root}.` }
86
- const rows = []
87
- for (const entry of entries) {
88
- if (!entry.isDirectory()) continue
89
- const graphPath = join(root, entry.name, 'graph.json')
90
- try {
91
- const st = statSync(graphPath)
92
- rows.push({name: entry.name, repoPath: join(parent, entry.name), builtAt: st.mtime.toISOString()})
93
- } catch { /* no graph built for this entry */ }
94
- }
95
- if (!rows.length) return `No built graphs under ${root}.`
110
+ const rows = liveRepositoryRecords(root).map((record) => ({
111
+ ...record,
112
+ builtAt: statSync(join(record.graphDir, 'graph.json')).mtime.toISOString(),
113
+ }))
114
+ if (!rows.length) return `No registered graphs under ${root}. Build a repository once with open_repo or rebuild_graph.`
96
115
  return [
97
- `Repos with built graphs under ${root} (switch with open_repo):`,
98
- ...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.name} — graph built ${r.builtAt} (${r.repoPath})`),
116
+ `Known repositories (${rows.length}) in ${root}:`,
117
+ ...rows.map((r) => ` ${norm(r.repoPath) === norm(ctx.repoRoot) ? '»' : ' '} ${r.label} [${r.repositoryId}] — graph built ${r.builtAt} (${r.repoPath})`),
99
118
  ].join('\n')
100
119
  }
101
120
 
@@ -120,6 +139,32 @@ export async function tRefreshAdvisories(g, args, ctx) {
120
139
  ].filter(Boolean).join('\n')
121
140
  }
122
141
 
142
+ export async function tPullArchitectureContract(g, args, ctx) {
143
+ if (!ctx.repoRoot || !ctx.graphPath) return 'No active repository graph — open_repo first.'
144
+ const syncUrl = process.env.WEAVATRIX_SYNC_URL
145
+ const token = process.env.WEAVATRIX_SYNC_TOKEN
146
+ if (!syncUrl || !token) return 'Hosted architecture pull is not configured. Use the hosted profile with WEAVATRIX_SYNC_URL and WEAVATRIX_SYNC_TOKEN, or keep .weavatrix/architecture.json locally.'
147
+ let url
148
+ try { url = process.env.WEAVATRIX_ARCHITECTURE_URL || new URL('/api/v1/architecture-contract', syncUrl).toString() }
149
+ catch { return 'WEAVATRIX_SYNC_URL is invalid.' }
150
+ const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
151
+ || registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
152
+ const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
153
+ try {
154
+ const res = await fetch(url, {
155
+ headers: {authorization: `Bearer ${token}`, 'x-weavatrix-repository-id': registry.repositoryId},
156
+ signal: AbortSignal.timeout(timeoutMs),
157
+ })
158
+ const body = await res.json().catch(() => null)
159
+ if (!res.ok) return `Hosted architecture endpoint answered HTTP ${res.status}; the local contract cache was not changed.`
160
+ if (body?.state === 'NOT_CONFIGURED' || !body?.contract) return 'Hosted target architecture is NOT_CONFIGURED. Define and save it in the Architecture editor first.'
161
+ const stored = writeCachedArchitectureContract(ctx.graphPath, body.contract)
162
+ return `Pulled target architecture ${stored.contract.name} (${stored.contract.style}, ${stored.contract.enforcement}) into the local graph cache. get_architecture_contract and verify_architecture now use it.`
163
+ } catch (error) {
164
+ return `Hosted architecture pull failed: ${error.message}; the previous local contract, if any, remains active.`
165
+ }
166
+ }
167
+
123
168
  // Push the current graph.json to a user-configured endpoint. Off until WEAVATRIX_SYNC_URL is set.
124
169
  // The payload is graph metadata (paths, symbols/ranges, imports, edges, metrics), never file contents.
125
170
  export async function tSyncGraph(g, args, ctx) {
@@ -130,13 +175,38 @@ export async function tSyncGraph(g, args, ctx) {
130
175
  }
131
176
  if (!g) return 'No graph loaded — build one first (open_repo / rebuild_graph).'
132
177
  let raw
133
- try { raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8')) } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
178
+ try {
179
+ const size = statSync(ctx.graphPath).size
180
+ if (size > MAX_SYNC_GRAPH_FILE_BYTES) {
181
+ return `Cannot sync: graph.json is ${Math.ceil(size / 1024 / 1024)} MB; the local safety limit is ${MAX_SYNC_GRAPH_FILE_BYTES / 1024 / 1024} MB.`
182
+ }
183
+ raw = JSON.parse(readFileSync(ctx.graphPath, 'utf8'))
184
+ } catch (e) { return `Cannot read ${ctx.graphPath}: ${e.message}` }
185
+ const requestedVersion = Number(args.payload_version) === 2 ? 2 : 3
134
186
  let payload
135
- try { payload = createSyncPayload(raw) } catch (e) {
187
+ try {
188
+ if (requestedVersion === 2) {
189
+ payload = createSyncPayload(raw)
190
+ } else {
191
+ if (!ctx.repoRoot) return 'Cannot build evidence: no repository root is active.'
192
+ if (graphStaleness(ctx).stale) {
193
+ return 'Cannot sync evidence from a stale graph. Run rebuild_graph, then call sync_graph again.'
194
+ }
195
+ const evidence = await createEvidenceSnapshot({repoRoot: ctx.repoRoot, graph: raw})
196
+ payload = createSyncPayloadV3(raw, evidence)
197
+ }
198
+ } catch (e) {
136
199
  return `Cannot sync: ${e.message}. Run rebuild_graph once before sync_graph.`
137
200
  }
138
201
  const body = JSON.stringify(payload)
139
- const repoName = String(ctx.repoRoot || '').replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'repo'
202
+ const bodyBytes = Buffer.byteLength(body)
203
+ if (bodyBytes > MAX_SYNC_BODY_BYTES) {
204
+ return `Cannot sync: payload is ${Math.ceil(bodyBytes / 1024)} KB; the hosted safety limit is ${MAX_SYNC_BODY_BYTES / 1024} KB. Narrow the graph scope and rebuild before retrying.`
205
+ }
206
+ const repoName = syncRepoLabel(ctx.repoRoot)
207
+ const registry = repositoryRecord(ctx.repoRoot, graphHomeDir())
208
+ || registerRepository({repoPath: ctx.repoRoot, graphDir: graphOutDirForRepo(ctx.repoRoot), graphHome: graphHomeDir()})
209
+ const timeoutMs = Math.min(120000, Math.max(1000, Number(args.timeout_ms) || 30000))
140
210
  try {
141
211
  const res = await fetch(url, {
142
212
  method: 'POST',
@@ -144,12 +214,23 @@ export async function tSyncGraph(g, args, ctx) {
144
214
  'content-type': 'application/json',
145
215
  'x-weavatrix-payload-version': String(payload.syncPayloadV),
146
216
  'x-weavatrix-repo': repoName,
217
+ 'x-weavatrix-repository-id': registry.repositoryId,
147
218
  ...(process.env.WEAVATRIX_SYNC_TOKEN ? {authorization: `Bearer ${process.env.WEAVATRIX_SYNC_TOKEN}`} : {}),
148
219
  },
149
220
  body,
221
+ signal: AbortSignal.timeout(timeoutMs),
150
222
  })
151
- if (!res.ok) return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.`
152
- return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges, ${Math.round(Buffer.byteLength(body) / 1024)} KB) pushed to ${url}.`
223
+ if (!res.ok) {
224
+ const accepted = res.headers?.get?.('x-weavatrix-accept-payload-versions')
225
+ const compatibility = (res.status === 415 || res.status === 422) && accepted
226
+ ? ` Endpoint accepts payload version(s) ${accepted}; retry with payload_version:2 only if you intentionally want graph-only sync.`
227
+ : ''
228
+ return `Sync endpoint answered HTTP ${res.status} — graph NOT accepted.${compatibility}`
229
+ }
230
+ const evidenceNote = payload.syncPayloadV === 3
231
+ ? ` + evidence ${payload.evidence?.snapshotHash?.slice(0, 12) || 'unknown'}`
232
+ : ''
233
+ return `Graph for ${repoName} (${payload.nodes.length} nodes / ${payload.links.length} edges${evidenceNote}, ${Math.round(bodyBytes / 1024)} KB) pushed to ${url}.`
153
234
  } catch (e) {
154
235
  return `Sync failed: ${e.message} — the graph stays local.`
155
236
  }
@@ -0,0 +1,200 @@
1
+ // Executable target architecture for agents: read the active contract before a change, then run the
2
+ // same deterministic verifier after it. No tool silently edits policy or accepts debt.
3
+ import {contractForChange, loadArchitectureContract, normalizeArchitectureContract, verifyArchitecture} from '../analysis/architecture-contract.js'
4
+ import {createPathClassifier, hasPathClass} from '../path-classification.js'
5
+ import {detectRepoStack} from '../scan/discover.js'
6
+ import {toolResult} from './tool-result.mjs'
7
+
8
+ const PROVISIONAL_BUDGETS = Object.freeze({
9
+ runtimeCycles: 0,
10
+ maxFileLoc: 300,
11
+ maxFunctionLoc: 120,
12
+ maxCyclomatic: 15,
13
+ maxModuleFiles: 80,
14
+ minModuleCohesion: .5,
15
+ maxModuleBoundaryRatio: .65,
16
+ })
17
+
18
+ const remediation = () => ({
19
+ offlinePath: '.weavatrix/architecture.json',
20
+ hostedAction: 'Open Architecture -> choose intended style -> Save target & baseline',
21
+ nextTool: 'verify_architecture',
22
+ })
23
+
24
+ const stackIds = (repoRoot) => {
25
+ try {
26
+ const stack = detectRepoStack(repoRoot)
27
+ return ['languages', 'runtimes', 'tests', 'infra', 'deploy']
28
+ .flatMap((category) => (Array.isArray(stack?.[category]) ? stack[category] : []))
29
+ .map((item) => String(item?.id || '')).filter(Boolean)
30
+ } catch { return [] }
31
+ }
32
+
33
+ function activeContract(ctx) {
34
+ if (!ctx?.repoRoot) return {contract: null, source: null, error: 'no repository root is active'}
35
+ return loadArchitectureContract(ctx.repoRoot, ctx.graphPath)
36
+ }
37
+
38
+ function starterContract(g) {
39
+ const files = [...new Set((g?.nodes || [])
40
+ .filter((node) => !String(node.id).includes('#'))
41
+ .map((node) => String(node.source_file || node.id || '').replace(/\\/g, '/'))
42
+ .filter(Boolean))]
43
+ const groups = new Map()
44
+ for (const file of files) {
45
+ const parts = file.split('/').filter(Boolean)
46
+ const depth = ['src', 'app', 'lib', 'packages', 'services'].includes(parts[0]) ? 2 : 1
47
+ const path = parts.slice(0, Math.max(1, Math.min(depth, parts.length - 1))).join('/') || '(root)'
48
+ groups.set(path, (groups.get(path) || 0) + 1)
49
+ }
50
+ const components = [...groups].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 80)
51
+ .filter(([path]) => path !== '(root)')
52
+ .map(([path], index) => ({
53
+ id: path.toLowerCase().replace(/[^a-z0-9._:-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) || `component-${index + 1}`,
54
+ name: path,
55
+ paths: [path],
56
+ }))
57
+ return normalizeArchitectureContract({
58
+ name: 'Proposed no-regressions baseline', style: 'custom', enforcement: 'ratchet', components,
59
+ dependencyRules: [],
60
+ budgets: PROVISIONAL_BUDGETS,
61
+ technologies: {required: [], forbidden: []}, exceptions: [], ratchet: {baseline: {fingerprints: [], metrics: {}}},
62
+ })
63
+ }
64
+
65
+ function notConfiguredResult(g, action, {includeStarter = false} = {}) {
66
+ const starter = includeStarter ? starterContract(g) : null
67
+ const starterText = starter
68
+ ? ` A source-free starter with ${starter.components.length} path territories is available in JSON output from this lookup.`
69
+ : ''
70
+ return toolResult([
71
+ `Architecture ${action} is NOT_CONFIGURED — no target contract is active.${starterText}`,
72
+ 'Next: save .weavatrix/architecture.json (offline) or approve a target in Hosted, then call verify_architecture.',
73
+ ].join('\n'), {
74
+ state: 'NOT_CONFIGURED',
75
+ remediation: remediation(),
76
+ ...(starter ? {
77
+ starterSummary: {components: starter.components.length, budgets: starter.budgets},
78
+ starterContract: starter,
79
+ } : {}),
80
+ })
81
+ }
82
+
83
+ function classifyChangeFiles(files, repoRoot) {
84
+ const classifier = createPathClassifier(repoRoot)
85
+ const normalized = [...new Set((Array.isArray(files) ? files : [])
86
+ .slice(0, 200)
87
+ .map((file) => String(file || '').replace(/\\/g, '/').replace(/^\.\//, ''))
88
+ .filter((file) => file && !file.startsWith('../') && !file.includes('/../')))]
89
+ .sort((a, b) => a.localeCompare(b))
90
+ const testOnlyFiles = normalized.filter((file) => hasPathClass(classifier.explain(file), 'test', 'e2e'))
91
+ const testOnly = new Set(testOnlyFiles)
92
+ return {files: normalized, productFiles: normalized.filter((file) => !testOnly.has(file)), testOnlyFiles}
93
+ }
94
+
95
+ function provisionalPreflight(g, args, ctx) {
96
+ const surfaces = classifyChangeFiles(args?.files, ctx?.repoRoot)
97
+ const intent = String(args?.intent || '').slice(0, 500)
98
+ const budgetText = [
99
+ `no new runtime cycles (baseline ${PROVISIONAL_BUDGETS.runtimeCycles})`,
100
+ `file <= ${PROVISIONAL_BUDGETS.maxFileLoc} LOC`,
101
+ `function <= ${PROVISIONAL_BUDGETS.maxFunctionLoc} LOC`,
102
+ `cyclomatic <= ${PROVISIONAL_BUDGETS.maxCyclomatic}`,
103
+ ].join('; ')
104
+ return toolResult([
105
+ `Architecture preflight is NOT_CONFIGURED for ${surfaces.files.length} file(s)${intent ? ` — ${intent}` : ''}.`,
106
+ `Provisional no-regression guidance (not enforced policy): ${budgetText}.`,
107
+ `${surfaces.productFiles.length} product file(s); ${surfaces.testOnlyFiles.length} test-only file(s). Save a target contract to make these budgets enforceable.`,
108
+ ].join('\n'), {
109
+ state: 'NOT_CONFIGURED',
110
+ guidance: 'PROVISIONAL_BUDGETS',
111
+ enforceable: false,
112
+ intent,
113
+ ...surfaces,
114
+ provisionalBudgets: PROVISIONAL_BUDGETS,
115
+ remediation: remediation(),
116
+ })
117
+ }
118
+
119
+ export function tGetArchitectureContract(g, args, ctx) {
120
+ const loaded = activeContract(ctx)
121
+ if (!loaded.contract) {
122
+ const text = loaded.error
123
+ ? `Architecture contract is invalid (${loaded.source || 'unknown'}): ${loaded.error}`
124
+ : 'No target architecture contract is active.'
125
+ if (!loaded.error) return notConfiguredResult(g, 'lookup', {includeStarter: true})
126
+ return toolResult(text, {state: 'ERROR', source: loaded.source, error: loaded.error})
127
+ }
128
+ const contract = loaded.contract
129
+ return toolResult([
130
+ `Target architecture: ${contract.name} (${contract.style}, ${contract.enforcement}).`,
131
+ `Contract ${contract.contractHash.slice(0, 12)} from ${loaded.source}; ${contract.components.length} components, ${contract.dependencyRules.length} dependency rules.`,
132
+ `Quality budgets: ${Object.entries(contract.budgets).map(([key, value]) => `${key}=${value}`).join(', ') || '(none)'}.`,
133
+ ].join('\n'), {state: 'ACTIVE', source: loaded.source, contract})
134
+ }
135
+
136
+ export function tPrepareChange(g, args = {}, ctx) {
137
+ const loaded = activeContract(ctx)
138
+ if (!loaded.contract) return provisionalPreflight(g, args, ctx)
139
+ const prepared = contractForChange(loaded.contract, args.files)
140
+ const surfaces = classifyChangeFiles(prepared.files, ctx?.repoRoot)
141
+ const intent = String(args.intent || '').slice(0, 500)
142
+ return toolResult([
143
+ `Architecture preflight for ${prepared.files.length} file(s)${intent ? ` — ${intent}` : ''}.`,
144
+ `Affected target components: ${prepared.components.join(', ') || '(unmapped)'}.`,
145
+ ...(surfaces.testOnlyFiles.length ? [`Test-only surface: ${surfaces.testOnlyFiles.join(', ')}.`] : []),
146
+ `Applicable rules: ${prepared.rules.map((rule) => rule.id).join(', ') || '(none)'}.`,
147
+ 'Run verify_architecture after the edit; do not silently add an exception or rewrite the target contract.',
148
+ ].join('\n'), {state: 'READY', intent, contractHash: loaded.contract.contractHash, ...prepared, productFiles: surfaces.productFiles, testOnlyFiles: surfaces.testOnlyFiles})
149
+ }
150
+
151
+ export function tVerifyArchitecture(g, args, ctx) {
152
+ const loaded = activeContract(ctx)
153
+ if (!loaded.contract) return notConfiguredResult(g, 'verification')
154
+ const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
155
+ const groups = [['new', verification.new], ['existing', verification.existing], ['fixed', verification.fixed], ['excepted', verification.excepted]]
156
+ const lines = [
157
+ `Architecture verify: ${verification.status} (${verification.enforcement}; contract ${verification.contractHash.slice(0, 12)}).`,
158
+ `New ${verification.new.length} · existing debt ${verification.existing.length} · fixed ${verification.fixed.length} · excepted ${verification.excepted.length}.`,
159
+ ...groups.filter(([, values]) => values.length).flatMap(([label, values]) => [
160
+ `${label}:`,
161
+ ...values.slice(0, 20).map((item) => typeof item === 'string' ? ` ${item}` : ` ${item.ruleId}: ${item.evidence}${item.current != null ? ` (${item.current} > ${item.target})` : ''}`),
162
+ ]),
163
+ ]
164
+ return toolResult(lines.join('\n'), {state: verification.status, source: loaded.source, verification}, {
165
+ completeness: {violationsReturned: Math.min(20, verification.new.length + verification.existing.length), violationsTotal: verification.new.length + verification.existing.length},
166
+ })
167
+ }
168
+
169
+ export function tExplainArchitectureViolation(g, args, ctx) {
170
+ const loaded = activeContract(ctx)
171
+ if (!loaded.contract) return toolResult('No active architecture contract.', {state: 'NOT_CONFIGURED'})
172
+ const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
173
+ const fingerprint = String(args.fingerprint || '')
174
+ const item = [...verification.new, ...verification.existing, ...verification.excepted]
175
+ .find((entry) => entry.fingerprint === fingerprint)
176
+ if (!item) return toolResult(`Violation ${fingerprint || '(missing)'} is not active in the current verification.`, {state: 'NOT_FOUND', fingerprint})
177
+ const rule = loaded.contract.dependencyRules.find((candidate) => candidate.id === item.ruleId)
178
+ return toolResult([
179
+ `${item.ruleId}: ${item.evidence}.`,
180
+ rule?.reason ? `Reason: ${rule.reason}` : 'The finding violates an explicit dependency or quality rule in the active target contract.',
181
+ 'Preferred action: change the dependency/metric. If that is intentionally impossible, propose a time-bounded exception for human review.',
182
+ ].join('\n'), {state: 'ACTIVE', violation: item, rule: rule || null})
183
+ }
184
+
185
+ export function tProposeArchitectureException(g, args, ctx) {
186
+ const loaded = activeContract(ctx)
187
+ if (!loaded.contract) return toolResult('No active architecture contract.', {state: 'NOT_CONFIGURED'})
188
+ const verification = verifyArchitecture({graph: g, contract: loaded.contract, technologies: stackIds(ctx.repoRoot)})
189
+ const item = [...verification.new, ...verification.existing].find((entry) => entry.fingerprint === String(args.fingerprint || ''))
190
+ if (!item) return toolResult('Only an active violation can be proposed as an exception.', {state: 'NOT_FOUND'})
191
+ const proposal = {
192
+ fingerprint: item.fingerprint,
193
+ reason: String(args.reason || '').trim().slice(0, 300),
194
+ ...(args.expires ? {expires: String(args.expires)} : {}),
195
+ }
196
+ const checked = normalizeArchitectureContract({...loaded.contract, exceptions: [...loaded.contract.exceptions, proposal]})
197
+ const normalized = checked.exceptions.find((entry) => entry.fingerprint === item.fingerprint)
198
+ if (!normalized || !normalized.reason) return toolResult('A non-empty reason and optional YYYY-MM-DD expiry are required.', {state: 'INVALID'})
199
+ return toolResult('Exception proposal prepared for human review; the contract was not changed.', {state: 'PROPOSED', proposal: normalized, contractHash: loaded.contract.contractHash})
200
+ }