weavatrix 0.3.12 → 0.3.13

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 CHANGED
@@ -12,9 +12,12 @@ questions. **34 network-free tools. No repository data leaves your machine.**
12
12
  - Source: [github.com/sergii-ziborov/weavatrix](https://github.com/sergii-ziborov/weavatrix)
13
13
  - npm: [`weavatrix`](https://www.npmjs.com/package/weavatrix) — `npx -y weavatrix <repoRoot>`
14
14
 
15
- This package is the complete offline engine under the MIT license and initiates no outbound HTTP.
16
- Every network capability lives in the separately versioned `weavatrix-online` superset, which depends
17
- on this core through a supported extension API. The split is documented in
15
+ This package is the complete offline engine under the MIT license: it reads and analyzes your code,
16
+ initiates no outbound HTTP, and never edits your source. It is the read-only base of a layered stack —
17
+ add [`weavatrix-refactor`](https://github.com/sergii-ziborov/weavatrix-refactor) (Apache-2.0) to apply
18
+ hash-verified, reversible edits, or [`weavatrix-online`](https://github.com/sergii-ziborov/weavatrix-online)
19
+ for authorized Cloud or self-hosted sync. Each is an optional superset that depends on this core through
20
+ a supported extension API; the offline/online split is documented in
18
21
  [docs/adr/0001-v0.3-offline-online-split.md](docs/adr/0001-v0.3-offline-online-split.md).
19
22
 
20
23
  ## Install
@@ -125,7 +128,9 @@ The 34 methods project the same reusable graph into the smallest view a task nee
125
128
  - **build** — `rebuild_graph` (reports the structural delta, keeps the prior state as `graph.prev.json`).
126
129
  - **retarget** *(in `offline`, absent from `pinned`)* — `open_repo`, `list_known_repos`.
127
130
  - **crossrepo** *(in `offline`, absent from `pinned`)* — `trace_api_contract` (joins routes to client
128
- call-sites across registered local graphs; reads no source).
131
+ call-sites across registered local graphs; reads no source). Results are compact and paginated by
132
+ default (`page_size`, opaque `cursor`, and bounded `per_item_limit` samples); `response_detail="full"`
133
+ is an explicit opt-in and remains paginated.
129
134
 
130
135
  Every finding is review evidence, never an auto-delete verdict: `find_dead_code` /
131
136
  `run_audit category=unused` always return `REVIEW_REQUIRED` with `autoDelete:false`. Typecheck, tests
@@ -0,0 +1,34 @@
1
+ # Weavatrix 0.3.13
2
+
3
+ 0.3.13 fixes two misleading verification and graph-path signals found while
4
+ dogfooding a real duplicate-removal refactor. It also bounds cross-repository
5
+ contract responses with revision-bound pagination.
6
+
7
+ ## Honest refactor verification
8
+
9
+ - `verified_change` no longer reports an enabled duplicate ratchet as
10
+ `SKIPPED: duplicate ratchet disabled` during the planning phase. An enabled
11
+ plan now reports `PLANNED`, `enabled: true`, and states that the ratchet runs
12
+ during `phase="verify"`. An explicit `duplicate_ratchet: false` remains the
13
+ only disabled state.
14
+ - `shortest_path` remains an undirected connectivity query, but every rendered
15
+ hop now preserves the stored graph-edge direction. Reverse traversal renders
16
+ `<--imports--` or `<--calls--` instead of inventing a forward dependency.
17
+ Repeated and generic file names are widened in both the header and hop lines.
18
+
19
+ ## Bounded cross-repository contract evidence
20
+
21
+ - `trace_api_contract` response schema v4 returns compact, paginated evidence
22
+ by default. `page_size`, `per_item_limit`, and a revision/filter-bound opaque
23
+ `cursor` keep responses bounded; `response_detail="full"` remains an explicit
24
+ opt-in and is still paginated.
25
+ - Invalid, stale, or cross-query cursors fail closed with `INVALID_CURSOR`.
26
+ - Repository graph reconciliation now reuses a persisted fresh graph before
27
+ invoking a rebuild, while stale or failed refreshes retain their explicit
28
+ completeness status.
29
+
30
+ ## Layer compatibility
31
+
32
+ The read-only core keeps its 34-tool surface and `weavatrix.edit-plan.v1`
33
+ contract unchanged. `weavatrix-refactor` 0.1.1 and `weavatrix-online` 0.2.0
34
+ are the paired upper-layer releases for this core version.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "weavatrix",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "type": "module",
5
5
  "description": "Local repository intelligence MCP for AI coding agents: an always-fresh architecture graph with production-first evidence — blast radius, dead code, endpoints, clones, history, Health and architecture safeguards, with honestly labeled precision.",
6
6
  "author": "Sergii Ziborov <sergii.ziborov@gmail.com>",
@@ -47,6 +47,7 @@
47
47
  "docs/releases/v0.3.10.md",
48
48
  "docs/releases/v0.3.11.md",
49
49
  "docs/releases/v0.3.12.md",
50
+ "docs/releases/v0.3.13.md",
50
51
  "docs/releases/v0.2.19.md",
51
52
  "README.md",
52
53
  "SECURITY.md",
@@ -111,6 +111,10 @@ function buildTools({tg, ti, th, ts, tb, te, ta, tar, thi, tc, tv, caps}) {
111
111
  max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000},
112
112
  max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100},
113
113
  top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10},
114
+ response_detail: {type: 'string', enum: ['compact', 'full'], default: 'compact', description: 'Compact returns bounded samples and counts. Full is explicit opt-in and still paginated.'},
115
+ page_size: {type: 'integer', minimum: 1, maximum: 50, default: 10, description: 'Maximum HTTP/transport/uncertain evidence items returned on this page.'},
116
+ per_item_limit: {type: 'integer', minimum: 1, maximum: 25, default: 5, description: 'Compact-mode sample limit for callsites, affected files, screens and modules per item.'},
117
+ cursor: {type: 'string', maxLength: 256, description: 'Opaque nextCursor from the previous page; bound to repository revisions and filters.'},
114
118
  },
115
119
  required: ['backend', 'clients'],
116
120
  },
@@ -0,0 +1,142 @@
1
+ import {createHash} from 'node:crypto'
2
+
3
+ const DEFAULT_PAGE_SIZE = 10
4
+ const MAX_PAGE_SIZE = 50
5
+ const DEFAULT_RELATED_LIMIT = 5
6
+ const MAX_RELATED_LIMIT = 25
7
+
8
+ export class ContractCursorError extends Error {
9
+ constructor(message) {
10
+ super(message)
11
+ this.name = 'ContractCursorError'
12
+ this.code = 'INVALID_CURSOR'
13
+ }
14
+ }
15
+
16
+ const boundedInteger = (value, fallback, minimum, maximum) => {
17
+ const parsed = Number(value)
18
+ return Number.isInteger(parsed) ? Math.max(minimum, Math.min(maximum, parsed)) : fallback
19
+ }
20
+
21
+ const pageFingerprint = (parts) => createHash('sha256')
22
+ .update(JSON.stringify(parts))
23
+ .digest('hex')
24
+ .slice(0, 24)
25
+
26
+ const encodeCursor = (fingerprint, offset) => Buffer
27
+ .from(JSON.stringify({v: 1, fingerprint, offset}), 'utf8')
28
+ .toString('base64url')
29
+
30
+ function decodeCursor(raw, fingerprint, total) {
31
+ if (!raw) return 0
32
+ try {
33
+ const parsed = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8'))
34
+ if (parsed?.v !== 1 || parsed.fingerprint !== fingerprint) {
35
+ throw new ContractCursorError('cursor does not belong to this repository revision and filter set')
36
+ }
37
+ if (!Number.isInteger(parsed.offset) || parsed.offset < 0 || parsed.offset > total) {
38
+ throw new ContractCursorError('cursor offset is outside the available evidence')
39
+ }
40
+ return parsed.offset
41
+ } catch (error) {
42
+ if (error instanceof ContractCursorError) throw error
43
+ throw new ContractCursorError('cursor is malformed')
44
+ }
45
+ }
46
+
47
+ const sample = (items, limit) => ({
48
+ total: Array.isArray(items) ? items.length : 0,
49
+ items: Array.isArray(items) ? items.slice(0, limit) : [],
50
+ truncated: Array.isArray(items) && items.length > limit,
51
+ })
52
+
53
+ function compactEndpoint(endpoint, relatedLimit) {
54
+ const affected = endpoint?.affected || {}
55
+ return {
56
+ kind: 'http-endpoint',
57
+ backend: endpoint.backend,
58
+ method: endpoint.method,
59
+ path: endpoint.path,
60
+ normalizedPath: endpoint.normalizedPath,
61
+ handler: endpoint.handler,
62
+ handlerNodeId: endpoint.handlerNodeId,
63
+ handlerResolution: endpoint.handlerResolution,
64
+ file: endpoint.file,
65
+ line: endpoint.line,
66
+ callsites: sample(endpoint.callsites, relatedLimit),
67
+ methodMismatches: endpoint.methodMismatches,
68
+ methodMismatchSites: sample(endpoint.methodMismatchSites, relatedLimit),
69
+ liveness: endpoint.liveness,
70
+ affected: {
71
+ complete: affected.complete === true,
72
+ files: sample(affected.files, relatedLimit),
73
+ screens: sample(affected.screens, relatedLimit),
74
+ modules: sample(affected.modules, relatedLimit),
75
+ traversalTruncated: affected.truncated || {},
76
+ },
77
+ }
78
+ }
79
+
80
+ function compactTransport(contract, relatedLimit) {
81
+ const affected = contract?.affected || {}
82
+ return {
83
+ kind: 'transport-contract',
84
+ transport: contract.transport,
85
+ side: contract.side,
86
+ service: contract.service,
87
+ operation: contract.operation,
88
+ name: contract.name,
89
+ file: contract.file,
90
+ line: contract.line,
91
+ detector: contract.detector,
92
+ liveness: contract.liveness,
93
+ runtimeObserved: contract.runtimeObserved === true,
94
+ callsites: sample(contract.callsites, relatedLimit),
95
+ affected: {
96
+ complete: affected.complete === true,
97
+ files: sample(affected.files, relatedLimit),
98
+ screens: sample(affected.screens, relatedLimit),
99
+ modules: sample(affected.modules, relatedLimit),
100
+ traversalTruncated: affected.truncated || {},
101
+ },
102
+ }
103
+ }
104
+
105
+ function evidenceItems(analysis, transportAnalysis) {
106
+ return [
107
+ ...(analysis.endpoints || []).map((value) => ({kind: 'http-endpoint', value})),
108
+ ...(transportAnalysis.contracts || []).map((value) => ({kind: 'transport-contract', value})),
109
+ ...(analysis.uncertain || []).map((value) => ({kind: 'http-uncertain', value})),
110
+ ...(transportAnalysis.uncertain || []).map((value) => ({kind: 'transport-uncertain', value})),
111
+ ]
112
+ }
113
+
114
+ function projectItem(item, detail, relatedLimit) {
115
+ if (detail === 'full') return {kind: item.kind, ...item.value}
116
+ if (item.kind === 'http-endpoint') return compactEndpoint(item.value, relatedLimit)
117
+ if (item.kind === 'transport-contract') return compactTransport(item.value, relatedLimit)
118
+ return {kind: item.kind, ...item.value}
119
+ }
120
+
121
+ export function paginateContractEvidence({analysis, transportAnalysis, args = {}, fingerprintParts = []}) {
122
+ const detail = args.response_detail === 'full' ? 'full' : 'compact'
123
+ const pageSize = boundedInteger(args.page_size, DEFAULT_PAGE_SIZE, 1, MAX_PAGE_SIZE)
124
+ const relatedLimit = boundedInteger(args.per_item_limit, DEFAULT_RELATED_LIMIT, 1, MAX_RELATED_LIMIT)
125
+ const all = evidenceItems(analysis, transportAnalysis)
126
+ const fingerprint = pageFingerprint(fingerprintParts)
127
+ const offset = decodeCursor(args.cursor, fingerprint, all.length)
128
+ const selected = all.slice(offset, offset + pageSize)
129
+ const nextOffset = offset + selected.length
130
+ const nextCursor = nextOffset < all.length ? encodeCursor(fingerprint, nextOffset) : null
131
+ return {
132
+ detail,
133
+ offset,
134
+ pageSize,
135
+ perItemLimit: detail === 'compact' ? relatedLimit : null,
136
+ totalItems: all.length,
137
+ returnedItems: selected.length,
138
+ hasMore: nextCursor !== null,
139
+ nextCursor,
140
+ items: selected.map((item) => projectItem(item, detail, relatedLimit)),
141
+ }
142
+ }
@@ -0,0 +1,87 @@
1
+ import {join} from 'node:path'
2
+ import {buildGraphForRepo} from '../build-graph.js'
3
+ import {persistedFreshnessMatches, repositoryFreshnessProbe} from '../graph/freshness-probe.js'
4
+ import {loadGraph} from './graph-context.mjs'
5
+
6
+ export const publicRecord = (record, alias) => ({repositoryId: record.repositoryId, label: record.label, alias})
7
+
8
+ function safeRefreshReason(record, error) {
9
+ let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
10
+ for (const path of [record.repoPath, record.graphDir]) {
11
+ if (!path) continue
12
+ message = message.split(String(path)).join(record.label || 'repository')
13
+ message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
14
+ }
15
+ return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
16
+ }
17
+
18
+ export async function reconcileGraph(record, alias, role, graphHome) {
19
+ let registeredGraph = null
20
+ try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
21
+ const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
22
+ ? registeredGraph.graphBuildMode
23
+ : 'full'
24
+ const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
25
+ const freshnessProbe = repositoryFreshnessProbe(record.repoPath)
26
+ if (registeredGraph && freshnessProbe && persistedFreshnessMatches(registeredGraph, freshnessProbe, buildMode)) {
27
+ return {
28
+ record,
29
+ alias,
30
+ graph: registeredGraph,
31
+ publicStatus: {
32
+ role,
33
+ repository: publicRecord(record, alias),
34
+ buildMode,
35
+ status: 'CURRENT',
36
+ refresh: {kind: 'none', reason: 'persisted-freshness-match', changedFileCount: 0},
37
+ },
38
+ }
39
+ }
40
+ let build
41
+ try {
42
+ build = await buildGraphForRepo(record.repoPath, {
43
+ mode: buildMode,
44
+ precision,
45
+ scope: '',
46
+ outDir: record.graphDir,
47
+ graphHome,
48
+ })
49
+ } catch (error) {
50
+ build = {ok: false, error: safeRefreshReason(record, error)}
51
+ }
52
+ const refreshKind = build?.refresh?.kind || null
53
+ const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
54
+ const publicStatus = {
55
+ role,
56
+ repository: publicRecord(record, alias),
57
+ buildMode,
58
+ status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
59
+ refresh: build?.ok ? {
60
+ kind: refreshKind || 'full',
61
+ reason: String(build?.refresh?.reason || 'graph-rebuilt'),
62
+ changedFileCount: changedFiles.length,
63
+ } : null,
64
+ }
65
+ if (build?.ok) {
66
+ try {
67
+ return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
68
+ } catch (error) {
69
+ const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
70
+ return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
71
+ }
72
+ }
73
+
74
+ const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
75
+ try {
76
+ return {
77
+ record,
78
+ alias,
79
+ graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
80
+ reason: `${reason}; stale registered graph used`,
81
+ publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
82
+ }
83
+ } catch (error) {
84
+ const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
85
+ return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
86
+ }
87
+ }
@@ -205,10 +205,3 @@ export function resolveSeedFiles(g, requested, limit = 12) {
205
205
  }
206
206
  return {seeds, missing}
207
207
  }
208
-
209
- export function undirectedNeighbors(g, id) {
210
- const seen = new Map()
211
- for (const e of g.out.get(id) || []) if (e.barrelProxy !== true) seen.set(e.id, e.relation)
212
- for (const e of g.inn.get(id) || []) if (e.barrelProxy !== true && !seen.has(e.id)) seen.set(e.id, e.relation)
213
- return seen
214
- }
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  isSymbol, degreeOf, labelOf, resolveNode, findSeeds, resolveSeedFiles,
3
- undirectedNeighbors, requestedPathClasses,
3
+ requestedPathClasses,
4
4
  } from '../graph-context.mjs'
5
5
  import {createPathClassifier, hasPathClass} from '../../path-classification.js'
6
6
 
@@ -52,6 +52,22 @@ const makeEdgeLabel = (g, shownIds) => {
52
52
  }
53
53
  }
54
54
 
55
+ // shortest_path is intentionally a connectivity query, so it may walk an edge in either
56
+ // direction. Keep the stored direction on every hop so the rendered path never turns a reverse
57
+ // traversal into a false caller/importer direction.
58
+ const connectivityNeighbors = (g, id) => {
59
+ const seen = new Map()
60
+ for (const edge of g.out.get(id) || []) {
61
+ if (edge.barrelProxy !== true) seen.set(String(edge.id), {relation: edge.relation, direction: 'forward'})
62
+ }
63
+ for (const edge of g.inn.get(id) || []) {
64
+ if (edge.barrelProxy !== true && !seen.has(String(edge.id))) {
65
+ seen.set(String(edge.id), {relation: edge.relation, direction: 'backward'})
66
+ }
67
+ }
68
+ return seen
69
+ }
70
+
55
71
  const relationSet = (relationFilter, legacyFilter) => {
56
72
  const raw = relationFilter ?? legacyFilter
57
73
  const values = Array.isArray(raw) ? raw : (raw == null ? [] : String(raw).split(','))
@@ -221,17 +237,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
221
237
  if (sourceId === targetId) return `Source and target are the same node: ${sourceNode.label ?? sourceId}.`
222
238
  const limit = Math.max(1, Math.min(20, Number(max_hops) || 8))
223
239
  const previous = new Map([[sourceId, null]])
224
- const relationTo = new Map()
240
+ const edgeTo = new Map()
225
241
  let frontier = [sourceId]
226
242
  let hops = 0
227
243
  let found = false
228
244
  while (frontier.length && hops < limit && !found) {
229
245
  const next = []
230
246
  for (const id of frontier) {
231
- for (const [neighbor, relation] of undirectedNeighbors(g, id)) {
247
+ for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
232
248
  if (previous.has(neighbor)) continue
233
249
  previous.set(neighbor, id)
234
- relationTo.set(neighbor, relation)
250
+ edgeTo.set(neighbor, edge)
235
251
  if (neighbor === targetId) { found = true; break }
236
252
  next.push(neighbor)
237
253
  }
@@ -244,6 +260,17 @@ export function tShortestPath(g, {source, target, max_hops = 8} = {}) {
244
260
  const path = []
245
261
  for (let current = targetId; current != null; current = previous.get(current)) path.unshift(current)
246
262
  const edgeLabel = makeEdgeLabel(g, path)
247
- const lines = path.map((id, index) => index === 0 ? ` ${edgeLabel(id)}` : ` --${relationTo.get(id) || 'rel'}--> ${edgeLabel(id)}`)
248
- return [`Shortest path (${path.length - 1} hops): ${sourceNode.label ?? sourceId} → ${targetNode.label ?? targetId}`, ...lines].join('\n')
263
+ const hopCount = path.length - 1
264
+ const lines = path.map((id, index) => {
265
+ if (index === 0) return ` ${edgeLabel(id)}`
266
+ const edge = edgeTo.get(id) || {}
267
+ return edge.direction === 'backward'
268
+ ? ` <--${edge.relation || 'rel'}-- ${edgeLabel(id)}`
269
+ : ` --${edge.relation || 'rel'}--> ${edgeLabel(id)}`
270
+ })
271
+ return [
272
+ `Shortest path (${hopCount} ${hopCount === 1 ? 'hop' : 'hops'}; undirected connectivity) from ${edgeLabel(sourceId)} to ${edgeLabel(targetId)}`,
273
+ 'Edge arrows preserve stored graph direction; traversal may move with or against them.',
274
+ ...lines,
275
+ ].join('\n')
249
276
  }
@@ -1,17 +1,16 @@
1
1
  // Cross-repository HTTP contract intelligence. Repository paths are resolved only through the
2
2
  // local global registry; callers can select an opaque repository UUID or an unambiguous label but
3
3
  // can never pass an arbitrary filesystem path through this tool.
4
- import {join} from 'node:path'
5
4
  import {analyzeHttpContracts} from '../analysis/http-contracts.js'
6
5
  import {analyzeTransportContracts} from '../analysis/transport-contracts.js'
7
- import {buildGraphForRepo} from '../build-graph.js'
8
6
  import {graphHomeDir} from '../graph/layout.js'
9
7
  import {liveRepositoryRecords} from '../graph/repo-registry.js'
10
- import {loadGraph} from './graph-context.mjs'
8
+ import {ContractCursorError, paginateContractEvidence} from './company-contract-page.mjs'
9
+ import {publicRecord, reconcileGraph} from './company-contract-reconcile.mjs'
11
10
  import {toolResult} from './tool-result.mjs'
12
11
  import {contractVerdict, contractVerdictLine} from './company-contract-verdict.mjs'
13
12
 
14
- const CROSS_REPO_HTTP_CONTRACT_V = 3
13
+ const CROSS_REPO_HTTP_CONTRACT_V = 4
15
14
  const selectorText = (value) => String(value ?? '').trim()
16
15
 
17
16
  function selectRecord(records, selector) {
@@ -35,76 +34,6 @@ function safeAlias(record, records) {
35
34
  return duplicates.length > 1 ? `${base}-${String(record.repositoryId).slice(0, 8)}` : base
36
35
  }
37
36
 
38
- function publicRecord(record, alias) {
39
- return {repositoryId: record.repositoryId, label: record.label, alias}
40
- }
41
-
42
- function safeRefreshReason(record, error) {
43
- let message = String(error instanceof Error ? error.message : error || 'graph refresh failed')
44
- for (const path of [record.repoPath, record.graphDir]) {
45
- if (!path) continue
46
- message = message.split(String(path)).join(record.label || 'repository')
47
- message = message.split(String(path).replace(/\\/g, '/')).join(record.label || 'repository')
48
- }
49
- return message.replace(/[\r\n]+/g, ' ').trim().slice(0, 400) || 'graph refresh failed'
50
- }
51
-
52
- async function reconcileGraph(record, alias, role, graphHome) {
53
- let registeredGraph = null
54
- try { registeredGraph = loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}) } catch { /* refresh may repair it */ }
55
- const buildMode = ['full', 'no-tests', 'tests-only'].includes(registeredGraph?.graphBuildMode)
56
- ? registeredGraph.graphBuildMode
57
- : 'full'
58
- const precision = registeredGraph?.graphPrecisionMode === 'off' ? 'off' : 'lsp'
59
- let build
60
- try {
61
- build = await buildGraphForRepo(record.repoPath, {
62
- mode: buildMode,
63
- precision,
64
- scope: '',
65
- outDir: record.graphDir,
66
- graphHome,
67
- })
68
- } catch (error) {
69
- build = {ok: false, error: safeRefreshReason(record, error)}
70
- }
71
- const refreshKind = build?.refresh?.kind || null
72
- const changedFiles = Array.isArray(build?.refresh?.changedFiles) ? build.refresh.changedFiles : []
73
- const publicStatus = {
74
- role,
75
- repository: publicRecord(record, alias),
76
- buildMode,
77
- status: build?.ok ? (refreshKind === 'none' ? 'CURRENT' : 'REFRESHED') : 'STALE_FALLBACK',
78
- refresh: build?.ok ? {
79
- kind: refreshKind || 'full',
80
- reason: String(build?.refresh?.reason || 'graph-rebuilt'),
81
- changedFileCount: changedFiles.length,
82
- } : null,
83
- }
84
- if (build?.ok) {
85
- try {
86
- return {record, alias, graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}), publicStatus}
87
- } catch (error) {
88
- const reason = `refreshed graph could not be loaded: ${safeRefreshReason(record, error)}`
89
- return {record, alias, graph: null, reason, publicStatus: {...publicStatus, status: 'FAILED', reason}}
90
- }
91
- }
92
-
93
- const reason = `graph refresh failed: ${safeRefreshReason(record, build?.error)}`
94
- try {
95
- return {
96
- record,
97
- alias,
98
- graph: loadGraph(join(record.graphDir, 'graph.json'), {repoRoot: record.repoPath}),
99
- reason: `${reason}; stale registered graph used`,
100
- publicStatus: {...publicStatus, reason: `${reason}; stale registered graph used`},
101
- }
102
- } catch (error) {
103
- const loadReason = `${reason}; registered graph could not be loaded: ${safeRefreshReason(record, error)}`
104
- return {record, alias, graph: null, reason: loadReason, publicStatus: {...publicStatus, status: 'FAILED', reason: loadReason}}
105
- }
106
- }
107
-
108
37
  export async function tTraceApiContract(_g, args = {}, ctx = {}) {
109
38
  const graphHome = ctx.graphHome || graphHomeDir()
110
39
  const records = liveRepositoryRecords(graphHome)
@@ -246,6 +175,39 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
246
175
  ? 'Completeness: complete within the declared repository graphs, supported static models and fresh revision-bound runtime capture.'
247
176
  : `Completeness: partial — ${completeness.reasons.join('; ')}.`,
248
177
  ]
178
+ let evidencePage
179
+ try {
180
+ evidencePage = paginateContractEvidence({
181
+ analysis,
182
+ transportAnalysis,
183
+ args,
184
+ fingerprintParts: [
185
+ backend.repositoryId,
186
+ backendGraph.graph?.graphRevision || null,
187
+ ...clientGraphs.flatMap((item) => [item.record.repositoryId, item.graph?.graphRevision || null]),
188
+ selectedTransport,
189
+ args.method || null,
190
+ args.path || null,
191
+ Array.isArray(args.changed_files) ? [...args.changed_files].sort() : [],
192
+ args.include_tests === true,
193
+ ],
194
+ })
195
+ } catch (error) {
196
+ if (!(error instanceof ContractCursorError)) throw error
197
+ return toolResult(
198
+ `VERDICT INVALID_CURSOR — ${error.message}. Restart from the first page without cursor.`,
199
+ {status: 'INVALID_CURSOR', code: error.code, reason: error.message},
200
+ {page: {status: 'INVALID_CURSOR'}},
201
+ )
202
+ }
203
+ const transportSummary = {
204
+ transportContractsV: transportAnalysis.transportContractsV,
205
+ transport: transportAnalysis.transport,
206
+ status: transportAnalysis.status,
207
+ completeness: transportAnalysis.completeness,
208
+ totals: transportAnalysis.totals,
209
+ runtimeEvidence: transportAnalysis.runtimeEvidence,
210
+ }
249
211
  const result = {
250
212
  crossRepoHttpContractV: CROSS_REPO_HTTP_CONTRACT_V,
251
213
  verdict,
@@ -253,14 +215,28 @@ export async function tTraceApiContract(_g, args = {}, ctx = {}) {
253
215
  backend: publicRecord(backend, backendAlias),
254
216
  clients: clients.map(({record, alias}) => publicRecord(record, alias)),
255
217
  },
256
- ...analysis,
257
- transportContracts: transportAnalysis,
218
+ httpContractsV: analysis.httpContractsV,
219
+ filters: analysis.filters,
220
+ limits: analysis.limits,
221
+ totals: analysis.totals,
222
+ wrapperDiscovery: analysis.wrapperDiscovery,
223
+ transportContracts: transportSummary,
224
+ evidencePage,
258
225
  status: completeness.status,
259
226
  graphReconciliation,
260
227
  completeness,
261
228
  }
262
229
  return toolResult(lines.join('\n'), result, {
263
230
  completeness,
231
+ page: {
232
+ detail: evidencePage.detail,
233
+ offset: evidencePage.offset,
234
+ pageSize: evidencePage.pageSize,
235
+ totalItems: evidencePage.totalItems,
236
+ returnedItems: evidencePage.returnedItems,
237
+ hasMore: evidencePage.hasMore,
238
+ nextCursor: evidencePage.nextCursor,
239
+ },
264
240
  warnings: completeness.complete ? [] : [{code: 'CROSS_REPO_ANALYSIS_PARTIAL', message: completeness.reasons.join('; ')}],
265
241
  })
266
242
  } catch (error) {
@@ -148,10 +148,17 @@ export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permis
148
148
  const baseRef = String(args.base_ref || 'HEAD').trim()
149
149
  const graph = phase === 'verify' ? await baselineProof(ctx, baseRef, currentGraph) : {state: 'PLANNED', baseline: baseRef}
150
150
 
151
- let duplicates = {state: 'SKIPPED', reason: 'duplicate ratchet disabled'}
152
- if (phase === 'verify' && args.duplicate_ratchet !== false) duplicates = permissions.health
153
- ? await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})
154
- : {state: 'UNKNOWN', reason: 'health capability is not enabled'}
151
+ const duplicateRatchetEnabled = args.duplicate_ratchet !== false
152
+ let duplicates
153
+ if (!duplicateRatchetEnabled) {
154
+ duplicates = {state: 'SKIPPED', enabled: false, reason: 'duplicate ratchet disabled'}
155
+ } else if (phase !== 'verify') {
156
+ duplicates = {state: 'PLANNED', enabled: true, reason: 'duplicate ratchet runs during verify phase'}
157
+ } else {
158
+ duplicates = permissions.health
159
+ ? {enabled: true, ...await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})}
160
+ : {state: 'UNKNOWN', enabled: true, reason: 'health capability is not enabled'}
161
+ }
155
162
 
156
163
  let api = {state: 'SKIPPED', reason: 'no api_contract scope was requested'}
157
164
  if (args.api_contract) {