weavatrix 0.2.4 → 0.2.6
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 +53 -11
- package/package.json +1 -1
- package/skill/SKILL.md +18 -7
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +9 -5
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/graph-analysis.aggregate.js +3 -1
- package/src/analysis/graph-analysis.refs.js +19 -11
- package/src/analysis/hot-path-review.js +228 -0
- package/src/analysis/internal-audit.run.js +36 -0
- package/src/analysis/source-complexity.report.js +5 -0
- package/src/analysis/source-complexity.walk.js +64 -10
- package/src/bounds.js +4 -0
- package/src/graph/builder/lang-js.js +71 -14
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +5 -3
- package/src/graph/incremental-refresh.js +4 -2
- package/src/graph/internal-builder.barrels.js +71 -5
- package/src/graph/internal-builder.build.js +77 -16
- package/src/mcp/catalog.mjs +12 -7
- package/src/mcp/graph-context.mjs +4 -0
- package/src/mcp/graph-diff.mjs +1 -1
- package/src/mcp/sync-payload.mjs +3 -2
- package/src/mcp/tools-actions.mjs +3 -1
- package/src/mcp/tools-context.mjs +164 -0
- package/src/mcp/tools-graph.mjs +2 -0
- package/src/mcp/tools-health.mjs +66 -5
- package/src/mcp/tools-impact.mjs +3 -3
- package/src/mcp/tools-source.mjs +238 -0
- package/src/precision/lsp-client.js +1 -1
- package/src/precision/lsp-overlay.js +29 -8
- package/src/precision/symbol-query.js +133 -0
package/src/mcp/catalog.mjs
CHANGED
|
@@ -26,9 +26,9 @@ 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-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', 'catalog.mjs']
|
|
30
30
|
|
|
31
|
-
function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
31
|
+
function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
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)},
|
|
@@ -36,7 +36,7 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
36
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, 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
|
-
{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).
|
|
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
42
|
{
|
|
@@ -84,11 +84,14 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
84
84
|
},
|
|
85
85
|
{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)},
|
|
86
86
|
{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)},
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
{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)},
|
|
88
|
+
{cap: 'source', refreshGraph: true, name: 'inspect_symbol', description: 'Inspect one exact symbol with an on-demand TypeScript/JavaScript LSP reference query, grouped occurrence containers, graph blast radius, complexity facts and bounded local source context. Ambiguous labels fail closed; point queries never replace the broad precision overlay.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_containers: {type: 'integer', minimum: 1, maximum: 50, default: 15}, context_lines: {type: 'integer', minimum: 0, maximum: 40, default: 8}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => ts.tInspectSymbol(g, a, ctx)},
|
|
89
|
+
{cap: 'source', refreshGraph: true, name: 'context_bundle', description: 'Return one compact, bounded source bundle for an exact symbol: definition, aggregated inbound/outbound containers, exact re-export sites, on-demand TS/JS reference evidence and a few focused source excerpts. Use before an edit when query_graph would be too broad.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Exact node ID or unambiguous symbol label'}, precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_references: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, max_related: {type: 'integer', minimum: 1, maximum: 30, default: 10}, max_reexports: {type: 'integer', minimum: 1, maximum: 100, default: 20}, max_source_files: {type: 'integer', minimum: 1, maximum: 8, default: 4}, context_lines: {type: 'integer', minimum: 0, maximum: 12, default: 4}, timeout_ms: {type: 'integer', minimum: 1000, maximum: 60000, default: 30000}}, required: ['label']}, run: (g, a, ctx) => tb.tContextBundle(g, a, ctx, ts.tInspectSymbol)},
|
|
90
|
+
{cap: 'health', name: 'find_duplicates', description: "Content-based clone detection over production code (MOSS winnowing over method bodies). Supports high-confidence small clones down to 12 tokens when min_tokens is lowered. 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', minimum: 12, maximum: 400, description: 'min fragment size, 12-400; 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)},
|
|
89
91
|
{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)},
|
|
90
92
|
{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)},
|
|
91
93
|
{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 bounded production-symbol hot paths 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. Local syntax cost stays separate from graph risk; 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: 0}, 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)},
|
|
92
95
|
{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)},
|
|
93
96
|
{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)},
|
|
94
97
|
{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)},
|
|
@@ -131,16 +134,18 @@ function buildTools({tg, ti, th, ta, tar, thi, tc}) {
|
|
|
131
134
|
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
132
135
|
export async function loadHotApi(version, capsArg) {
|
|
133
136
|
const v = version ? `?v=${version}` : ''
|
|
134
|
-
const [tg, ti, th, ta, tar, thi, tc] = await Promise.all([
|
|
137
|
+
const [tg, ti, th, ts, tb, ta, tar, thi, tc] = await Promise.all([
|
|
135
138
|
import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
|
|
136
139
|
import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
|
|
137
140
|
import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
|
|
141
|
+
import(new URL(`./tools-source.mjs${v}`, import.meta.url).href),
|
|
142
|
+
import(new URL(`./tools-context.mjs${v}`, import.meta.url).href),
|
|
138
143
|
import(new URL(`./tools-actions.mjs${v}`, import.meta.url).href),
|
|
139
144
|
import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
|
|
140
145
|
import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
|
|
141
146
|
import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
|
|
142
147
|
])
|
|
143
|
-
const all = buildTools({tg, ti, th, ta, tar, thi, tc})
|
|
148
|
+
const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc})
|
|
144
149
|
const raw = capsArg == null ? 'offline' : String(capsArg).trim()
|
|
145
150
|
const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
146
151
|
.flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
|
|
@@ -62,6 +62,10 @@ export function loadGraph(path, {repoRoot = null} = {}) {
|
|
|
62
62
|
edgeTypesV: Number(raw.edgeTypesV) || 0,
|
|
63
63
|
edgeProvenanceV: Number(raw.edgeProvenanceV) || 0,
|
|
64
64
|
barrelResolutionV: Number(raw.barrelResolutionV) || 0,
|
|
65
|
+
reExportOccurrencesV: Number(raw.reExportOccurrencesV) || 0,
|
|
66
|
+
symbolSpacesV: Number(raw.symbolSpacesV) || 0,
|
|
67
|
+
reExportOccurrences: Array.isArray(raw.reExportOccurrences) ? raw.reExportOccurrences : [],
|
|
68
|
+
jsExportRecords: raw.jsExportRecords && typeof raw.jsExportRecords === 'object' ? raw.jsExportRecords : {},
|
|
65
69
|
extractorSchemaV: Number(raw.extractorSchemaV) || 0,
|
|
66
70
|
extImportsV: Number(raw.extImportsV) || 0,
|
|
67
71
|
complexityV: Number(raw.complexityV) || 0,
|
package/src/mcp/graph-diff.mjs
CHANGED
|
@@ -35,7 +35,7 @@ function stableNodeIndex(graph) {
|
|
|
35
35
|
if (!raw) continue
|
|
36
36
|
const stem = terminalLineSuffix(raw)
|
|
37
37
|
const params = Number.isInteger(node?.complexity?.params) ? node.complexity.params : ''
|
|
38
|
-
const signature = [stem, node?.symbol_kind || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
|
|
38
|
+
const signature = [stem, node?.symbol_kind || '', node?.symbol_space || '', node?.exported === true ? 'exported' : '', params].join('\u0000')
|
|
39
39
|
if (!groups.has(signature)) groups.set(signature, [])
|
|
40
40
|
groups.get(signature).push(node)
|
|
41
41
|
}
|
package/src/mcp/sync-payload.mjs
CHANGED
|
@@ -95,7 +95,8 @@ const COMPLEXITY_NUMBERS = [
|
|
|
95
95
|
'startLine', 'endLine', 'loc', 'params', 'objectFields', 'branches', 'cyclomatic',
|
|
96
96
|
'loops', 'maxLoopDepth', 'returns', 'awaits', 'callCount', 'externalCalls',
|
|
97
97
|
'asyncBoundaries', 'allocations', 'objectLiterals', 'spreadCopies', 'sorts',
|
|
98
|
-
'linearOps', '
|
|
98
|
+
'linearOps', 'allocationsInLoops', 'copiesInLoops', 'linearOpsInLoops',
|
|
99
|
+
'sortsInLoops', 'recursionInLoops', 'timeRank', 'timeScore', 'memoryRank', 'memoryScore',
|
|
99
100
|
];
|
|
100
101
|
|
|
101
102
|
function sanitizeComplexity(value) {
|
|
@@ -179,7 +180,7 @@ function sanitizeNodeV3(value) {
|
|
|
179
180
|
else delete out.file_type;
|
|
180
181
|
if (sourceFile) out.source_file = sourceFile;
|
|
181
182
|
else delete out.source_file;
|
|
182
|
-
for (const key of ['symbol_kind', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
|
|
183
|
+
for (const key of ['symbol_kind', 'symbol_space', 'member_of', 'visibility']) setIf(out, key, safeToken(value[key], 128));
|
|
183
184
|
if (out.complexity) {
|
|
184
185
|
for (const key of ['family', 'scope', 'complexityScope', 'confidence']) {
|
|
185
186
|
const safe = safeToken(value.complexity?.[key], 32);
|
|
@@ -91,7 +91,9 @@ export async function tOpenRepo(g, args, ctx) {
|
|
|
91
91
|
savedPrecision = ['lsp', 'off'].includes(saved.graphPrecisionMode) ? saved.graphPrecisionMode : defaultPrecisionMode()
|
|
92
92
|
schemaUpgrade = !Number.isInteger(saved.edgeTypesV) || saved.edgeTypesV < 2
|
|
93
93
|
|| !Number.isInteger(saved.edgeProvenanceV) || saved.edgeProvenanceV < 1
|
|
94
|
-
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV <
|
|
94
|
+
|| !Number.isInteger(saved.extractorSchemaV) || saved.extractorSchemaV < 5
|
|
95
|
+
|| !Number.isInteger(saved.reExportOccurrencesV) || saved.reExportOccurrencesV < 1
|
|
96
|
+
|| !Number.isInteger(saved.symbolSpacesV) || saved.symbolSpacesV < 1
|
|
95
97
|
if (savedPrecision === 'lsp') {
|
|
96
98
|
const overlay = readPrecisionOverlay(graphPath, saved)
|
|
97
99
|
precisionUpgrade = !overlay || (typeof overlay.semanticInputFingerprint === 'string'
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
2
|
+
import {boundedInteger} from '../bounds.js'
|
|
3
|
+
import {isSymbol, labelOf} from './graph-context.mjs'
|
|
4
|
+
import {toolResult} from './tool-result.mjs'
|
|
5
|
+
|
|
6
|
+
const MAX_LINE_SAMPLES = 5
|
|
7
|
+
|
|
8
|
+
const fileOf = (g, id) => {
|
|
9
|
+
const node = g.byId.get(String(id))
|
|
10
|
+
return String(node?.source_file || (isSymbol(id) ? String(id).split('#')[0] : id))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function aggregateEdges(g, edges, cap) {
|
|
14
|
+
const groups = new Map()
|
|
15
|
+
for (const edge of edges || []) {
|
|
16
|
+
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
17
|
+
const id = String(edge.id)
|
|
18
|
+
const relation = String(edge.relation || 'references')
|
|
19
|
+
const key = `${id}\0${relation}`
|
|
20
|
+
let group = groups.get(key)
|
|
21
|
+
if (!group) {
|
|
22
|
+
group = {id, label: labelOf(g, id), file: fileOf(g, id), relation, count: 0, lines: []}
|
|
23
|
+
groups.set(key, group)
|
|
24
|
+
}
|
|
25
|
+
group.count++
|
|
26
|
+
if (Number.isInteger(edge.line) && group.lines.length < MAX_LINE_SAMPLES && !group.lines.includes(edge.line)) group.lines.push(edge.line)
|
|
27
|
+
if (edge.typeOnly === true) group.typeOnly = true
|
|
28
|
+
if (edge.compileOnly === true) group.compileOnly = true
|
|
29
|
+
}
|
|
30
|
+
const all = [...groups.values()].sort((left, right) => right.count - left.count
|
|
31
|
+
|| left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
|
|
32
|
+
return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sameOrigin = (occurrence, definition) => occurrence.originId === definition.id
|
|
36
|
+
|| (occurrence.originFile === definition.file && occurrence.originName === definition.name)
|
|
37
|
+
|
|
38
|
+
function exactReExportSites(g, definition, cap) {
|
|
39
|
+
const occurrences = Array.isArray(g.reExportOccurrences) ? g.reExportOccurrences : []
|
|
40
|
+
const exposures = new Map()
|
|
41
|
+
if (definition.exported) exposures.set(definition.file, new Set([definition.name]))
|
|
42
|
+
const shown = new Map()
|
|
43
|
+
const add = (occurrence, exported = occurrence.exported) => {
|
|
44
|
+
const key = `${occurrence.file}\0${occurrence.line}\0${exported}\0${occurrence.kind}`
|
|
45
|
+
if (shown.has(key)) return false
|
|
46
|
+
shown.set(key, {
|
|
47
|
+
file: occurrence.file,
|
|
48
|
+
line: occurrence.line,
|
|
49
|
+
kind: occurrence.kind,
|
|
50
|
+
exported,
|
|
51
|
+
imported: occurrence.imported,
|
|
52
|
+
targetFile: occurrence.targetFile,
|
|
53
|
+
typeOnly: occurrence.typeOnly === true,
|
|
54
|
+
...(occurrence.specifier ? {specifier: occurrence.specifier} : {}),
|
|
55
|
+
...(occurrence.originFile ? {originFile: occurrence.originFile, originName: occurrence.originName} : {}),
|
|
56
|
+
})
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
for (let pass = 0; pass <= occurrences.length; pass++) {
|
|
60
|
+
let changed = false
|
|
61
|
+
for (const occurrence of occurrences) {
|
|
62
|
+
if (occurrence.kind === 'star') {
|
|
63
|
+
const names = exposures.get(occurrence.targetFile)
|
|
64
|
+
if (!names) continue
|
|
65
|
+
for (const name of names) {
|
|
66
|
+
if (name === 'default') continue
|
|
67
|
+
changed = add(occurrence, name) || changed
|
|
68
|
+
let exported = exposures.get(occurrence.file)
|
|
69
|
+
if (!exported) exposures.set(occurrence.file, (exported = new Set()))
|
|
70
|
+
const before = exported.size
|
|
71
|
+
exported.add(name)
|
|
72
|
+
changed = exported.size !== before || changed
|
|
73
|
+
}
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
if (!sameOrigin(occurrence, definition)) continue
|
|
77
|
+
changed = add(occurrence) || changed
|
|
78
|
+
if (occurrence.kind !== 'namespace') {
|
|
79
|
+
let exported = exposures.get(occurrence.file)
|
|
80
|
+
if (!exported) exposures.set(occurrence.file, (exported = new Set()))
|
|
81
|
+
const before = exported.size
|
|
82
|
+
exported.add(occurrence.exported)
|
|
83
|
+
changed = exported.size !== before || changed
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (!changed) break
|
|
87
|
+
}
|
|
88
|
+
const all = [...shown.values()].sort((left, right) => left.file.localeCompare(right.file)
|
|
89
|
+
|| left.line - right.line || left.exported.localeCompare(right.exported))
|
|
90
|
+
return {total: all.length, shown: all.slice(0, cap), capped: all.length > cap}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function linesForGroups(title, groups) {
|
|
94
|
+
if (!groups.shown.length) return [`${title}: none`]
|
|
95
|
+
const lines = [`${title}: ${groups.total} container(s)${groups.capped ? ` (${groups.shown.length} shown)` : ''}`]
|
|
96
|
+
for (const group of groups.shown) {
|
|
97
|
+
const sites = group.lines.length ? `:${group.lines.join(',')}` : ''
|
|
98
|
+
lines.push(` ${group.count}× ${group.relation} ${group.label} [${group.file}${sites}]`)
|
|
99
|
+
}
|
|
100
|
+
return lines
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function textFor(result) {
|
|
104
|
+
if (result.status !== 'OK') return result.text || `Context bundle: ${result.status}`
|
|
105
|
+
const definition = result.definition
|
|
106
|
+
const lines = [
|
|
107
|
+
`Context bundle: ${definition.label} [${definition.id}]`,
|
|
108
|
+
`Definition: ${definition.file}:${definition.line} (${definition.kind}, ${definition.space} space)`,
|
|
109
|
+
`Evidence: ${result.evidence.state}; ${result.references.occurrences} exact reference occurrence(s) in ${result.references.files} file(s).`,
|
|
110
|
+
...linesForGroups('Inbound', result.inbound),
|
|
111
|
+
...linesForGroups('Outbound', result.outbound),
|
|
112
|
+
`Re-export sites: ${result.reExports.total}${result.reExports.capped ? ` (${result.reExports.shown.length} shown)` : ''}`,
|
|
113
|
+
]
|
|
114
|
+
for (const site of result.reExports.shown) lines.push(` ${site.file}:${site.line} ${site.kind} ${site.imported} → ${site.exported}${site.typeOnly ? ' [type]' : ''}`)
|
|
115
|
+
for (const source of result.source) lines.push('', `${source.role} source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
|
|
116
|
+
return lines.join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function tContextBundle(g, args = {}, ctx = {}, inspectSymbol) {
|
|
120
|
+
if (typeof inspectSymbol !== 'function') throw new Error('context_bundle requires inspect_symbol support')
|
|
121
|
+
const maxRelated = boundedInteger(args.max_related, 10, 1, 30)
|
|
122
|
+
const maxReExports = boundedInteger(args.max_reexports, 20, 1, 100)
|
|
123
|
+
const maxSourceFiles = boundedInteger(args.max_source_files, 4, 1, 8)
|
|
124
|
+
const inspected = await inspectSymbol(g, {
|
|
125
|
+
...args,
|
|
126
|
+
max_containers: Math.min(maxRelated, boundedInteger(args.max_containers, 10, 1, 30)),
|
|
127
|
+
context_lines: boundedInteger(args.context_lines, 4, 0, 12),
|
|
128
|
+
}, ctx)
|
|
129
|
+
const inspection = inspected?.result
|
|
130
|
+
if (!inspection || inspection.status !== 'OK') return inspected
|
|
131
|
+
const definition = {
|
|
132
|
+
...inspection.definition,
|
|
133
|
+
name: String(g.byId.get(inspection.definition.id)?.label || '').replace(/\(\)$/, ''),
|
|
134
|
+
}
|
|
135
|
+
const inbound = aggregateEdges(g, g.inn.get(definition.id), maxRelated)
|
|
136
|
+
const outbound = aggregateEdges(g, g.out.get(definition.id), maxRelated)
|
|
137
|
+
const reExports = exactReExportSites(g, definition, maxReExports)
|
|
138
|
+
const source = []
|
|
139
|
+
if (inspection.source.definition) source.push({role: 'Definition', ...inspection.source.definition})
|
|
140
|
+
for (const excerpt of inspection.source.callers || []) {
|
|
141
|
+
if (source.length >= maxSourceFiles) break
|
|
142
|
+
if (source.some((item) => item.file === excerpt.file && item.focusLine === excerpt.focusLine)) continue
|
|
143
|
+
source.push({role: 'Caller', ...excerpt})
|
|
144
|
+
}
|
|
145
|
+
const result = {
|
|
146
|
+
status: 'OK', definition, evidence: inspection.evidence,
|
|
147
|
+
references: {
|
|
148
|
+
occurrences: inspection.exact.occurrences,
|
|
149
|
+
files: inspection.exact.files,
|
|
150
|
+
containers: inspection.exact.containers,
|
|
151
|
+
capped: inspection.exact.capped,
|
|
152
|
+
},
|
|
153
|
+
inbound, outbound, reExports, source,
|
|
154
|
+
}
|
|
155
|
+
return toolResult(textFor(result), result, {
|
|
156
|
+
warnings: inspected.warnings,
|
|
157
|
+
completeness: {
|
|
158
|
+
status: inspection.evidence.state === 'EXACT' && !inspection.exact.capped && !inbound.capped && !outbound.capped && !reExports.capped ? 'complete' : 'bounded',
|
|
159
|
+
relatedLimit: maxRelated,
|
|
160
|
+
reExportLimit: maxReExports,
|
|
161
|
+
sourceFileLimit: maxSourceFiles,
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
}
|
package/src/mcp/tools-graph.mjs
CHANGED
|
@@ -48,6 +48,8 @@ export function tGraphStats(g, ctx) {
|
|
|
48
48
|
g.edgeProvenanceV ? `- Edge provenance: v${g.edgeProvenanceV} (${fmt(provenance.counts)}; ${provenance.complete ? 'complete' : `${provenance.counts.UNKNOWN} unclassified`})` : `- Edge provenance: unavailable (rebuild_graph required)`,
|
|
49
49
|
`- Semantic precision: ${precision.state}${precision.provider ? ` via ${precision.provider}${precision.providerVersion ? ` ${precision.providerVersion}` : ''}${precision.typescriptVersion ? ` (TypeScript ${precision.typescriptVersion})` : ''}` : ''}; ${precision.verifiedEdges || 0} EXACT_LSP edge(s), ${precision.queried || 0}/${precision.candidates || 0} bounded target(s) queried${precision.truncated ? ' (partial/truncated)' : ''}${precision.reason ? `; ${precision.reason}` : ''}`,
|
|
50
50
|
g.barrelResolutionV ? `- Barrel resolution: v${g.barrelResolutionV} (semantic tools look through JS/TS re-export facades)` : `- Barrel resolution: unavailable (rebuild_graph required for JS/TS barrel transparency)`,
|
|
51
|
+
g.reExportOccurrencesV ? `- Re-export occurrences: v${g.reExportOccurrencesV} (${g.reExportOccurrences.length} exact site(s))` : `- Re-export occurrences: unavailable (rebuild_graph required)`,
|
|
52
|
+
g.symbolSpacesV ? `- TypeScript symbol spaces: v${g.symbolSpacesV} (type/value identities separated)` : `- TypeScript symbol spaces: unavailable (rebuild_graph required)`,
|
|
51
53
|
`- Relations: ${fmt(relCount)}`,
|
|
52
54
|
Object.keys(confCount).length ? `- Legacy confidence: ${fmt(confCount)}` : null,
|
|
53
55
|
`- Communities: ${comm.size} (top by size: ${topComm.map(([c, n]) => `#${c}=${n}`).join(', ')})`,
|
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {summarizeCommunities, aggregateGraph} from '../analysis/graph-analysis.j
|
|
|
11
11
|
import {detectEndpoints} from '../analysis/endpoints.js'
|
|
12
12
|
import {computeStaticTestReachability} from '../analysis/static-test-reachability.js'
|
|
13
13
|
import {computeDeadCodeReview} from '../analysis/dead-code-review.js'
|
|
14
|
+
import {computeHotPathReview} from '../analysis/hot-path-review.js'
|
|
14
15
|
import {collectNonRuntimeRoots, collectPackageScopes, collectSourceTexts, readRepoJson} from '../analysis/internal-audit.collect.js'
|
|
15
16
|
import {entryFiles} from '../analysis/internal-audit.reach.js'
|
|
16
17
|
import {buildInternalGraph} from '../graph/internal-builder.js'
|
|
@@ -53,7 +54,7 @@ function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
|
|
|
53
54
|
export function tFindDuplicates(g, args, ctx) {
|
|
54
55
|
if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
|
|
55
56
|
const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
|
|
56
|
-
const tokMin = Math.min(400, Math.max(
|
|
57
|
+
const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
|
|
57
58
|
const mode = args.mode === 'strict' ? 'strict' : 'renamed'
|
|
58
59
|
const skipTests = args.include_tests ? false : true
|
|
59
60
|
const includeClassified = args.include_classified === true || args.include_non_product === true
|
|
@@ -61,7 +62,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
61
62
|
// semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
|
|
62
63
|
// (same name, drifted behavior). Token-clone pairing is skipped entirely.
|
|
63
64
|
if (args.mode === 'semantic') {
|
|
64
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
|
|
65
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true, minTokens: tokMin})
|
|
65
66
|
const frags = data.frags
|
|
66
67
|
const candidates = []
|
|
67
68
|
for (const twin of data.nameTwins || []) {
|
|
@@ -96,13 +97,14 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
96
97
|
})
|
|
97
98
|
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.`
|
|
98
99
|
}
|
|
99
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
|
|
100
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings, minTokens: tokMin})
|
|
100
101
|
const groups = groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
|
|
102
|
+
const smallPolicy = tokMin < 30 ? ' Small fragments below 30 tokens require at least 95% similarity and two shared bounded fingerprints.' : ''
|
|
101
103
|
const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
|
|
102
104
|
const suppressionNote = suppressed && !includeClassified
|
|
103
105
|
? ` ${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.`
|
|
104
106
|
: ''
|
|
105
|
-
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${suppressionNote}`
|
|
107
|
+
if (!groups.length) return `No clones at ≥${simMin}% similarity / ≥${tokMin} tokens (${mode} mode). Try lowering the thresholds.${smallPolicy}${suppressionNote}`
|
|
106
108
|
const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
|
|
107
109
|
const lines = top.map((grp, k) => {
|
|
108
110
|
const isStr = grp.members.some((f) => f.kind === 'string')
|
|
@@ -110,7 +112,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
110
112
|
const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
|
|
111
113
|
return [head, ...sites].join('\n')
|
|
112
114
|
})
|
|
113
|
-
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${suppressionNote}`
|
|
115
|
+
return `Found ${groups.length} clone group(s) (${mode} mode, ≥${simMin}%, ≥${tokMin} tok${includeStrings ? ', incl. large string literals' : ''}). Top ${top.length}:\n\n${lines.join('\n\n')}\n\nUse read_source on any two sites to compare, then extract shared logic.${smallPolicy}${suppressionNote}`
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
// Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
|
|
@@ -227,10 +229,12 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
227
229
|
const summary = summarizeFindings(findings)
|
|
228
230
|
const sev = summary.bySeverity
|
|
229
231
|
const bycat = summary.byCategory
|
|
232
|
+
const deps = audit.dependencyReport || {}
|
|
230
233
|
return [
|
|
231
234
|
heading,
|
|
232
235
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
233
236
|
...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'}.`,
|
|
234
238
|
`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}.`,
|
|
235
239
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
236
240
|
'',
|
|
@@ -418,11 +422,13 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
418
422
|
const shown = filtered.slice(0, max)
|
|
419
423
|
const sev = audit.summary.bySeverity
|
|
420
424
|
const bycat = audit.summary.byCategory
|
|
425
|
+
const deps = audit.dependencyReport || {}
|
|
421
426
|
const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
|
|
422
427
|
return [
|
|
423
428
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
424
429
|
`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}.`,
|
|
425
430
|
`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'}.`,
|
|
426
432
|
...auditConventionLines(audit),
|
|
427
433
|
`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.`,
|
|
428
434
|
``,
|
|
@@ -509,6 +515,61 @@ export function tModuleMap(g, args, ctx) {
|
|
|
509
515
|
].filter((line) => line != null).join('\n')
|
|
510
516
|
}
|
|
511
517
|
|
|
518
|
+
// Parser-backed local cost review. This keeps syntax, graph coupling and measured/static test
|
|
519
|
+
// evidence as separate fields so callers cannot mistake a ranking heuristic for profiler output.
|
|
520
|
+
export function tHotPathReview(g, args, ctx) {
|
|
521
|
+
const review = computeHotPathReview(rawGraph(ctx), {
|
|
522
|
+
repoRoot: ctx?.repoRoot || null,
|
|
523
|
+
path: args.path,
|
|
524
|
+
includeTests: args.include_tests === true,
|
|
525
|
+
includeClassified: args.include_classified === true,
|
|
526
|
+
topN: args.top_n,
|
|
527
|
+
cyclomaticThreshold: args.cyclomatic_threshold,
|
|
528
|
+
callThreshold: args.call_threshold,
|
|
529
|
+
loopDepthThreshold: args.loop_depth_threshold,
|
|
530
|
+
timeRankThreshold: args.time_rank_threshold,
|
|
531
|
+
minScore: args.min_score,
|
|
532
|
+
})
|
|
533
|
+
if (!review.ok) return toolResult(`Hot-path review refused: ${review.error}.`, review)
|
|
534
|
+
const pct = (value) => typeof value === 'number' ? `${Math.round(value * 100)}%` : String(value || 'NOT_AVAILABLE')
|
|
535
|
+
const coverageLine = review.coverage.actualCoverage === 'AVAILABLE'
|
|
536
|
+
? `Measured coverage: ${review.coverage.measuredFiles} file(s) from ${review.coverage.sources.join(', ') || 'coverage report'}.`
|
|
537
|
+
: review.coverage.staticReachability
|
|
538
|
+
? `actualCoverage: NOT_AVAILABLE; static test reachability ${review.coverage.staticReachability.reachableFiles}/${review.coverage.staticReachability.productFiles} product file(s).`
|
|
539
|
+
: 'actualCoverage: NOT_AVAILABLE; no measured or static test evidence was available.'
|
|
540
|
+
const text = [
|
|
541
|
+
`Local hot-path review: ${review.candidateSymbols} candidate(s) from ${review.analyzedSymbols} analyzed symbol(s); showing ${review.hotspots.length}.`,
|
|
542
|
+
`Thresholds: time rank >=${review.thresholds.timeRank}, cyclomatic >=${review.thresholds.cyclomatic}, calls >=${review.thresholds.calls}, loop depth >=${review.thresholds.loopDepth}, score >=${review.thresholds.minScore}.`,
|
|
543
|
+
coverageLine,
|
|
544
|
+
'Local syntax cost and graph coupling are separate. Scores are review priority, not measured runtime.',
|
|
545
|
+
'',
|
|
546
|
+
...(review.hotspots.length ? review.hotspots.flatMap((item, index) => {
|
|
547
|
+
const tests = item.testEvidence.actualCoverage === 'NOT_AVAILABLE'
|
|
548
|
+
? item.testEvidence.staticReachable ? `static-test d${item.testEvidence.distance}` : 'no-static-test-path'
|
|
549
|
+
: `coverage ${pct(item.testEvidence.actualCoverage)}`
|
|
550
|
+
const pointer = `${item.file}${item.startLine ? `:${item.startLine}${item.endLine > item.startLine ? `-${item.endLine}` : ''}` : ''}`
|
|
551
|
+
const evidence = item.sourceEvidence.length
|
|
552
|
+
? `\n evidence: ${item.sourceEvidence.map((entry) => `${entry.kind}@L${entry.line || '?'}${entry.detail ? ` (${entry.detail})` : ''}`).join('; ')}`
|
|
553
|
+
: ''
|
|
554
|
+
return [
|
|
555
|
+
` ${String(index + 1).padStart(2)}. score ${String(item.score).padStart(5)} syntax ${String(item.localSyntax.score).padStart(5)} graph ${String(item.graphRisk.score).padStart(5)} ${item.confidence}`,
|
|
556
|
+
` ${item.label} (${pointer}; fan-in ${item.graphRisk.fanIn}, fan-out ${item.graphRisk.fanOut}; ${tests})`,
|
|
557
|
+
` ${item.reasons.slice(0, 5).join('; ')}${evidence}`,
|
|
558
|
+
]
|
|
559
|
+
}) : [' (none at the selected thresholds)']),
|
|
560
|
+
review.bounds.truncated ? `... +${review.bounds.totalCandidates - review.bounds.returned} more (raise top_n, min_score, or narrow path).` : null,
|
|
561
|
+
'',
|
|
562
|
+
'Caveat: no interprocedural Big-O, recursion-bound, CFG, dead-store or taint-flow claim is made.',
|
|
563
|
+
].filter((line) => line != null).join('\n')
|
|
564
|
+
return toolResult(text, review, {
|
|
565
|
+
completeness: {
|
|
566
|
+
symbols: 'COMPLETE_FOR_INDEXED_GRAPH',
|
|
567
|
+
output: review.bounds.truncated ? 'BOUNDED' : 'COMPLETE',
|
|
568
|
+
coverage: review.coverage.actualCoverage,
|
|
569
|
+
},
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
512
573
|
// Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
|
|
513
574
|
// tests are never executed here) onto files and symbols, then rank refactor risk as
|
|
514
575
|
// connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -59,7 +59,7 @@ const impactKind = (entry) => {
|
|
|
59
59
|
// Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
|
|
60
60
|
// (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
|
|
61
61
|
// containing file, because importers depend on the file rather than the individual symbol.
|
|
62
|
-
export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
62
|
+
export function tGetDependents(g, {label, depth = 3, max_nodes = 40, include_container_importers = false} = {}) {
|
|
63
63
|
const info = resolveNodeInfo(g, label)
|
|
64
64
|
const n = info.node
|
|
65
65
|
if (!n) return `No node found matching "${label}".`
|
|
@@ -69,7 +69,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
69
69
|
const id = String(n.id)
|
|
70
70
|
const seeds = new Set([id])
|
|
71
71
|
let containingFile = null
|
|
72
|
-
if (isSymbol(id)) {
|
|
72
|
+
if (include_container_importers === true && isSymbol(id)) {
|
|
73
73
|
const container = (g.inn.get(id) || []).find((e) => e.relation === 'contains')
|
|
74
74
|
if (container) {
|
|
75
75
|
containingFile = String(container.id)
|
|
@@ -88,7 +88,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
88
88
|
return [
|
|
89
89
|
note,
|
|
90
90
|
`Dependents of ${n.label ?? id} (reverse calls/imports/inherits, depth ≤${maxDepth}): ${ranked.length} found (${runtimeCount} runtime, ${compileCount} compile-time-only), showing ${shown.length} by proximity + connectivity.`,
|
|
91
|
-
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
91
|
+
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)} by explicit request.` : null,
|
|
92
92
|
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} [${r.entry.provenance || 'UNKNOWN'}] ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
93
93
|
].filter(Boolean).join('\n')
|
|
94
94
|
}
|