weavatrix 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +138 -53
- package/SECURITY.md +25 -8
- package/package.json +2 -2
- package/skill/SKILL.md +92 -30
- package/src/analysis/architecture-contract.js +343 -0
- package/src/analysis/audit-debt.js +94 -0
- package/src/analysis/change-classification.js +509 -0
- package/src/analysis/cycle-route.js +14 -0
- package/src/analysis/dead-check.js +5 -3
- package/src/analysis/dead-code-review.js +245 -0
- package/src/analysis/dep-check-ecosystems.js +6 -0
- package/src/analysis/dep-check.js +40 -6
- package/src/analysis/dep-rules.js +12 -9
- package/src/analysis/duplicates.compute.js +47 -7
- package/src/analysis/endpoints-java.js +186 -0
- package/src/analysis/endpoints.js +8 -2
- package/src/analysis/findings.js +8 -1
- package/src/analysis/git-history.js +549 -0
- package/src/analysis/git-ref-graph.js +74 -0
- package/src/analysis/graph-analysis.aggregate.js +2 -2
- package/src/analysis/graph-analysis.edges.js +3 -0
- package/src/analysis/graph-analysis.summaries.js +1 -1
- package/src/analysis/http-contracts.js +581 -0
- package/src/analysis/internal-audit.collect.js +3 -2
- package/src/analysis/internal-audit.reach.js +52 -1
- package/src/analysis/internal-audit.run.js +58 -10
- package/src/analysis/java-source.js +36 -0
- package/src/analysis/package-reachability.js +39 -0
- package/src/analysis/static-test-reachability.js +133 -0
- package/src/build-graph.js +72 -13
- package/src/graph/build-worker.js +3 -4
- package/src/graph/builder/lang-js.js +71 -12
- package/src/graph/community.js +10 -0
- package/src/graph/file-lock.js +69 -0
- package/src/graph/freshness-probe.js +141 -0
- package/src/graph/graph-filter.js +28 -11
- package/src/graph/incremental-refresh.js +232 -0
- package/src/graph/internal-builder.barrels.js +169 -0
- package/src/graph/internal-builder.build.js +82 -11
- package/src/graph/internal-builder.langs.js +2 -1
- package/src/graph/layout.js +21 -5
- package/src/graph/repo-registry.js +124 -0
- package/src/mcp/catalog.mjs +62 -19
- package/src/mcp/evidence-snapshot.architecture.mjs +80 -0
- package/src/mcp/evidence-snapshot.common.mjs +160 -0
- package/src/mcp/evidence-snapshot.duplicates.mjs +217 -0
- package/src/mcp/evidence-snapshot.health.mjs +95 -0
- package/src/mcp/evidence-snapshot.inventory.mjs +148 -0
- package/src/mcp/evidence-snapshot.mjs +50 -0
- package/src/mcp/evidence-snapshot.package-graph.mjs +249 -0
- package/src/mcp/evidence-snapshot.structure.mjs +81 -0
- package/src/mcp/graph-context.mjs +100 -16
- package/src/mcp/graph-diff.mjs +74 -20
- package/src/mcp/staleness-notice.mjs +20 -0
- package/src/mcp/sync-evidence.mjs +418 -0
- package/src/mcp/sync-payload.mjs +164 -0
- package/src/mcp/tool-result.mjs +51 -0
- package/src/mcp/tools-actions.mjs +111 -30
- package/src/mcp/tools-architecture.mjs +144 -0
- package/src/mcp/tools-company.mjs +273 -0
- package/src/mcp/tools-graph.mjs +25 -14
- package/src/mcp/tools-health.mjs +336 -14
- package/src/mcp/tools-history.mjs +22 -0
- package/src/mcp/tools-impact-change.mjs +261 -0
- package/src/mcp/tools-impact.mjs +25 -6
- package/src/mcp-server.mjs +100 -17
- package/src/mcp-source-tools.mjs +11 -3
- package/src/path-classification.js +201 -0
- package/src/path-ignore.js +69 -0
- package/src/security/typosquat.js +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Global local registry for repository graphs. Absolute paths never leave the machine; hosted sync
|
|
2
|
+
// uses the opaque UUID. Identity is anchored in each canonical graph folder so simultaneous MCP
|
|
3
|
+
// processes cannot mint different IDs for the same repository.
|
|
4
|
+
import {randomUUID} from 'node:crypto'
|
|
5
|
+
import {existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync} from 'node:fs'
|
|
6
|
+
import {basename, join, relative, resolve} from 'node:path'
|
|
7
|
+
import process from 'node:process'
|
|
8
|
+
import {graphStorageKey} from './layout.js'
|
|
9
|
+
import {atomicWriteFileSync, withFileLockSync} from './file-lock.js'
|
|
10
|
+
|
|
11
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
12
|
+
const MARKER = '.repository-id'
|
|
13
|
+
|
|
14
|
+
const normalized = (value) => {
|
|
15
|
+
const path = String(value || '').replace(/\\/g, '/').replace(/\/+$/, '')
|
|
16
|
+
return process.platform === 'win32' ? path.toLowerCase() : path
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const realOrResolved = (value) => {
|
|
20
|
+
try { return realpathSync.native(value) } catch { return resolve(value) }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const canonicalGraphDir = (repoPath, graphHome) => join(resolve(graphHome), graphStorageKey(repoPath))
|
|
24
|
+
const markerPath = (graphDir) => join(graphDir, MARKER)
|
|
25
|
+
|
|
26
|
+
export function registryPath(graphHome) { return join(graphHome, 'repositories.json') }
|
|
27
|
+
|
|
28
|
+
export function readRepositoryRegistry(graphHome) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(readFileSync(registryPath(graphHome), 'utf8'))
|
|
31
|
+
return parsed?.repositoryRegistryV === 1 && Array.isArray(parsed.repositories)
|
|
32
|
+
? parsed.repositories.filter((item) => item && typeof item === 'object')
|
|
33
|
+
: []
|
|
34
|
+
} catch { return [] }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeRegistry(graphHome, repositories) {
|
|
38
|
+
atomicWriteFileSync(registryPath(graphHome), JSON.stringify({repositoryRegistryV: 1, repositories}, null, 2), 'utf8')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readMarker(graphDir) {
|
|
42
|
+
try {
|
|
43
|
+
const id = readFileSync(markerPath(graphDir), 'utf8').trim()
|
|
44
|
+
return UUID.test(id) ? id : null
|
|
45
|
+
} catch { return null }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createOrReadMarker(graphDir, preferredId) {
|
|
49
|
+
mkdirSync(graphDir, {recursive: true})
|
|
50
|
+
const path = markerPath(graphDir)
|
|
51
|
+
const existing = readMarker(graphDir)
|
|
52
|
+
if (existing) return existing
|
|
53
|
+
if (existsSync(path)) throw new Error(`invalid repository identity marker: ${path}`)
|
|
54
|
+
const candidate = UUID.test(String(preferredId || '')) ? preferredId : randomUUID()
|
|
55
|
+
try { writeFileSync(path, `${candidate}\n`, {encoding: 'utf8', flag: 'wx'}) }
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error?.code !== 'EEXIST') throw error
|
|
58
|
+
const raced = readMarker(graphDir)
|
|
59
|
+
if (!raced) throw new Error(`invalid repository identity marker after concurrent registration: ${path}`)
|
|
60
|
+
return raced
|
|
61
|
+
}
|
|
62
|
+
return candidate
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function registerRepository({repoPath, graphDir, graphHome}) {
|
|
66
|
+
mkdirSync(graphHome, {recursive: true})
|
|
67
|
+
const real = realOrResolved(repoPath)
|
|
68
|
+
const canonical = canonicalGraphDir(real, graphHome)
|
|
69
|
+
if (normalized(realOrResolved(graphDir)) !== normalized(realOrResolved(canonical))) {
|
|
70
|
+
throw new Error(`repository graphs must be registered from the canonical graph directory: ${canonical}`)
|
|
71
|
+
}
|
|
72
|
+
return withFileLockSync(join(graphHome, '.repositories.lock'), () => {
|
|
73
|
+
const key = normalized(real)
|
|
74
|
+
const repositories = readRepositoryRegistry(graphHome)
|
|
75
|
+
let record = repositories.find((item) => normalized(item.repoPath) === key)
|
|
76
|
+
const now = new Date().toISOString()
|
|
77
|
+
const repositoryId = createOrReadMarker(canonical, record?.repositoryId)
|
|
78
|
+
if (!record) {
|
|
79
|
+
record = {repositoryId, repoPath: real, label: basename(real) || 'repo', graphDir: canonical, firstSeenAt: now, lastSeenAt: now}
|
|
80
|
+
repositories.push(record)
|
|
81
|
+
} else {
|
|
82
|
+
record.repositoryId = repositoryId
|
|
83
|
+
record.repoPath = real
|
|
84
|
+
record.graphDir = canonical
|
|
85
|
+
record.label = basename(real) || record.label || 'repo'
|
|
86
|
+
record.lastSeenAt = now
|
|
87
|
+
}
|
|
88
|
+
const deduped = repositories.filter((item, index, all) =>
|
|
89
|
+
normalized(item.repoPath) !== key || all.findIndex((candidate) => normalized(candidate.repoPath) === key) === index)
|
|
90
|
+
deduped.sort((a, b) => String(a.label).localeCompare(String(b.label)) || String(a.repositoryId).localeCompare(String(b.repositoryId)))
|
|
91
|
+
writeRegistry(graphHome, deduped)
|
|
92
|
+
return {...record}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function repositoryRecord(repoPath, graphHome) {
|
|
97
|
+
const real = realOrResolved(repoPath)
|
|
98
|
+
const key = normalized(real)
|
|
99
|
+
return liveRepositoryRecords(graphHome).find((item) => normalized(item.repoPath) === key) || null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function validLiveRecord(item, graphHome) {
|
|
103
|
+
if (!UUID.test(String(item?.repositoryId || '')) || typeof item?.repoPath !== 'string' || typeof item?.graphDir !== 'string') return null
|
|
104
|
+
let repoReal
|
|
105
|
+
let graphHomeReal
|
|
106
|
+
let graphReal
|
|
107
|
+
try {
|
|
108
|
+
repoReal = realpathSync.native(item.repoPath)
|
|
109
|
+
graphHomeReal = realpathSync.native(graphHome)
|
|
110
|
+
graphReal = realpathSync.native(item.graphDir)
|
|
111
|
+
if (!statSync(repoReal).isDirectory() || !existsSync(join(repoReal, '.git'))) return null
|
|
112
|
+
if (!statSync(graphReal).isDirectory() || !statSync(join(graphReal, 'graph.json')).isFile()) return null
|
|
113
|
+
} catch { return null }
|
|
114
|
+
if (normalized(graphReal) !== normalized(realOrResolved(canonicalGraphDir(repoReal, graphHomeReal)))) return null
|
|
115
|
+
const rel = relative(graphHomeReal, graphReal)
|
|
116
|
+
if (!rel || rel.startsWith('..') || resolve(graphHomeReal, rel) !== resolve(graphReal)) return null
|
|
117
|
+
if (readMarker(graphReal) !== item.repositoryId) return null
|
|
118
|
+
return {...item, repoPath: repoReal, graphDir: graphReal}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function liveRepositoryRecords(graphHome) {
|
|
122
|
+
if (!existsSync(graphHome)) return []
|
|
123
|
+
return readRepositoryRegistry(graphHome).map((item) => validLiveRecord(item, graphHome)).filter(Boolean)
|
|
124
|
+
}
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -11,53 +11,96 @@ import {readSource, searchCode} from '../mcp-source-tools.mjs'
|
|
|
11
11
|
|
|
12
12
|
const SELF_DIR = dirname(fileURLToPath(import.meta.url))
|
|
13
13
|
const resolveRg = createRgResolver(SELF_DIR)
|
|
14
|
-
|
|
14
|
+
// The default profile is fully offline and includes explicit local repository retargeting. Operators
|
|
15
|
+
// who want a hard single-repository boundary can select the `pinned` profile instead. HTTP tools are
|
|
16
|
+
// split into explicit `advisories` and `hosted` capabilities; the old `online` name remains an alias
|
|
17
|
+
// for both so existing registrations keep working.
|
|
18
|
+
export const DEFAULT_CAPS = Object.freeze(['graph', 'search', 'source', 'health', 'build', 'retarget', 'crossrepo'])
|
|
19
|
+
const PROFILE_CAPS = Object.freeze({
|
|
20
|
+
offline: DEFAULT_CAPS,
|
|
21
|
+
pinned: ['graph', 'search', 'source', 'health', 'build'],
|
|
22
|
+
osv: [...DEFAULT_CAPS, 'advisories'],
|
|
23
|
+
hosted: [...DEFAULT_CAPS, 'advisories', 'hosted'],
|
|
24
|
+
full: [...DEFAULT_CAPS, 'advisories', 'hosted'],
|
|
25
|
+
})
|
|
15
26
|
|
|
16
27
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
17
28
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
18
|
-
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-actions.mjs', 'catalog.mjs']
|
|
29
|
+
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
|
|
19
30
|
|
|
20
|
-
function buildTools({tg, ti, th, ta}) {
|
|
21
|
-
|
|
31
|
+
function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
32
|
+
const tools = [
|
|
22
33
|
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, confidence breakdown, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
|
|
23
34
|
{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)},
|
|
24
35
|
{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)},
|
|
25
|
-
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
36
|
+
{cap: 'graph', name: 'query_graph', description: 'Explore the graph around a concept (BFS/DFS). Returns a focused, ranked subgraph — the closest, most-connected nodes near the matched seeds, with edges among them — not the full flood; states how many nodes were reached vs shown.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, 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: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a) => tg.tQueryGraph(g, a)},
|
|
26
37
|
{cap: 'graph', name: 'god_nodes', description: 'Rank 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.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}}}, run: (g, a) => tg.tGodNodes(g, a)},
|
|
27
38
|
{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)},
|
|
28
39
|
{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). For a symbol, also follows importers of its containing file. 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}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
29
|
-
{cap: 'graph', name: 'change_impact', description: '
|
|
40
|
+
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
41
|
+
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
42
|
+
{cap: 'crossrepo', name: 'trace_api_contract', description: 'Cross-repository HTTP contract and blast-radius evidence. Select a registered backend and one or more registered clients by stable repository UUID or unambiguous label; joins backend endpoints to static client calls and follows client importers to affected screens. Repository paths stay local and cannot be supplied through this tool. This capability is intentionally absent from the pinned profile.', inputSchema: {type: 'object', properties: {backend: {type: 'string', description: 'Backend repository UUID or exact unambiguous registry label'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20, description: 'Client repository UUIDs or exact unambiguous registry labels'}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048, description: 'Optional endpoint route filter; {id}, :id and concrete parameter values are normalized'}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional backend repo-relative changed files; only endpoints declared in those files are traced'}, include_tests: {type: 'boolean', default: false}, max_impact_depth: {type: 'integer', minimum: 0, maximum: 5, default: 2}, max_endpoints: {type: 'integer', minimum: 1, maximum: 500, default: 250}, max_matches: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_affected_files: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}, required: ['backend', 'clients']}, run: (g, a, ctx) => tc.tTraceApiContract(g, a, ctx)},
|
|
30
43
|
{cap: 'graph', name: 'get_community', description: 'Get all nodes in a community by community ID (0-indexed by size).', inputSchema: {type: 'object', properties: {community_id: {type: 'integer', description: 'Community ID (0-indexed by size)'}}, required: ['community_id']}, run: (g, a) => tg.tGetCommunity(g, a)},
|
|
31
44
|
{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)},
|
|
32
45
|
{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)},
|
|
33
|
-
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over
|
|
34
|
-
{cap: 'health', name: '
|
|
35
|
-
{cap: 'health', name: '
|
|
46
|
+
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Tests, e2e, generated code, mocks, stories, docs, benchmarks and temporary roots are classified separately and 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', description: 'min fragment size, 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_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)},
|
|
47
|
+
{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)},
|
|
48
|
+
{cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, 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: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, 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_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)},
|
|
49
|
+
{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)},
|
|
36
50
|
{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)},
|
|
37
51
|
{cap: 'graph', name: 'module_map', description: 'Folder-level 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'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
|
|
38
|
-
{cap: 'source', name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web
|
|
39
|
-
{cap: 'build', name: 'rebuild_graph', description: "Rebuild
|
|
40
|
-
{cap: 'graph', name: 'graph_diff', description: 'Structural diff
|
|
52
|
+
{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): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
53
|
+
{cap: 'build', name: 'rebuild_graph', description: "Rebuild the active full-repository graph and report a structural delta. With scope, build an isolated diagnostic graph without replacing or diffing the full graph; normal graph queries remain pinned to the full repository.", inputSchema: {type: 'object', properties: {mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}, scope: {type: 'string', description: 'Optional isolated diagnostic path prefix; never replaces the active full graph'}}}, run: (g, a, ctx) => ta.tRebuildGraph(g, a, ctx)},
|
|
54
|
+
{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)},
|
|
55
|
+
{cap: 'graph', name: 'get_architecture_contract', description: 'Return the executable target-architecture contract, quality budgets and ratchet mode that an agent must follow before editing.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tar.tGetArchitectureContract(g, a, ctx)},
|
|
56
|
+
{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)},
|
|
57
|
+
{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)},
|
|
58
|
+
{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)},
|
|
59
|
+
{cap: 'health', name: 'propose_architecture_exception', description: 'Prepare, but never apply, a bounded exception proposal for human review.', inputSchema: {type: 'object', properties: {fingerprint: {type: 'string'}, reason: {type: 'string'}, expires: {type: 'string', description: 'Optional YYYY-MM-DD'}}, required: ['fingerprint', 'reason']}, run: (g, a, ctx) => tar.tProposeArchitectureException(g, a, ctx)},
|
|
41
60
|
{cap: 'retarget', name: 'open_repo', description: 'OFFLINE RETARGET: switch this server to another local Git repository, building its graph when missing. This explicit tool call changes the active repository boundary; pass build:false to probe without building. Omit the retarget capability at registration to pin one repository.', inputSchema: {type: 'object', properties: {path: {type: 'string', description: 'Absolute path to a Git working tree'}, build: {type: 'boolean', description: 'Build the graph when missing (default true)', default: true}, mode: {type: 'string', enum: ['full', 'no-tests', 'tests-only'], default: 'full'}}, required: ['path']}, run: (g, a, ctx) => ta.tOpenRepo(g, a, ctx)},
|
|
42
|
-
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list
|
|
43
|
-
{cap: '
|
|
44
|
-
{cap: '
|
|
61
|
+
{cap: 'retarget', name: 'list_known_repos', description: 'OFFLINE RETARGET: list every registered local repository graph from the global per-user registry, regardless of parent folder.', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => ta.tListKnownRepos(g, a, ctx)},
|
|
62
|
+
{cap: 'advisories', name: 'refresh_advisories', description: "NETWORK / explicit advisories profile: refresh the local OSV advisory store for this repo's lockfile-pinned packages (npm/PyPI/Go). Sends package names + versions to OSV.dev — never automatic, never source code. Afterwards run_audit reads the refreshed store fully offline (~/.weavatrix/advisories.json).", inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', description: 'Per-request timeout, default 20000'}}}, run: (g, a, ctx) => ta.tRefreshAdvisories(g, a, ctx)},
|
|
63
|
+
{cap: 'hosted', name: 'pull_architecture_contract', description: 'NETWORK / explicit hosted profile: fetch the owner-approved target architecture for the active stable repository UUID, validate it and cache it locally. Sends only the opaque UUID with bearer auth; never source or paths.', inputSchema: {type: 'object', properties: {timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000}}}, run: (g, a, ctx) => ta.tPullArchitectureContract(g, a, ctx)},
|
|
64
|
+
{cap: 'hosted', name: 'sync_graph', description: 'NETWORK / explicit hosted profile, off until configured: derive a bounded evidence snapshot locally and send it with an allowlisted graph payload to an endpoint you configure (env WEAVATRIX_SYNC_URL, WEAVATRIX_SYNC_TOKEN bearer auth). Local analyzers may read source files, but file bodies, snippets, absolute paths and unknown fields are never uploaded. Payload v3 includes architecture/health/stack/package evidence; request v2 only for graph-only compatibility.', inputSchema: {type: 'object', properties: {payload_version: {type: 'integer', enum: [2, 3], default: 3, description: '3 includes the evidence snapshot; 2 is graph-only compatibility and is never selected as a silent fallback'}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 120000, default: 30000, description: 'Network timeout in milliseconds'}}}, run: (g, a, ctx) => ta.tSyncGraph(g, a, ctx)},
|
|
45
65
|
]
|
|
66
|
+
// Every tool supports the same machine-output switch. structuredContent is returned regardless;
|
|
67
|
+
// output_format=json mirrors that envelope into TextContent for older workflow runners.
|
|
68
|
+
return tools.map((tool) => ({
|
|
69
|
+
...tool,
|
|
70
|
+
inputSchema: {
|
|
71
|
+
...(tool.inputSchema || {type: 'object'}),
|
|
72
|
+
properties: {
|
|
73
|
+
...(tool.inputSchema?.properties || {}),
|
|
74
|
+
output_format: {
|
|
75
|
+
type: 'string',
|
|
76
|
+
enum: ['text', 'json'],
|
|
77
|
+
default: 'text',
|
|
78
|
+
description: 'text keeps the concise summary; json mirrors the stable structuredContent envelope',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
outputSchema: {type: 'object', additionalProperties: true},
|
|
83
|
+
}))
|
|
46
84
|
}
|
|
47
85
|
|
|
48
86
|
// Import the tool modules (cache-busted when version > 0), build the catalog, apply the caps filter.
|
|
49
|
-
// capsArg semantics: undefined/null = offline defaults
|
|
87
|
+
// capsArg semantics: undefined/null = offline defaults; a
|
|
50
88
|
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
51
89
|
export async function loadHotApi(version, capsArg) {
|
|
52
90
|
const v = version ? `?v=${version}` : ''
|
|
53
|
-
const [tg, ti, th, ta] = await Promise.all([
|
|
91
|
+
const [tg, ti, th, ta, tar, thi, tc] = await Promise.all([
|
|
54
92
|
import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
|
|
55
93
|
import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
|
|
56
94
|
import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
|
|
57
95
|
import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
|
|
96
|
+
import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
|
|
97
|
+
import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
|
|
98
|
+
import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
|
|
58
99
|
])
|
|
59
|
-
const all = buildTools({tg, ti, th, ta})
|
|
60
|
-
const
|
|
100
|
+
const all = buildTools({tg, ti, th, ta, tar, thi, tc})
|
|
101
|
+
const raw = capsArg == null ? 'offline' : String(capsArg).trim()
|
|
102
|
+
const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
103
|
+
.flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
|
|
61
104
|
const caps = new Set(selected)
|
|
62
105
|
const tools = all.filter((t) => caps.has(t.cap))
|
|
63
106
|
return {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAPS, STATE, VERDICT, bounded, compareText, moduleId, nonNegativeInteger,
|
|
3
|
+
} from './evidence-snapshot.common.mjs'
|
|
4
|
+
|
|
5
|
+
function sanitizeModule(value) {
|
|
6
|
+
const id = moduleId(value?.name)
|
|
7
|
+
if (!id) return null
|
|
8
|
+
return {
|
|
9
|
+
id,
|
|
10
|
+
fileCount: nonNegativeInteger(value.fileCount),
|
|
11
|
+
nodeCount: nonNegativeInteger(value.nodeCount),
|
|
12
|
+
symbolCount: nonNegativeInteger(value.symbolCount),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sanitizeModuleDependency(value) {
|
|
17
|
+
const from = moduleId(value?.from)
|
|
18
|
+
const to = moduleId(value?.to)
|
|
19
|
+
if (!from || !to || from === to) return null
|
|
20
|
+
return {from, to, count: nonNegativeInteger(value.count)}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sortedModuleDependencies(values) {
|
|
24
|
+
return (Array.isArray(values) ? values : [])
|
|
25
|
+
.map(sanitizeModuleDependency)
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.sort((a, b) => compareText(a.from, b.from) || compareText(a.to, b.to) || a.count - b.count)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function buildArchitectureSection(graph, aggregate, audit, structure) {
|
|
31
|
+
if (!aggregate) {
|
|
32
|
+
return {
|
|
33
|
+
state: STATE.ERROR,
|
|
34
|
+
verdict: VERDICT.UNKNOWN,
|
|
35
|
+
completeness: {reasons: ['GRAPH_AGGREGATION_ERROR']},
|
|
36
|
+
modules: [],
|
|
37
|
+
dependencies: {runtime: [], typeOnly: [], compileOnly: []},
|
|
38
|
+
cycles: [],
|
|
39
|
+
boundaryViolations: [],
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const modules = bounded(
|
|
44
|
+
(aggregate.modules || []).map(sanitizeModule).filter(Boolean).sort((a, b) => compareText(a.id, b.id)),
|
|
45
|
+
CAPS.modules,
|
|
46
|
+
)
|
|
47
|
+
const runtime = bounded(sortedModuleDependencies(aggregate.moduleEdges), CAPS.moduleDependencies)
|
|
48
|
+
const typeOnly = bounded(sortedModuleDependencies(aggregate.typeOnlyModuleEdges), CAPS.moduleDependencies)
|
|
49
|
+
const compileOnly = bounded(sortedModuleDependencies(aggregate.compileOnlyModuleEdges), CAPS.moduleDependencies)
|
|
50
|
+
const cycles = structure?.cycles || bounded([], CAPS.architectureFindings)
|
|
51
|
+
const boundaries = structure?.boundaries || bounded([], CAPS.architectureFindings)
|
|
52
|
+
|
|
53
|
+
const edgeTypesComplete = Number.isInteger(graph?.edgeTypesV) && graph.edgeTypesV >= 2
|
|
54
|
+
const auditComplete = audit?.ok === true
|
|
55
|
+
const structureComplete = structure?.state === STATE.COMPLETE
|
|
56
|
+
const state = edgeTypesComplete && auditComplete && structureComplete ? STATE.COMPLETE : STATE.PARTIAL
|
|
57
|
+
const fails = cycles.items.some((cycle) => cycle.kind === 'runtime') || boundaries.items.length > 0
|
|
58
|
+
return {
|
|
59
|
+
state,
|
|
60
|
+
verdict: fails ? VERDICT.FAIL : state === STATE.COMPLETE ? VERDICT.PASS : VERDICT.UNKNOWN,
|
|
61
|
+
completeness: {
|
|
62
|
+
modules: modules.completeness,
|
|
63
|
+
runtimeDependencies: runtime.completeness,
|
|
64
|
+
typeOnlyDependencies: typeOnly.completeness,
|
|
65
|
+
compileOnlyDependencies: compileOnly.completeness,
|
|
66
|
+
cycles: cycles.completeness,
|
|
67
|
+
boundaryViolations: boundaries.completeness,
|
|
68
|
+
reasons: [
|
|
69
|
+
...(!edgeTypesComplete ? ['EDGE_TYPES_V2_REQUIRED'] : []),
|
|
70
|
+
...(!auditComplete ? ['AUDIT_UNAVAILABLE'] : []),
|
|
71
|
+
...(!structureComplete ? ['STRUCTURE_EVIDENCE_UNAVAILABLE'] : []),
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
modules: modules.items,
|
|
75
|
+
dependencies: {runtime: runtime.items, typeOnly: typeOnly.items, compileOnly: compileOnly.items},
|
|
76
|
+
cycles: cycles.items,
|
|
77
|
+
boundaryViolations: boundaries.items,
|
|
78
|
+
boundaryRulesState: structure?.rulesState || STATE.ERROR,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const STATE = Object.freeze({
|
|
4
|
+
COMPLETE: 'COMPLETE',
|
|
5
|
+
PARTIAL: 'PARTIAL',
|
|
6
|
+
NOT_CHECKED: 'NOT_CHECKED',
|
|
7
|
+
NOT_APPLICABLE: 'NOT_APPLICABLE',
|
|
8
|
+
ERROR: 'ERROR',
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export const VERDICT = Object.freeze({PASS: 'PASS', FAIL: 'FAIL', UNKNOWN: 'UNKNOWN'})
|
|
12
|
+
|
|
13
|
+
export const CAPS = Object.freeze({
|
|
14
|
+
modules: 500,
|
|
15
|
+
moduleDependencies: 2_000,
|
|
16
|
+
architectureFindings: 150,
|
|
17
|
+
findings: 500,
|
|
18
|
+
hotspots: 250,
|
|
19
|
+
stackBadges: 100,
|
|
20
|
+
packages: 5_000,
|
|
21
|
+
directUsage: 1_000,
|
|
22
|
+
usageFiles: 20,
|
|
23
|
+
packageGraphNodes: 5_000,
|
|
24
|
+
packageGraphEdges: 20_000,
|
|
25
|
+
duplicateGroups: 100,
|
|
26
|
+
duplicateMembers: 12,
|
|
27
|
+
divergenceCandidates: 100,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export const COMPLEXITY_THRESHOLDS = Object.freeze({
|
|
31
|
+
loc: Object.freeze({warning: 120, high: 300}),
|
|
32
|
+
cyclomatic: Object.freeze({warning: 15, high: 30}),
|
|
33
|
+
params: Object.freeze({warning: 6, high: 10}),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f]/
|
|
37
|
+
const ABSOLUTE_PATH_FRAGMENT = /(?:^|[\/\s"'`(=])[a-z]:[\\/]|(?:^|[\s"'`(=])(?:\\\\[^\\/\s]+(?:[\\/]|$)|file:(?:\/\/)?[\\/]|\/(?!\/)[^\s])/i
|
|
38
|
+
const SAFE_TOKEN = /^[A-Za-z0-9@][A-Za-z0-9._:+/@-]*$/
|
|
39
|
+
const SAFE_RULE = /^[a-z0-9][a-z0-9._-]*$/
|
|
40
|
+
const SAFE_FINDING_ID = /^[a-f0-9]{8,64}$/i
|
|
41
|
+
const SAFE_SEVERITY = new Set(['critical', 'high', 'medium', 'low', 'info'])
|
|
42
|
+
const SAFE_CONFIDENCE = new Set(['high', 'medium', 'low'])
|
|
43
|
+
const SAFE_CATEGORY = new Set(['unused', 'structure', 'vulnerability', 'malware'])
|
|
44
|
+
const SAFE_CHECK_STATE = new Set(Object.values(STATE))
|
|
45
|
+
|
|
46
|
+
export function metadataString(value, max = 512) {
|
|
47
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !CONTROL_CHARS.test(value)
|
|
48
|
+
? value
|
|
49
|
+
: undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function privacySafeText(value, max = 512) {
|
|
53
|
+
const text = metadataString(value, max)
|
|
54
|
+
return text && !ABSOLUTE_PATH_FRAGMENT.test(text) ? text : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function safeToken(value, max = 256) {
|
|
58
|
+
const text = metadataString(value, max)
|
|
59
|
+
return text && SAFE_TOKEN.test(text) ? text : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function repoRelativePath(value, max = 4096) {
|
|
63
|
+
const raw = metadataString(value, max)
|
|
64
|
+
if (!raw) return undefined
|
|
65
|
+
const path = raw.replace(/\\/g, '/')
|
|
66
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|\/)/i.test(path)) return undefined
|
|
67
|
+
const segments = path.split('/')
|
|
68
|
+
if (!segments.length || segments.some((segment) => !segment || segment === '.' || segment === '..')) return undefined
|
|
69
|
+
return path
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function graphId(value) {
|
|
73
|
+
const id = metadataString(value, 4096)
|
|
74
|
+
if (!id) return undefined
|
|
75
|
+
const hash = id.indexOf('#')
|
|
76
|
+
const file = hash < 0 ? id : id.slice(0, hash)
|
|
77
|
+
return repoRelativePath(file) ? id.replace(/\\/g, '/') : undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function moduleId(value) {
|
|
81
|
+
if (value === '(root)') return '(root)'
|
|
82
|
+
return repoRelativePath(value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function nonNegativeInteger(value) {
|
|
86
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
87
|
+
? Math.trunc(value)
|
|
88
|
+
: 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function optionalNonNegativeInteger(value) {
|
|
92
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
|
93
|
+
? Math.trunc(value)
|
|
94
|
+
: undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function addIf(out, key, value) {
|
|
98
|
+
if (value !== undefined) out[key] = value
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function compareText(a, b) {
|
|
102
|
+
return String(a).localeCompare(String(b), 'en')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function bounded(sortedItems, cap) {
|
|
106
|
+
const items = sortedItems.slice(0, cap)
|
|
107
|
+
return {
|
|
108
|
+
items,
|
|
109
|
+
completeness: {
|
|
110
|
+
total: sortedItems.length,
|
|
111
|
+
returned: items.length,
|
|
112
|
+
truncated: sortedItems.length > items.length,
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stableStringify(value) {
|
|
118
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
119
|
+
if (value && typeof value === 'object') {
|
|
120
|
+
return `{${Object.keys(value).sort(compareText).map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`
|
|
121
|
+
}
|
|
122
|
+
return JSON.stringify(value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function hashSnapshot(snapshot) {
|
|
126
|
+
return createHash('sha256').update(stableStringify(snapshot)).digest('hex')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function normalizeCheckState(value) {
|
|
130
|
+
if (value === 'OK') return STATE.COMPLETE
|
|
131
|
+
return SAFE_CHECK_STATE.has(value) ? value : STATE.ERROR
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function sanitizeFinding(value) {
|
|
135
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
136
|
+
const id = metadataString(value.id, 64)
|
|
137
|
+
const category = metadataString(value.category, 32)
|
|
138
|
+
const rule = metadataString(value.rule, 64)
|
|
139
|
+
const severity = metadataString(value.severity, 16)
|
|
140
|
+
if (!id || !SAFE_FINDING_ID.test(id) || !category || !SAFE_CATEGORY.has(category) ||
|
|
141
|
+
!rule || !SAFE_RULE.test(rule) || !severity || !SAFE_SEVERITY.has(severity)) return null
|
|
142
|
+
|
|
143
|
+
const out = {id, category, rule, severity}
|
|
144
|
+
const confidence = metadataString(value.confidence, 16)
|
|
145
|
+
if (confidence && SAFE_CONFIDENCE.has(confidence)) out.confidence = confidence
|
|
146
|
+
addIf(out, 'file', repoRelativePath(value.file))
|
|
147
|
+
const line = optionalNonNegativeInteger(value.line)
|
|
148
|
+
if (line !== undefined) out.line = line
|
|
149
|
+
addIf(out, 'symbol', privacySafeText(value.symbol, 256))
|
|
150
|
+
addIf(out, 'package', safeToken(value.package))
|
|
151
|
+
addIf(out, 'version', safeToken(value.version, 128))
|
|
152
|
+
addIf(out, 'graphNodeId', graphId(value.graphNodeId))
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function numericRecord(value, keys) {
|
|
157
|
+
const out = {}
|
|
158
|
+
for (const key of keys) out[key] = nonNegativeInteger(value?.[key])
|
|
159
|
+
return out
|
|
160
|
+
}
|