weavatrix 0.2.6 → 0.2.7
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 +57 -10
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.7.md +93 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +27 -7
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/mcp/catalog.mjs +26 -6
- package/src/mcp/graph-context.mjs +1 -1
- package/src/mcp/tools-graph.mjs +46 -3
- package/src/mcp/tools-health.mjs +15 -28
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-verified-change.mjs +169 -0
- package/src/security/malware-heuristics.exclusions.js +3 -3
- package/src/security/malware-heuristics.roots.js +4 -1
- package/src/security/malware-heuristics.scan.js +4 -2
- package/src/security/malware-scoring.js +22 -5
package/src/mcp/catalog.mjs
CHANGED
|
@@ -26,19 +26,38 @@ const PROFILE_CAPS = Object.freeze({
|
|
|
26
26
|
|
|
27
27
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
28
28
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
29
|
-
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
|
|
29
|
+
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
|
|
30
30
|
|
|
31
|
-
function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
31
|
+
function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
|
|
32
32
|
const tools = [
|
|
33
33
|
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
|
|
34
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)},
|
|
35
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)},
|
|
36
|
-
{cap: 'graph', name: 'query_graph', description: 'Explore
|
|
36
|
+
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', 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'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
37
37
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
38
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)},
|
|
39
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). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
40
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
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
|
+
{
|
|
43
|
+
cap: 'graph', refreshGraph: true, name: 'verified_change',
|
|
44
|
+
description: 'Proof-carrying change workflow. Given a natural-language task and current diff/files, returns compact edit contexts, bounded call-argument data-flow, blast radius, graph/architecture/duplicate/API ratchets, affected tests, and one PASS/BLOCKED/UNKNOWN verdict. Package tests run only when explicitly requested and WEAVATRIX_ALLOW_TEST_RUNS=1.',
|
|
45
|
+
inputSchema: {type: 'object', additionalProperties: false, properties: {
|
|
46
|
+
task: {type: 'string', minLength: 1, maxLength: 4000}, phase: {type: 'string', enum: ['plan', 'verify'], default: 'plan'},
|
|
47
|
+
base_ref: {type: 'string', maxLength: 200, default: 'HEAD'}, diff: {type: 'string', maxLength: 2097152}, files: {type: 'array', items: {type: 'string'}, maxItems: 500},
|
|
48
|
+
precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_symbols: {type: 'integer', minimum: 1, maximum: 5, default: 3},
|
|
49
|
+
impact_depth: {type: 'integer', minimum: 1, maximum: 4, default: 2}, max_impact_nodes: {type: 'integer', minimum: 5, maximum: 120, default: 40},
|
|
50
|
+
data_flow_depth: {type: 'integer', minimum: 1, maximum: 3, default: 2}, max_data_flow_edges: {type: 'integer', minimum: 1, maximum: 60, default: 30},
|
|
51
|
+
duplicate_ratchet: {type: 'boolean', default: true},
|
|
52
|
+
api_contract: {type: 'object', additionalProperties: true, properties: {backend: {type: 'string'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500}}, required: ['backend', 'clients']},
|
|
53
|
+
tests: {type: 'array', maxItems: 5, items: {type: 'object', additionalProperties: false, properties: {script: {type: 'string', maxLength: 120}, args: {type: 'array', maxItems: 40, items: {type: 'string', maxLength: 300}}}, required: ['script']}},
|
|
54
|
+
run_tests: {type: 'boolean', default: false}, test_timeout_ms: {type: 'integer', minimum: 1000, maximum: 300000, default: 60000},
|
|
55
|
+
}, required: ['task']},
|
|
56
|
+
run: (g, a, ctx) => tv.tVerifiedChange(g, a, ctx, {
|
|
57
|
+
impact: ti.tChangeImpact, context: tb.tContextBundle, inspect: ts.tInspectSymbol,
|
|
58
|
+
prepareChange: tar.tPrepareChange, verifyArchitecture: tar.tVerifyArchitecture, traceApi: tc.tTraceApiContract,
|
|
59
|
+
}, {source: caps.has('source'), health: caps.has('health'), crossrepo: caps.has('crossrepo')}),
|
|
60
|
+
},
|
|
42
61
|
{
|
|
43
62
|
cap: 'crossrepo',
|
|
44
63
|
name: 'trace_api_contract',
|
|
@@ -91,7 +110,7 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
|
91
110
|
{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)},
|
|
92
111
|
{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)},
|
|
93
112
|
{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)},
|
|
94
|
-
{cap: 'health', name: 'hot_path_review', description: 'Rank
|
|
113
|
+
{cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
|
|
95
114
|
{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)},
|
|
96
115
|
{cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
|
|
97
116
|
{cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): 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)},
|
|
@@ -134,7 +153,7 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
|
134
153
|
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
135
154
|
export async function loadHotApi(version, capsArg) {
|
|
136
155
|
const v = version ? `?v=${version}` : ''
|
|
137
|
-
const [tg, ti, th, ts, tb, ta, tar, thi, tc] = await Promise.all([
|
|
156
|
+
const [tg, ti, th, ts, tb, ta, tar, thi, tc, tv] = await Promise.all([
|
|
138
157
|
import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
|
|
139
158
|
import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
|
|
140
159
|
import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
|
|
@@ -144,12 +163,13 @@ export async function loadHotApi(version, capsArg) {
|
|
|
144
163
|
import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
|
|
145
164
|
import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
|
|
146
165
|
import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
|
|
166
|
+
import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
|
|
147
167
|
])
|
|
148
|
-
const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc})
|
|
149
168
|
const raw = capsArg == null ? 'offline' : String(capsArg).trim()
|
|
150
169
|
const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
151
170
|
.flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
|
|
152
171
|
const caps = new Set(selected)
|
|
172
|
+
const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps})
|
|
153
173
|
const tools = all.filter((t) => caps.has(t.cap))
|
|
154
174
|
return {
|
|
155
175
|
tools,
|
|
@@ -169,7 +169,7 @@ const sourceFileOf = (node) => {
|
|
|
169
169
|
return normPath(source)
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
-
function requestedPathClasses(query) {
|
|
172
|
+
export function requestedPathClasses(query) {
|
|
173
173
|
const words = new Set(wordsOf(query))
|
|
174
174
|
const requested = new Set()
|
|
175
175
|
for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) {
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// staleness probe in graph-context. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
4
4
|
import {
|
|
5
5
|
isSymbol, degreeOf, labelOf, connList,
|
|
6
|
-
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors,
|
|
6
|
+
resolveNodeInfo, resolveNode, ambiguityNote, findSeeds, resolveSeedFiles, undirectedNeighbors, requestedPathClasses,
|
|
7
7
|
graphStaleness, fileStalenessNote,
|
|
8
8
|
} from './graph-context.mjs'
|
|
9
9
|
import {summarizeEdgeProvenance} from '../graph/edge-provenance.js'
|
|
10
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
10
11
|
|
|
11
12
|
const compileKind = (edge) => edge?.typeOnly === true ? 'type-only' : edge?.compileOnly === true ? 'compile-only' : null
|
|
12
13
|
|
|
@@ -165,7 +166,15 @@ export function tGetCommunity(g, {community_id} = {}) {
|
|
|
165
166
|
// A plain BFS/DFS flood dumps every reached node (thousands on a real graph) at near-zero signal.
|
|
166
167
|
// Instead: traverse to record reach + distance-from-seed, then show only the closest, most-connected
|
|
167
168
|
// slice as a coherent subgraph (edges kept only among shown nodes). Honest about what was trimmed.
|
|
168
|
-
|
|
169
|
+
const QUERY_NON_PRODUCT = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
170
|
+
const LOW_SIGNAL_SYMBOL_RE = /^(?:const(?:ant)?|variable|property|field|enum_member)$/i
|
|
171
|
+
const querySourceFile = (node) => String(node?.source_file || String(node?.id || '').split('#', 1)[0]).replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
|
|
172
|
+
const queryWords = (value) => new Set(String(value || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean))
|
|
173
|
+
|
|
174
|
+
export function tQueryGraph(g, {
|
|
175
|
+
question, mode = 'bfs', depth = 3, context_filter, seed_files, augment_seeds = false,
|
|
176
|
+
include_classified = false, include_low_signal = false, token_budget = 2000,
|
|
177
|
+
} = {}, toolCtx = {}) {
|
|
169
178
|
const pinned = resolveSeedFiles(g, seed_files)
|
|
170
179
|
// Exact seed files are a control surface, not a hint: by default they disable fuzzy keyword seeds.
|
|
171
180
|
// Callers can opt back into augmentation when they explicitly want both behaviors.
|
|
@@ -177,6 +186,32 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
177
186
|
const maxDepth = Math.max(1, Math.min(6, Number(depth) || 3))
|
|
178
187
|
const ctx = Array.isArray(context_filter) && context_filter.length ? new Set(context_filter.map((c) => String(c).toLowerCase())) : null
|
|
179
188
|
const relOk = (rel) => !ctx || ctx.has(String(rel ?? '').toLowerCase())
|
|
189
|
+
const requestedClasses = requestedPathClasses(question)
|
|
190
|
+
const classifier = createPathClassifier(toolCtx.repoRoot || null)
|
|
191
|
+
const classificationCache = new Map()
|
|
192
|
+
const pinnedFiles = new Set(pinned.seeds.map(querySourceFile))
|
|
193
|
+
const classifiedSuppressed = new Set()
|
|
194
|
+
const pathPolicy = (id) => {
|
|
195
|
+
const node = g.byId.get(String(id))
|
|
196
|
+
const file = querySourceFile(node)
|
|
197
|
+
if (!file || pinnedFiles.has(file) || include_classified === true) return {ok: true}
|
|
198
|
+
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
199
|
+
const info = classificationCache.get(file)
|
|
200
|
+
const classes = QUERY_NON_PRODUCT.filter((name) => hasPathClass(info, name))
|
|
201
|
+
if (!classes.length && !info?.excluded) return {ok: true}
|
|
202
|
+
if (classes.some((name) => requestedClasses.has(name))) return {ok: true}
|
|
203
|
+
classifiedSuppressed.add(String(id))
|
|
204
|
+
return {ok: false, bucket: 'classified'}
|
|
205
|
+
}
|
|
206
|
+
const questionTerms = queryWords(question)
|
|
207
|
+
const isLowSignal = (id) => {
|
|
208
|
+
if (include_low_signal === true || start.includes(String(id))) return false
|
|
209
|
+
const node = g.byId.get(String(id))
|
|
210
|
+
if (!node || !isSymbol(node.id) || !LOW_SIGNAL_SYMBOL_RE.test(String(node.symbol_kind || ''))) return false
|
|
211
|
+
const labelTerms = queryWords(node.label || String(node.id || '').split('#').pop() || '')
|
|
212
|
+
if ([...questionTerms].some((term) => labelTerms.has(term))) return false
|
|
213
|
+
return degreeOf(g, id) === 0
|
|
214
|
+
}
|
|
180
215
|
const charBudget = Math.max(400, (Number(token_budget) || 2000) * 4)
|
|
181
216
|
// node budget scales gently with the token budget; edges follow the surviving nodes.
|
|
182
217
|
const nodeBudget = Math.max(20, Math.min(120, Math.round((Number(token_budget) || 2000) / 40)))
|
|
@@ -193,6 +228,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
193
228
|
if (d >= maxDepth) continue
|
|
194
229
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
195
230
|
if (!relOk(rel)) continue
|
|
231
|
+
if (!pathPolicy(nid).ok) continue
|
|
196
232
|
if (!seen.has(nid)) stack.push({id: nid, d: d + 1})
|
|
197
233
|
}
|
|
198
234
|
}
|
|
@@ -204,6 +240,7 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
204
240
|
for (const id of frontier)
|
|
205
241
|
for (const [nid, rel] of undirectedNeighbors(g, id)) {
|
|
206
242
|
if (!relOk(rel)) continue
|
|
243
|
+
if (!pathPolicy(nid).ok) continue
|
|
207
244
|
if (!depthOf.has(nid)) {
|
|
208
245
|
depthOf.set(nid, d + 1)
|
|
209
246
|
next.push(nid)
|
|
@@ -213,7 +250,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
213
250
|
}
|
|
214
251
|
}
|
|
215
252
|
// rank reached nodes: seeds first, then by proximity (depth asc), then connectivity (degree desc)
|
|
253
|
+
const reachedBeforeSignalFilter = depthOf.size
|
|
254
|
+
const lowSignalSuppressed = [...depthOf.keys()].filter(isLowSignal).length
|
|
216
255
|
const ranked = [...depthOf.entries()]
|
|
256
|
+
.filter(([id]) => !isLowSignal(id))
|
|
217
257
|
.map(([id, d]) => ({id, d, deg: degreeOf(g, id)}))
|
|
218
258
|
.sort((a, b) => a.d - b.d || b.deg - a.deg)
|
|
219
259
|
const shown = ranked.slice(0, nodeBudget)
|
|
@@ -236,7 +276,10 @@ export function tQueryGraph(g, {question, mode = 'bfs', depth = 3, context_filte
|
|
|
236
276
|
`Query: "${question}" (${mode}, depth ${maxDepth}${ctx ? `, context ${[...ctx].join('/')}` : ''})`,
|
|
237
277
|
`Seeds: ${seeds.map((s) => s.label ?? s.id).join(', ')}`,
|
|
238
278
|
pinned.missing.length ? `Unresolved pinned seed files: ${pinned.missing.join(', ')}` : null,
|
|
239
|
-
`Reached ${
|
|
279
|
+
`Reached ${reachedBeforeSignalFilter} policy-eligible nodes; showing ${shown.length} closest by proximity + connectivity, ${shownEdges.length} edges among them.`,
|
|
280
|
+
classifiedSuppressed.size ? `Suppressed ${classifiedSuppressed.size} classified/non-product traversal node(s); ask for that class or pass include_classified:true.` : null,
|
|
281
|
+
lowSignalSuppressed ? `Suppressed ${lowSignalSuppressed} unreferenced constant/field node(s) with no query-term match; pass include_low_signal:true to inspect them.` : null,
|
|
282
|
+
include_classified === true ? 'Path policy: classified/non-product traversal explicitly enabled.' : `Path policy: production-first${requestedClasses.size ? `; explicit question classes enabled: ${[...requestedClasses].join(', ')}` : ''}.`,
|
|
240
283
|
``,
|
|
241
284
|
`Nodes:`,
|
|
242
285
|
]
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {spawnSync} from 'node:child_process'
|
|
4
4
|
import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
|
|
5
5
|
import {computeDuplicates} from '../analysis/duplicates.js'
|
|
6
|
+
import {analyzeDuplicateGroups} from '../analysis/duplicate-groups.js'
|
|
6
7
|
import {runInternalAudit} from '../analysis/internal-audit.js'
|
|
7
8
|
import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
8
9
|
import {compareAuditDebt, normalizeAuditScopeFiles, scopeAuditFindings} from '../analysis/audit-debt.js'
|
|
@@ -31,26 +32,6 @@ const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
|
31
32
|
return true
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
// Group clone pairs into union-find families.
|
|
35
|
-
function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified}) {
|
|
36
|
-
const frags = data.frags || []
|
|
37
|
-
const elig = (i) => fragmentEligible(frags[i], {tokMin, skipTests, includeClassified})
|
|
38
|
-
const pairs = (data.modes?.[mode] || []).filter(([i, j, s]) => s >= simMin && elig(i) && elig(j))
|
|
39
|
-
const parent = new Map()
|
|
40
|
-
const find = (x) => { let r = x; while (parent.has(r) && parent.get(r) !== r) r = parent.get(r); return r }
|
|
41
|
-
for (const [i, j] of pairs) { if (!parent.has(i)) parent.set(i, i); if (!parent.has(j)) parent.set(j, j); parent.set(find(i), find(j)) }
|
|
42
|
-
const groups = new Map()
|
|
43
|
-
for (const [i, j, s] of pairs) {
|
|
44
|
-
const r = find(i)
|
|
45
|
-
if (!groups.has(r)) groups.set(r, {members: new Set(), maxSim: 0})
|
|
46
|
-
const g = groups.get(r); g.members.add(i); g.members.add(j); g.maxSim = Math.max(g.maxSim, s)
|
|
47
|
-
}
|
|
48
|
-
return [...groups.values()].map((g) => {
|
|
49
|
-
const members = [...g.members].sort((a, b) => frags[b].n - frags[a].n)
|
|
50
|
-
return {members: members.map((i) => frags[i]), maxSim: g.maxSim, tokens: members.reduce((n, i) => n + frags[i].n, 0)}
|
|
51
|
-
}).sort((a, b) => b.tokens - a.tokens)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
35
|
export function tFindDuplicates(g, args, ctx) {
|
|
55
36
|
if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
|
|
56
37
|
const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
|
|
@@ -97,10 +78,10 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
97
78
|
})
|
|
98
79
|
return `Found ${candidates.length} actionable same-name pair(s) across files (semantic mode; one closest clone and/or farthest collision per name). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nThese are review candidates, not automatic refactors. Use read_source on both sites before changing code.`
|
|
99
80
|
}
|
|
100
|
-
const
|
|
101
|
-
const groups =
|
|
81
|
+
const analysis = analyzeDuplicateGroups(ctx.repoRoot, ctx.graphPath, args)
|
|
82
|
+
const groups = analysis.groups
|
|
102
83
|
const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
|
|
103
|
-
const suppressed =
|
|
84
|
+
const suppressed = analysis.suppressed
|
|
104
85
|
const suppressionNote = suppressed && !includeClassified
|
|
105
86
|
? ` ${suppressed} fragment(s) classified as tests/e2e/generated/mock/story/docs/benchmark/temp or matched by .weavatrix.json exclude were suppressed; pass include_classified:true (and include_tests:true for tests) to inspect them explicitly.`
|
|
106
87
|
: ''
|
|
@@ -162,13 +143,15 @@ export function tFindDeadCode(g, args, ctx) {
|
|
|
162
143
|
: `${candidate.owner ? `${candidate.owner}.` : ''}${candidate.symbol || candidate.id}`
|
|
163
144
|
const where = `${candidate.file}${candidate.line ? `:${candidate.line}` : ''}`
|
|
164
145
|
return [
|
|
165
|
-
`${index + 1}. [${candidate.confidence}/${candidate.classification}] ${subject} (${where})`,
|
|
146
|
+
`${index + 1}. [${candidate.confidence}/${candidate.evidenceTier}/${candidate.classification}] ${subject} (${where})`,
|
|
166
147
|
` evidence: ${candidate.evidence.map((item) => item.fact).join(' ')}`,
|
|
167
148
|
candidate.caveats.length ? ` caution: ${candidate.caveats.join(' ')}` : null,
|
|
149
|
+
` remaining: ${candidate.remainingChecks.join(' ')}`,
|
|
168
150
|
].filter(Boolean).join('\n')
|
|
169
151
|
})
|
|
170
152
|
const text = [
|
|
171
153
|
`Dead-code review: ${shown.length} of ${review.candidates.length} candidate(s) shown (high ${counts.high}, medium ${counts.medium}, low ${counts.low}).`,
|
|
154
|
+
`Evidence tiers: strong static ${review.totals.byEvidenceTier.strongStatic}, bounded static ${review.totals.byEvidenceTier.boundedStatic}, high uncertainty ${review.totals.byEvidenceTier.highUncertainty}.`,
|
|
172
155
|
`Verdict: REVIEW_REQUIRED. This is static evidence, never permission to auto-delete or bulk-delete.`,
|
|
173
156
|
suppression ? `Suppressed by current filters: ${suppression}.` : null,
|
|
174
157
|
review.suppressed.confidence ? 'Use min_confidence=low only when public/framework/dynamic candidates need explicit review.' : null,
|
|
@@ -196,7 +179,10 @@ const SEVERITY_RANK = {critical: 0, high: 1, medium: 2, low: 3, info: 4}
|
|
|
196
179
|
|
|
197
180
|
export function formatAuditFinding(f) {
|
|
198
181
|
const where = f.file ? ` (${f.file}${f.symbol ? ` ${f.symbol}` : ''})` : f.package ? ` (pkg ${f.package}${f.version ? `@${f.version}` : ''}${f.manifest ? `; ${f.manifest}` : ''})` : ''
|
|
199
|
-
|
|
182
|
+
const verification = f.verification?.evidenceModel
|
|
183
|
+
? `\n verification: ${f.verification.evidenceModel}; manifest ${f.verification.manifestDeclaration?.status || 'N/A'}; indexed imports ${f.verification.indexedSourceImports?.status || 'N/A'}; decision ${f.verification.decision || 'REVIEW_REQUIRED'}`
|
|
184
|
+
: ''
|
|
185
|
+
return ` [${f.severity}/${f.confidence || '?'}] ${f.rule}: ${f.title}${where}${f.reason ? `\n reason: ${f.reason}` : ''}${verification}${f.cycleRoute ? `\n route: ${f.cycleRoute}` : ''}${f.fixHint ? `\n fix: ${f.fixHint}` : ''}`
|
|
200
186
|
}
|
|
201
187
|
|
|
202
188
|
const auditFilter = (audit, args, findings = audit.findings) => {
|
|
@@ -234,7 +220,7 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
234
220
|
heading,
|
|
235
221
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
236
222
|
...auditConventionLines(audit),
|
|
237
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
223
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
238
224
|
`Scoped severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Scoped categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
239
225
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
240
226
|
'',
|
|
@@ -428,7 +414,7 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
428
414
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
429
415
|
`Severity: critical ${sev.critical}, high ${sev.high}, medium ${sev.medium}, low ${sev.low}, info ${sev.info}. Categories: unused ${bycat.unused}, structure ${bycat.structure}, vulnerability ${bycat.vulnerability}, malware ${bycat.malware}.`,
|
|
430
416
|
`Structure: ${audit.structureReport?.runtimeCycles ?? audit.structureReport?.cycles ?? 0} runtime cycle(s), ${audit.structureReport?.compileTimeCouplings ?? audit.structureReport?.typeCouplings ?? 0} compile-time coupling group(s), ${audit.structureReport?.orphans ?? 0} orphan(s); import edges: ${audit.structureReport?.runtimeImportEdges ?? audit.structureReport?.importEdges ?? 0} runtime + ${audit.structureReport?.typeOnlyImportEdges ?? 0} type-only + ${audit.structureReport?.compileOnlyImportEdges ?? 0} compile-only. Dead: ${audit.deadReport.deadFiles} file(s), ${audit.deadReport.unusedExports} unused export(s).`,
|
|
431
|
-
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}.`,
|
|
417
|
+
`Dependency manifests: ${deps.status || 'UNKNOWN'} — checked ${deps.declared ?? audit.scanned.manifestDeps ?? 0} declared package(s) against ${deps.importRecords ?? audit.scanned.externalImports ?? 0} external import record(s); unused ${deps.unused ?? 'unknown'}, missing ${deps.missing ?? 'unknown'}, duplicate declarations ${deps.duplicateDeclarations ?? 'unknown'}. npm per-finding manifest/source/config verification: ${deps.perFindingVerification ? 'AVAILABLE' : 'UNAVAILABLE'}.`,
|
|
432
418
|
...auditConventionLines(audit),
|
|
433
419
|
`Checks: ${check('OSV', audit.checks?.osv)}; ${check('malware', audit.checks?.malware)}. A NOT_CHECKED/PARTIAL/ERROR check is incomplete or unknown, never a clean zero.`,
|
|
434
420
|
``,
|
|
@@ -540,6 +526,7 @@ export function tHotPathReview(g, args, ctx) {
|
|
|
540
526
|
const text = [
|
|
541
527
|
`Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
|
|
542
528
|
`Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
|
|
529
|
+
`Selection: ${review.selectionPolicy.mode}${review.selectionPolicy.strongLocalFallback ? '; strong local sort/recursion/deep-loop evidence can pass below the blended score gate' : '; strict explicit score gate'}.`,
|
|
543
530
|
coverageLine,
|
|
544
531
|
'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
|
|
545
532
|
'',
|
|
@@ -557,7 +544,7 @@ export function tHotPathReview(g, args, ctx) {
|
|
|
557
544
|
` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
|
|
558
545
|
]
|
|
559
546
|
}) : [' (none at the selected thresholds)']),
|
|
560
|
-
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n, min_score
|
|
547
|
+
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n to display more, raise min_score or narrow path to tighten; lower min_score to broaden).` : null,
|
|
561
548
|
'',
|
|
562
549
|
'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
|
|
563
550
|
].filter((line) => line != null).join('\n')
|
|
@@ -244,6 +244,7 @@ export function tChangeImpactV2(g, args = {}, ctx = {}) {
|
|
|
244
244
|
},
|
|
245
245
|
testEvidence: {
|
|
246
246
|
actualCoverage: hasCoverage ? 'AVAILABLE' : 'NOT_AVAILABLE',
|
|
247
|
+
changedFiles: changed.slice(0, 500).map((file) => ({file, ...testEvidenceFor(file)})),
|
|
247
248
|
staticTestReachability: {
|
|
248
249
|
kind: staticTests.kind,
|
|
249
250
|
testFiles: staticTests.testFiles,
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {findSeeds, rawGraph} from './graph-context.mjs'
|
|
2
|
+
import {toolResult} from './tool-result.mjs'
|
|
3
|
+
import {expandTaskQuery, retrieveTaskContext} from '../analysis/task-retrieval.js'
|
|
4
|
+
import {extractCallArgumentEvidence} from '../analysis/data-flow-evidence.js'
|
|
5
|
+
import {runAllowedTests, validateTestRequests} from '../analysis/allowed-test-runner.js'
|
|
6
|
+
import {compareDuplicateGroups} from '../analysis/duplicate-groups.js'
|
|
7
|
+
import {buildGraphAtGitRef} from '../analysis/git-ref-graph.js'
|
|
8
|
+
import {diffGraphs, formatGraphDiff} from './graph-diff.mjs'
|
|
9
|
+
|
|
10
|
+
const richResult = (value) => value?.__weavatrixToolResult === true ? value.result : null
|
|
11
|
+
const normalizeFile = (value) => String(value || '').replace(/\\/g, '/')
|
|
12
|
+
const changedFilesOf = (impact) => [...new Set((impact?.changes || []).flatMap((change) => [change.path, change.oldPath, change.newPath]).map(normalizeFile).filter((file) => file && file !== '(diff unavailable)'))]
|
|
13
|
+
const targetTests = (impact) => [...new Set([
|
|
14
|
+
...(impact?.testEvidence?.changedFiles || []).map((item) => item.staticTestReachability?.test),
|
|
15
|
+
...(impact?.blastRadius?.nodes || []).map((node) => node.testEvidence?.staticTestReachability?.test),
|
|
16
|
+
].filter(Boolean))].slice(0, 30)
|
|
17
|
+
|
|
18
|
+
function testCoverage(proof, requests, suggested) {
|
|
19
|
+
if (!suggested.length) return {state: 'NOT_APPLICABLE', covered: [], missing: []}
|
|
20
|
+
if (proof.state !== 'PASS') return {state: 'PENDING', covered: [], missing: suggested}
|
|
21
|
+
if ((requests || []).some((request) => request?.script === 'test' && (!request.args || !request.args.length))) {
|
|
22
|
+
return {state: 'COMPLETE', kind: 'full-test-script', covered: suggested, missing: []}
|
|
23
|
+
}
|
|
24
|
+
const args = (requests || []).flatMap((request) => request?.args || []).map(normalizeFile)
|
|
25
|
+
const covered = suggested.filter((file) => args.some((arg) => arg === file || arg.endsWith(`/${file}`)))
|
|
26
|
+
return {state: covered.length === suggested.length ? 'COMPLETE' : 'PARTIAL', kind: 'explicit-test-paths', covered, missing: suggested.filter((file) => !covered.includes(file))}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function graphProof(diff) {
|
|
30
|
+
const runtimeCycles = diff?.cycles?.runtime?.introduced || []
|
|
31
|
+
return {
|
|
32
|
+
state: runtimeCycles.length ? 'BLOCKED' : diff.schemaMigration ? 'UNKNOWN' : 'PASS',
|
|
33
|
+
...(diff.schemaMigration ? {reason: 'graph extractor/schema versions differ, so structural ratchets are not comparable'} : {}),
|
|
34
|
+
summary: formatGraphDiff(diff),
|
|
35
|
+
counts: {
|
|
36
|
+
nodesAdded: diff.nodes.added.length, nodesRemoved: diff.nodes.removed.length,
|
|
37
|
+
edgesAdded: diff.edges.added, edgesRemoved: diff.edges.removed,
|
|
38
|
+
moduleDependenciesAdded: diff.moduleEdges.added.length, orphaned: diff.orphaned.length,
|
|
39
|
+
runtimeCyclesIntroduced: runtimeCycles.length,
|
|
40
|
+
},
|
|
41
|
+
runtimeCycles: runtimeCycles.slice(0, 20), moduleDependenciesAdded: diff.moduleEdges.added.slice(0, 20),
|
|
42
|
+
orphaned: diff.orphaned.slice(0, 20), schemaMigration: diff.schemaMigration,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function baselineProof(ctx, baseRef, currentGraph) {
|
|
47
|
+
const mode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode) ? currentGraph.graphBuildMode : 'full'
|
|
48
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, baseRef, {mode})
|
|
49
|
+
if (!built.ok) return {state: 'UNKNOWN', reason: built.error}
|
|
50
|
+
return {...graphProof(diffGraphs(built.graph, currentGraph)), baseline: {ref: built.ref, commit: built.commit}}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function apiState(result) {
|
|
54
|
+
if (!result || result.status !== 'COMPLETE' || result.completeness?.complete !== true) return 'UNKNOWN'
|
|
55
|
+
if (['HTTP_METHOD_MISMATCH', 'CLIENTS_AT_RISK_WITH_METHOD_MISMATCHES'].includes(result.verdict?.code)) return 'BLOCKED'
|
|
56
|
+
return 'UNKNOWN'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function architectureState(result) {
|
|
60
|
+
if (!result || result.state === 'NOT_CONFIGURED' || result.state === 'ERROR') return 'UNKNOWN'
|
|
61
|
+
if (!result.verification) return result.state === 'READY' ? 'PASS' : 'UNKNOWN'
|
|
62
|
+
return result.verification.new?.length || String(result.verification.status).toUpperCase() === 'FAIL' ? 'BLOCKED' : 'PASS'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decide({phase, impact, graph, architecture, duplicates, api, tests, suggestedTests, testCoverageState}) {
|
|
66
|
+
if (phase === 'plan') return tests.state === 'BLOCKED'
|
|
67
|
+
? {verdict: 'BLOCKED', blockers: [`targeted test plan was rejected: ${tests.reason || 'invalid request'}`], unknowns: []}
|
|
68
|
+
: {verdict: 'UNKNOWN', blockers: [], unknowns: ['verification has not run; apply the edit and call verified_change with phase=verify']}
|
|
69
|
+
if (impact.status === 'COMPLETE' && !impact.changes?.length) return {verdict: 'PASS', blockers: [], unknowns: []}
|
|
70
|
+
const blockers = [], unknowns = []
|
|
71
|
+
if (impact.status !== 'COMPLETE') unknowns.push('change impact is partial or has unmapped evidence')
|
|
72
|
+
if (graph.state === 'BLOCKED') blockers.push('the change introduces a runtime dependency cycle')
|
|
73
|
+
if (graph.state === 'UNKNOWN') unknowns.push(`Git graph baseline is unavailable: ${graph.reason || 'unknown reason'}`)
|
|
74
|
+
if (architecture.state === 'BLOCKED') blockers.push('new architecture-contract violations were found')
|
|
75
|
+
if (architecture.state === 'UNKNOWN') unknowns.push('architecture contract is not configured or verification is incomplete')
|
|
76
|
+
if (duplicates.state === 'BLOCKED') blockers.push('new duplicate groups intersect the changed files')
|
|
77
|
+
if (duplicates.state === 'UNKNOWN') unknowns.push(`duplicate ratchet is incomplete: ${duplicates.reason || 'health capability unavailable'}`)
|
|
78
|
+
if (api.state === 'BLOCKED') blockers.push('cross-repository API evidence contains HTTP method mismatches')
|
|
79
|
+
if (api.state === 'UNKNOWN') unknowns.push(`API contract evidence is incomplete: ${api.reason || 'no bounded proof'}`)
|
|
80
|
+
if (tests.state === 'FAIL' || tests.state === 'BLOCKED') {
|
|
81
|
+
const failed = (tests.results || []).filter((result) => result.status !== 'PASS').map((result) => `${result.script} (${result.status}${result.exitCode == null ? '' : `, exit ${result.exitCode}`})`)
|
|
82
|
+
blockers.push(`targeted tests failed or were rejected: ${tests.reason || failed.join(', ') || 'unknown failure'}`)
|
|
83
|
+
}
|
|
84
|
+
if (tests.state === 'DISABLED') unknowns.push('targeted test execution was requested but runtime permission is disabled')
|
|
85
|
+
if (tests.state === 'NOT_REQUESTED' && suggestedTests.length) unknowns.push('affected tests were identified but no allowlisted package test was requested')
|
|
86
|
+
if (tests.state === 'PASS' && testCoverageState.state === 'PARTIAL') unknowns.push(`targeted test run did not cover ${testCoverageState.missing.length} suggested test path(s)`)
|
|
87
|
+
return {verdict: blockers.length ? 'BLOCKED' : unknowns.length ? 'UNKNOWN' : 'PASS', blockers, unknowns}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function tVerifiedChange(g, args = {}, ctx = {}, tools = {}, permissions = {}) {
|
|
91
|
+
const phase = args.phase === 'verify' ? 'verify' : 'plan'
|
|
92
|
+
const currentGraph = rawGraph(ctx)
|
|
93
|
+
const impactValue = await tools.impact(g, {base: args.base_ref, diff: args.diff, files: args.files, depth: args.impact_depth, max_nodes: args.max_impact_nodes}, ctx)
|
|
94
|
+
const impact = richResult(impactValue) || {status: 'PARTIAL', changes: [], seeds: {ids: []}, blastRadius: {nodes: []}}
|
|
95
|
+
const changedFiles = changedFilesOf(impact)
|
|
96
|
+
const retrieval = retrieveTaskContext(g, {
|
|
97
|
+
task: args.task, semanticSeeds: findSeeds(g, expandTaskQuery(args.task), 12, {repoRoot: ctx.repoRoot}),
|
|
98
|
+
changedSeedIds: impact.seeds?.ids, maxSymbols: args.max_symbols, repoRoot: ctx.repoRoot,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
let contexts = []
|
|
102
|
+
if (permissions.source) contexts = await Promise.all(retrieval.selected.map(async (symbol) => {
|
|
103
|
+
const value = await tools.context(g, {label: symbol.id, precision: args.precision, max_related: 8, max_source_files: 3, context_lines: 4}, ctx, tools.inspect)
|
|
104
|
+
const result = richResult(value)
|
|
105
|
+
return result ? {symbol: symbol.id, status: result.status, definition: result.definition, evidence: result.evidence, references: result.references, inbound: result.inbound, outbound: result.outbound, reExports: result.reExports, source: result.source} : {symbol: symbol.id, status: 'UNKNOWN'}
|
|
106
|
+
}))
|
|
107
|
+
const dataFlow = permissions.source ? extractCallArgumentEvidence({
|
|
108
|
+
graph: g, repoRoot: ctx.repoRoot, seedIds: retrieval.selected.map((item) => item.id),
|
|
109
|
+
depth: Math.max(1, Math.min(3, Number(args.data_flow_depth) || 2)),
|
|
110
|
+
maxEdges: Math.max(1, Math.min(60, Number(args.max_data_flow_edges) || 30)),
|
|
111
|
+
})
|
|
112
|
+
: {model: 'bounded call-argument-to-parameter evidence (not CFG or taint analysis)', status: 'UNAVAILABLE', reason: 'source capability is not enabled', edges: [], unsupportedEdges: 0, capped: false}
|
|
113
|
+
const suggestedTests = targetTests(impact)
|
|
114
|
+
const checkedTests = validateTestRequests(ctx.repoRoot, args.tests || [])
|
|
115
|
+
const testProof = phase === 'verify'
|
|
116
|
+
? await runAllowedTests(ctx.repoRoot, args.tests || [], {enabled: args.run_tests === true, timeoutMs: args.test_timeout_ms})
|
|
117
|
+
: {state: checkedTests.ok ? 'PLANNED' : 'BLOCKED', reason: checkedTests.reason, plan: checkedTests.tests || [], results: []}
|
|
118
|
+
const testCoverageState = testCoverage(testProof, args.tests || [], suggestedTests)
|
|
119
|
+
|
|
120
|
+
const architectureValue = phase === 'verify'
|
|
121
|
+
? (permissions.health ? tools.verifyArchitecture(g, {}, ctx) : null)
|
|
122
|
+
: tools.prepareChange(g, {intent: args.task, files: changedFiles}, ctx)
|
|
123
|
+
const architectureResult = richResult(architectureValue)
|
|
124
|
+
const architecture = {state: architectureState(architectureResult), evidence: architectureResult}
|
|
125
|
+
const baseRef = String(args.base_ref || 'HEAD').trim()
|
|
126
|
+
const graph = phase === 'verify' ? await baselineProof(ctx, baseRef, currentGraph) : {state: 'PLANNED', baseline: baseRef}
|
|
127
|
+
|
|
128
|
+
let duplicates = {state: 'SKIPPED', reason: 'duplicate ratchet disabled'}
|
|
129
|
+
if (phase === 'verify' && args.duplicate_ratchet !== false) duplicates = permissions.health
|
|
130
|
+
? await compareDuplicateGroups({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, currentGraph, baseRef, changedFiles, args: {mode: 'renamed', min_similarity: 80, min_tokens: 50}})
|
|
131
|
+
: {state: 'UNKNOWN', reason: 'health capability is not enabled'}
|
|
132
|
+
|
|
133
|
+
let api = {state: 'SKIPPED', reason: 'no api_contract scope was requested'}
|
|
134
|
+
if (args.api_contract) {
|
|
135
|
+
if (!permissions.crossrepo) api = {state: 'UNKNOWN', reason: 'crossrepo capability is not enabled'}
|
|
136
|
+
else {
|
|
137
|
+
const result = richResult(await tools.traceApi(g, {
|
|
138
|
+
...args.api_contract, changed_files: args.api_contract.changed_files || changedFiles,
|
|
139
|
+
max_endpoints: Math.min(100, Number(args.api_contract.max_endpoints) || 100),
|
|
140
|
+
max_matches: Math.min(500, Number(args.api_contract.max_matches) || 500),
|
|
141
|
+
max_affected_files: Math.min(100, Number(args.api_contract.max_affected_files) || 100),
|
|
142
|
+
top_n: Math.min(10, Number(args.api_contract.top_n) || 10),
|
|
143
|
+
}, ctx))
|
|
144
|
+
api = {state: apiState(result), evidence: result}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const decision = decide({phase, impact, graph, architecture, duplicates, api, tests: testProof, suggestedTests, testCoverageState})
|
|
149
|
+
if (!permissions.source && retrieval.selected.length) decision.unknowns.push('source capability is disabled; exact LSP/source edit contexts were not collected')
|
|
150
|
+
if (phase === 'verify' && contexts.some((item) => item.status !== 'OK' || item.evidence?.state !== 'EXACT' || item.references?.capped)) {
|
|
151
|
+
decision.unknowns.push('one or more exact edit contexts are incomplete')
|
|
152
|
+
}
|
|
153
|
+
if (phase === 'verify' && args.diff) decision.unknowns.push('a supplied diff was classified, but equivalence between that patch and the active graph is not proven; verify on a checked-out change without diff')
|
|
154
|
+
if (decision.verdict === 'PASS' && decision.unknowns.length) decision.verdict = 'UNKNOWN'
|
|
155
|
+
const result = {
|
|
156
|
+
schemaVersion: 'weavatrix.verified-change.v1', verdict: decision.verdict, phase, task: String(args.task),
|
|
157
|
+
blockers: decision.blockers, unknowns: decision.unknowns,
|
|
158
|
+
retrieval, editContexts: contexts, dataFlow, changeImpact: impact, graphBaseline: graph,
|
|
159
|
+
architecture, duplicates, apiContract: api, tests: {...testProof, suggestedFiles: suggestedTests, coverage: testCoverageState},
|
|
160
|
+
}
|
|
161
|
+
const text = [
|
|
162
|
+
`${decision.verdict} — verified_change ${phase}`, `Task: ${String(args.task).slice(0, 500)}`,
|
|
163
|
+
`Change: ${changedFiles.length} file(s), ${impact.seeds?.ids?.length || 0} exact seed(s), blast radius ${impact.blastRadius?.impacted || 0}.`,
|
|
164
|
+
`Edit context: ${retrieval.selected.length} symbol(s); ${contexts.length} exact bundle(s); data-flow ${dataFlow.status} (${dataFlow.edges.length} call edge(s)).`,
|
|
165
|
+
`Ratchets: graph ${graph.state}; architecture ${architecture.state}; duplicates ${duplicates.state}; API ${api.state}; tests ${testProof.state}.`,
|
|
166
|
+
...decision.blockers.map((item) => `BLOCKER: ${item}`), ...decision.unknowns.map((item) => `UNKNOWN: ${item}`),
|
|
167
|
+
].join('\n')
|
|
168
|
+
return toolResult(text, result, {completeness: {status: decision.verdict === 'UNKNOWN' ? 'PARTIAL' : 'COMPLETE'}})
|
|
169
|
+
}
|
|
@@ -8,7 +8,7 @@ const URL_RE = /https?:\/\/[^\s'"`)\\<>]+/gi;
|
|
|
8
8
|
const PACKAGE_JSON_SCRIPT_RE = /["']?(preinstall|install|postinstall|prepare|prepack|postpack|prepublish|prepublishonly)["']?\s*:/i;
|
|
9
9
|
const PACKAGE_JSON_METADATA_URL_RE = /["']?(bugs|homepage|repository|funding|author|contributors|maintainers|publishConfig|license)["']?\s*:|["']?url["']?\s*:/i;
|
|
10
10
|
const NETWORK_CALL_RE = /\b(fetch|axios\.|XMLHttpRequest|sendBeacon|https?\.request|request\(|curl\b|wget\b|iwr\b|invoke-webrequest)\b/i;
|
|
11
|
-
const
|
|
11
|
+
const INSTALL_SCRIPT_HOOKS = ["preinstall", "install", "postinstall"];
|
|
12
12
|
|
|
13
13
|
function cleanUrlToken(value) {
|
|
14
14
|
return String(value || "").trim().replace(/[.,;:!?]+$/g, "").replace(/[)\]}]+$/g, "");
|
|
@@ -102,8 +102,8 @@ export function isManifestUrlNoise(file, signal) {
|
|
|
102
102
|
return /(^|\/)(pkg-info|metadata)$/.test(f);
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
export function
|
|
106
|
-
for (const hook of
|
|
105
|
+
export function installScriptSnippet(scripts = {}) {
|
|
106
|
+
for (const hook of INSTALL_SCRIPT_HOOKS) {
|
|
107
107
|
const s = String(scripts?.[hook] || "").trim();
|
|
108
108
|
if (s) return `${hook}: ${s}`.slice(0, 200);
|
|
109
109
|
}
|
|
@@ -109,7 +109,10 @@ export function buildContentRoots(repoPath, installed) {
|
|
|
109
109
|
const roots = [];
|
|
110
110
|
const nmDir = join(repoPath, "node_modules");
|
|
111
111
|
const jsPkgDirs = existingDir(nmDir) ? listPkgDirs(nmDir) : [];
|
|
112
|
-
|
|
112
|
+
// Scan installed package roots, not the whole node_modules container. Package-manager caches,
|
|
113
|
+
// hosted release snapshots and other hidden metadata are not installed dependencies and may
|
|
114
|
+
// legitimately contain malware-signature fixtures (including Weavatrix's own rule source).
|
|
115
|
+
for (const item of jsPkgDirs) roots.push({ kind: "npm", root: item.dir, packages: 1, pathToPkg: pkgOfNodeModulesPath });
|
|
113
116
|
|
|
114
117
|
for (const sp of sitePackageRoots(repoPath)) {
|
|
115
118
|
const topMap = pyTopLevelMap(sp);
|
|
@@ -6,7 +6,7 @@ import { resolveRg } from "../scan/search.js";
|
|
|
6
6
|
import { classifyInstallScript, classifyResolvedUrl, isCloudSdkMetadataUse } from "./registry-sig.js";
|
|
7
7
|
import { fileHeuristicSweep } from "./malware-file-heuristics.js";
|
|
8
8
|
import { buildMalwareFindings } from "./malware-scoring.js";
|
|
9
|
-
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded,
|
|
9
|
+
import { parseMalwareExclusions, isManifestUrlNoise, isDocUrlNoise, isMalwareSignalExcluded, installScriptSnippet } from "./malware-heuristics.exclusions.js";
|
|
10
10
|
import { buildContentRoots, listPkgDirs } from "./malware-heuristics.roots.js";
|
|
11
11
|
import { rgSweep, nodeSweep } from "./malware-heuristics.sweep.js";
|
|
12
12
|
|
|
@@ -37,7 +37,9 @@ export async function scanMalware(repoPath, { installed = [], importedPkgs = new
|
|
|
37
37
|
for (const { pkg, dir } of pkgDirs) {
|
|
38
38
|
try {
|
|
39
39
|
const pj = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
40
|
-
|
|
40
|
+
// package-lock hasInstallScript describes install-time hooks. Development and
|
|
41
|
+
// publication hooks such as prepare do not constitute drift from that flag.
|
|
42
|
+
const scriptSnippet = installScriptSnippet(pj.scripts);
|
|
41
43
|
if (scriptSnippet && lockScriptMeta.get(`${pkg}@${pj.version || ""}`) === false) {
|
|
42
44
|
add(pkg, {
|
|
43
45
|
key: "install-script-drift",
|