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 +9 -4
- package/docs/releases/v0.3.13.md +34 -0
- package/package.json +2 -1
- package/src/mcp/catalog.mjs +4 -0
- package/src/mcp/company-contract-page.mjs +142 -0
- package/src/mcp/company-contract-reconcile.mjs +87 -0
- package/src/mcp/graph/context-seeds.mjs +0 -7
- package/src/mcp/graph/tools-query.mjs +33 -6
- package/src/mcp/tools-company.mjs +52 -76
- package/src/mcp/tools-verified-change.mjs +11 -4
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
|
|
16
|
-
|
|
17
|
-
|
|
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.
|
|
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",
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
247
|
+
for (const [neighbor, edge] of connectivityNeighbors(g, id)) {
|
|
232
248
|
if (previous.has(neighbor)) continue
|
|
233
249
|
previous.set(neighbor, id)
|
|
234
|
-
|
|
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
|
|
248
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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) {
|