weavatrix 0.2.3 → 0.2.5
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 +91 -20
- package/SECURITY.md +18 -0
- package/package.json +3 -1
- package/skill/SKILL.md +49 -13
- package/src/analysis/dead-check.js +48 -2
- package/src/analysis/dead-code-review.js +22 -8
- package/src/analysis/duplicates.compute.js +11 -6
- package/src/analysis/git-ref-graph.js +10 -2
- 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/build-graph.js +49 -10
- package/src/graph/build-worker.js +21 -1
- package/src/graph/builder/lang-java.js +1 -0
- package/src/graph/builder/lang-js.js +24 -0
- package/src/graph/builder/lang-python.js +161 -4
- package/src/graph/freshness-probe.js +3 -3
- package/src/graph/incremental-refresh.js +1 -1
- package/src/graph/internal-builder.barrels.js +33 -4
- package/src/graph/internal-builder.build.js +52 -8
- package/src/mcp/catalog.mjs +14 -11
- package/src/mcp/graph-context.mjs +143 -14
- package/src/mcp/sync-payload.mjs +2 -1
- package/src/mcp/tools-actions.mjs +59 -20
- package/src/mcp/tools-company.mjs +11 -3
- package/src/mcp/tools-graph.mjs +12 -6
- package/src/mcp/tools-health.mjs +112 -11
- package/src/mcp/tools-impact.mjs +24 -6
- package/src/mcp/tools-source.mjs +237 -0
- package/src/mcp-server.mjs +99 -11
- package/src/path-classification.js +2 -2
- package/src/precision/lsp-client.js +682 -0
- package/src/precision/lsp-overlay.js +872 -0
- package/src/precision/symbol-query.js +137 -0
- package/src/precision/typescript-lsp-provider.js +682 -0
package/src/mcp/tools-health.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Health tools: clone detection, the internal audit, community/module overviews, coverage mapping
|
|
2
2
|
// and the HTTP endpoint inventory. Hot-reloadable (re-imported by catalog.mjs on change).
|
|
3
3
|
import {spawnSync} from 'node:child_process'
|
|
4
|
-
import {degreeOf, rawGraph} from './graph-context.mjs'
|
|
4
|
+
import {degreeOf, rawGraph, effectiveRawGraph} from './graph-context.mjs'
|
|
5
5
|
import {computeDuplicates} from '../analysis/duplicates.js'
|
|
6
6
|
import {runInternalAudit} from '../analysis/internal-audit.js'
|
|
7
7
|
import {classifyChangeImpact} from '../analysis/change-classification.js'
|
|
@@ -11,14 +11,16 @@ 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'
|
|
17
18
|
import {resolveGitCommit, withGitRefCheckout} from '../analysis/git-ref-graph.js'
|
|
18
19
|
import {childProcessEnv} from '../child-env.js'
|
|
19
20
|
import {toolResult} from './tool-result.mjs'
|
|
20
|
-
import {createPathClassifier} from '../path-classification.js'
|
|
21
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
21
22
|
import {createRepoBoundary} from '../repo-path.js'
|
|
23
|
+
import {filterGraphForMode} from '../graph/graph-filter.js'
|
|
22
24
|
|
|
23
25
|
const NON_PRODUCT_DUPLICATE_CLASSES = new Set(['generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
24
26
|
const fragmentEligible = (fragment, {tokMin, skipTests, includeClassified}) => {
|
|
@@ -52,7 +54,7 @@ function groupClones(data, {simMin, tokMin, mode, skipTests, includeClassified})
|
|
|
52
54
|
export function tFindDuplicates(g, args, ctx) {
|
|
53
55
|
if (!ctx.repoRoot) return 'Duplicate scan needs the repo root (not provided to this server).'
|
|
54
56
|
const simMin = Math.min(100, Math.max(50, Number(args.min_similarity) || 80))
|
|
55
|
-
const tokMin = Math.min(400, Math.max(
|
|
57
|
+
const tokMin = Math.min(400, Math.max(12, Number(args.min_tokens) || 50))
|
|
56
58
|
const mode = args.mode === 'strict' ? 'strict' : 'renamed'
|
|
57
59
|
const skipTests = args.include_tests ? false : true
|
|
58
60
|
const includeClassified = args.include_classified === true || args.include_non_product === true
|
|
@@ -60,7 +62,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
60
62
|
// semantic mode: same-name symbols across files, ranked by size — LOW similarity is the signal
|
|
61
63
|
// (same name, drifted behavior). Token-clone pairing is skipped entirely.
|
|
62
64
|
if (args.mode === 'semantic') {
|
|
63
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true})
|
|
65
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {nameTwins: true, minTokens: tokMin})
|
|
64
66
|
const frags = data.frags
|
|
65
67
|
const candidates = []
|
|
66
68
|
for (const twin of data.nameTwins || []) {
|
|
@@ -95,13 +97,14 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
95
97
|
})
|
|
96
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.`
|
|
97
99
|
}
|
|
98
|
-
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings})
|
|
100
|
+
const data = computeDuplicates(ctx.repoRoot, ctx.graphPath, {includeStrings, minTokens: tokMin})
|
|
99
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.' : ''
|
|
100
103
|
const suppressed = data.frags.filter((fragment) => !fragmentEligible(fragment, {tokMin, skipTests, includeClassified})).length
|
|
101
104
|
const suppressionNote = suppressed && !includeClassified
|
|
102
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.`
|
|
103
106
|
: ''
|
|
104
|
-
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}`
|
|
105
108
|
const top = groups.slice(0, Math.min(30, Math.max(1, Number(args.top_n) || 15)))
|
|
106
109
|
const lines = top.map((grp, k) => {
|
|
107
110
|
const isStr = grp.members.some((f) => f.kind === 'string')
|
|
@@ -109,7 +112,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
109
112
|
const sites = grp.members.slice(0, 8).map((f) => ` ${f.file}:${f.start}-${f.end}`)
|
|
110
113
|
return [head, ...sites].join('\n')
|
|
111
114
|
})
|
|
112
|
-
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}`
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
// Focused dead-code review queue. Unlike the broad run_audit surface, this includes functions and
|
|
@@ -117,7 +120,7 @@ export function tFindDuplicates(g, args, ctx) {
|
|
|
117
120
|
// candidates. It never returns an automatic-delete verdict.
|
|
118
121
|
export function tFindDeadCode(g, args, ctx) {
|
|
119
122
|
if (!ctx.repoRoot) return 'Dead-code review needs the repo root (not provided to this server).'
|
|
120
|
-
const graph =
|
|
123
|
+
const graph = effectiveRawGraph(ctx)
|
|
121
124
|
const boundary = createRepoBoundary(ctx.repoRoot)
|
|
122
125
|
const pkg = readRepoJson(boundary, 'package.json') || {}
|
|
123
126
|
const rules = readRepoJson(boundary, '.weavatrix-deps.json') || {}
|
|
@@ -226,10 +229,12 @@ const formatOrdinaryAudit = (audit, args, findings = audit.findings, heading = n
|
|
|
226
229
|
const summary = summarizeFindings(findings)
|
|
227
230
|
const sev = summary.bySeverity
|
|
228
231
|
const bycat = summary.byCategory
|
|
232
|
+
const deps = audit.dependencyReport || {}
|
|
229
233
|
return [
|
|
230
234
|
heading,
|
|
231
235
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
232
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'}.`,
|
|
233
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}.`,
|
|
234
239
|
`Repository-level ${auditChecksLine(audit)}`,
|
|
235
240
|
'',
|
|
@@ -323,7 +328,12 @@ async function runAuditWithBaseline(args, ctx, currentGraph) {
|
|
|
323
328
|
}
|
|
324
329
|
|
|
325
330
|
const baseline = await withGitRefCheckout(ctx.repoRoot, resolved.commit, async (checkout) => {
|
|
326
|
-
|
|
331
|
+
let graph = await buildInternalGraph(checkout)
|
|
332
|
+
const currentMode = ['full', 'no-tests', 'tests-only'].includes(currentGraph?.graphBuildMode)
|
|
333
|
+
? currentGraph.graphBuildMode : 'full'
|
|
334
|
+
if (currentMode !== 'full') graph = filterGraphForMode(graph, currentMode, {repoRoot: checkout})
|
|
335
|
+
graph.graphBuildMode = currentMode
|
|
336
|
+
graph.graphBuildScope = ''
|
|
327
337
|
return runInternalAudit(checkout, {graph, skipMalwareScan: true})
|
|
328
338
|
})
|
|
329
339
|
if (!baseline.ok || !baseline.value?.ok) {
|
|
@@ -385,7 +395,7 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
385
395
|
if (!ctx.repoRoot) return 'Audit needs the repo root (not provided to this server).'
|
|
386
396
|
if (args.base_ref) return runAuditWithBaseline(args, ctx, rawGraph(ctx))
|
|
387
397
|
const audit = await runInternalAudit(ctx.repoRoot, {
|
|
388
|
-
graph:
|
|
398
|
+
graph: effectiveRawGraph(ctx),
|
|
389
399
|
skipMalwareScan: !args.include_malware_scan, // greps installed packages — slow, so opt-in
|
|
390
400
|
})
|
|
391
401
|
if (!audit.ok) return `Audit failed: ${audit.error}`
|
|
@@ -412,11 +422,13 @@ export async function tRunAudit(g, args, ctx) {
|
|
|
412
422
|
const shown = filtered.slice(0, max)
|
|
413
423
|
const sev = audit.summary.bySeverity
|
|
414
424
|
const bycat = audit.summary.byCategory
|
|
425
|
+
const deps = audit.dependencyReport || {}
|
|
415
426
|
const check = (name, state) => `${name} ${state?.status || 'ERROR'}${state?.detail ? ` — ${state.detail}` : ''}`
|
|
416
427
|
return [
|
|
417
428
|
`Internal audit of ${audit.repo} (${audit.scanned.files} files, ${audit.scanned.symbols} symbols, ${audit.scanned.externalImports} external imports; malware scan: ${audit.scanned.malwareScanMode}).`,
|
|
418
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}.`,
|
|
419
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'}.`,
|
|
420
432
|
...auditConventionLines(audit),
|
|
421
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.`,
|
|
422
434
|
``,
|
|
@@ -440,7 +452,36 @@ export function tListCommunities(g, args, ctx) {
|
|
|
440
452
|
// Folder-level architecture map: modules (top-two path segments) with file/symbol counts and the
|
|
441
453
|
// strongest module→module dependencies. Pure graph aggregation — no filesystem reads.
|
|
442
454
|
export function tModuleMap(g, args, ctx) {
|
|
443
|
-
const
|
|
455
|
+
const graph = rawGraph(ctx)
|
|
456
|
+
const testsOnly = graph.graphBuildMode === 'tests-only'
|
|
457
|
+
const includeNonProduct = args.include_non_product === true || testsOnly
|
|
458
|
+
const classifier = createPathClassifier(ctx?.repoRoot || null)
|
|
459
|
+
const nonProductFiles = new Set()
|
|
460
|
+
const classifiedFiles = new Set()
|
|
461
|
+
if (!includeNonProduct) {
|
|
462
|
+
for (const node of graph.nodes || []) {
|
|
463
|
+
if (!node?.source_file) continue
|
|
464
|
+
const sourceFile = String(node.source_file)
|
|
465
|
+
if (classifiedFiles.has(sourceFile)) continue
|
|
466
|
+
classifiedFiles.add(sourceFile)
|
|
467
|
+
const explanation = classifier.explain(sourceFile)
|
|
468
|
+
if (explanation.excluded || hasPathClass(explanation, 'test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp')) {
|
|
469
|
+
nonProductFiles.add(sourceFile)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const visibleGraph = includeNonProduct || nonProductFiles.size === 0 ? graph : (() => {
|
|
474
|
+
const keep = new Set((graph.nodes || [])
|
|
475
|
+
.filter((node) => !node?.source_file || !nonProductFiles.has(String(node.source_file)))
|
|
476
|
+
.map((node) => String(node.id)))
|
|
477
|
+
const endpoint = (value) => String(value && typeof value === 'object' ? value.id : value)
|
|
478
|
+
return {
|
|
479
|
+
...graph,
|
|
480
|
+
nodes: (graph.nodes || []).filter((node) => keep.has(String(node.id))),
|
|
481
|
+
links: (graph.links || []).filter((link) => keep.has(endpoint(link.source)) && keep.has(endpoint(link.target))),
|
|
482
|
+
}
|
|
483
|
+
})()
|
|
484
|
+
const agg = aggregateGraph(visibleGraph, null)
|
|
444
485
|
const topN = Math.max(1, Math.min(60, Number(args.top_n) || 25))
|
|
445
486
|
const mods = agg.modules.slice(0, topN)
|
|
446
487
|
const edges = agg.moduleEdges.slice(0, Math.min(50, topN * 2))
|
|
@@ -458,6 +499,11 @@ export function tModuleMap(g, args, ctx) {
|
|
|
458
499
|
collectCompileEdges(agg.compileOnlyModuleEdges, 'compileOnly')
|
|
459
500
|
const compiled = [...compileEdges.values()].sort((a, b) => b.count - a.count).slice(0, Math.min(50, topN * 2))
|
|
460
501
|
return [
|
|
502
|
+
testsOnly
|
|
503
|
+
? 'Scope: tests-only graph; classified test/e2e/fixture surfaces are retained automatically.'
|
|
504
|
+
: includeNonProduct
|
|
505
|
+
? 'Scope: all indexed files, including classified non-product surfaces.'
|
|
506
|
+
: `Scope: production-only (default); excluded ${nonProductFiles.size} classified test/fixture/benchmark/generated/docs file(s). Pass include_non_product:true for the complete graph.`,
|
|
461
507
|
`Module map: ${agg.totals.files} files in ${agg.modules.length} folder-modules, ${agg.totals.moduleEdges} runtime module dependencies and ${agg.totals.compileTimeModuleEdges || 0} compile-time dependencies (${agg.totals.typeOnlyModuleEdges || 0} type-only, ${agg.totals.compileOnlyModuleEdges || 0} compile-only). Top ${mods.length}:`,
|
|
462
508
|
...mods.map((m) => ` ${m.name} — ${m.fileCount} files, ${m.symbolCount} symbols`),
|
|
463
509
|
``,
|
|
@@ -469,6 +515,61 @@ export function tModuleMap(g, args, ctx) {
|
|
|
469
515
|
].filter((line) => line != null).join('\n')
|
|
470
516
|
}
|
|
471
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
|
+
|
|
472
573
|
// Coverage × graph: map an EXISTING coverage report (istanbul/lcov/coverage.py/Go — read offline,
|
|
473
574
|
// tests are never executed here) onto files and symbols, then rank refactor risk as
|
|
474
575
|
// connectivity × uncovered share. Pairs with get_dependents: many dependents + low coverage ⇒ write
|
package/src/mcp/tools-impact.mjs
CHANGED
|
@@ -31,9 +31,11 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
31
31
|
}
|
|
32
32
|
const depthKey = compileOnly ? 'compileDepth' : 'runtimeDepth'
|
|
33
33
|
const relationKey = compileOnly ? 'compileRelation' : 'runtimeRelation'
|
|
34
|
+
const provenanceKey = compileOnly ? 'compileProvenance' : 'runtimeProvenance'
|
|
34
35
|
if (entry[depthKey] != null && entry[depthKey] <= depth) continue
|
|
35
36
|
entry[depthKey] = depth
|
|
36
37
|
entry[relationKey] = e.relation || 'rel'
|
|
38
|
+
entry[provenanceKey] = e.provenance || 'UNKNOWN'
|
|
37
39
|
states.set(id, entry)
|
|
38
40
|
frontier.push({id, depth, compileOnly})
|
|
39
41
|
}
|
|
@@ -43,6 +45,7 @@ function reverseReach(g, seeds, maxDepth) {
|
|
|
43
45
|
depth: entry.runtimeDepth ?? entry.compileDepth ?? 0,
|
|
44
46
|
compileOnly: entry.runtimeDepth == null,
|
|
45
47
|
relation: entry.runtimeDepth != null ? entry.runtimeRelation : entry.compileRelation,
|
|
48
|
+
provenance: entry.runtimeDepth != null ? entry.runtimeProvenance : entry.compileProvenance,
|
|
46
49
|
}]))
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -56,7 +59,7 @@ const impactKind = (entry) => {
|
|
|
56
59
|
// Transitive blast-radius: who is affected if this node changes. Walks REVERSE dependency edges
|
|
57
60
|
// (calls/imports/inherits — not structural `contains`) out to `depth`. For a symbol, also seeds its
|
|
58
61
|
// containing file, because importers depend on the file rather than the individual symbol.
|
|
59
|
-
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} = {}) {
|
|
60
63
|
const info = resolveNodeInfo(g, label)
|
|
61
64
|
const n = info.node
|
|
62
65
|
if (!n) return `No node found matching "${label}".`
|
|
@@ -66,7 +69,7 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
66
69
|
const id = String(n.id)
|
|
67
70
|
const seeds = new Set([id])
|
|
68
71
|
let containingFile = null
|
|
69
|
-
if (isSymbol(id)) {
|
|
72
|
+
if (include_container_importers === true && isSymbol(id)) {
|
|
70
73
|
const container = (g.inn.get(id) || []).find((e) => e.relation === 'contains')
|
|
71
74
|
if (container) {
|
|
72
75
|
containingFile = String(container.id)
|
|
@@ -85,18 +88,22 @@ export function tGetDependents(g, {label, depth = 3, max_nodes = 40} = {}) {
|
|
|
85
88
|
return [
|
|
86
89
|
note,
|
|
87
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.`,
|
|
88
|
-
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)}.` : null,
|
|
89
|
-
...shown.map((r) => ` [d${r.d} ${impactKind(r.entry)}] ${r.entry.relation || 'rel'} ${labelOf(g, r.id)} (deg ${r.deg}) [${r.id}]`),
|
|
91
|
+
containingFile ? `Includes importers of its containing file ${labelOf(g, containingFile)} by explicit request.` : null,
|
|
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}]`),
|
|
90
93
|
].filter(Boolean).join('\n')
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
// Re-query the last rebuild's before/after pair (graph.prev.json vs graph.json), optionally scoped.
|
|
94
97
|
export async function tGraphDiff(g, args, ctx) {
|
|
98
|
+
const current = rawGraph(ctx)
|
|
99
|
+
const currentMode = ['full', 'no-tests', 'tests-only'].includes(current?.graphBuildMode)
|
|
100
|
+
? current.graphBuildMode
|
|
101
|
+
: 'full'
|
|
95
102
|
let prev
|
|
96
103
|
let baselineLabel = 'previous rebuild state'
|
|
97
104
|
if (args.base_ref) {
|
|
98
105
|
if (!ctx.repoRoot) return 'A Git-ref graph diff needs the active repository root.'
|
|
99
|
-
const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref)
|
|
106
|
+
const built = await buildGraphAtGitRef(ctx.repoRoot, args.base_ref, {mode: currentMode})
|
|
100
107
|
if (!built.ok) return `Could not build the baseline graph: ${built.error}`
|
|
101
108
|
prev = built.graph
|
|
102
109
|
baselineLabel = `${built.ref} (${built.commit.slice(0, 12)})`
|
|
@@ -106,7 +113,17 @@ export async function tGraphDiff(g, args, ctx) {
|
|
|
106
113
|
return `No previous graph state at ${prevPath} — pass base_ref (for example HEAD~1 or main), or run rebuild_graph to save one automatically.`
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
|
-
|
|
116
|
+
if (!args.base_ref) {
|
|
117
|
+
const previousMode = ['full', 'no-tests', 'tests-only'].includes(prev?.graphBuildMode)
|
|
118
|
+
? prev.graphBuildMode
|
|
119
|
+
: 'full'
|
|
120
|
+
if (previousMode !== currentMode) {
|
|
121
|
+
return [
|
|
122
|
+
`Graph diff unavailable: previous graph mode is ${previousMode}, current graph mode is ${currentMode}.`,
|
|
123
|
+
'Those node/edge universes are not comparable. Run rebuild_graph once more in the same mode, or pass base_ref to build a mode-matched immutable baseline.',
|
|
124
|
+
].join('\n')
|
|
125
|
+
}
|
|
126
|
+
}
|
|
110
127
|
const filter = args.path ? String(args.path).replace(/\\/g, '/') : null
|
|
111
128
|
const scope = (graph) => filter ? {
|
|
112
129
|
nodes: (graph.nodes || []).filter((n) => String(n.id).startsWith(filter)),
|
|
@@ -118,6 +135,7 @@ export async function tGraphDiff(g, args, ctx) {
|
|
|
118
135
|
} : graph
|
|
119
136
|
return [
|
|
120
137
|
`Graph diff (${baselineLabel} → current)${filter ? `, scoped to ${filter}` : ''}:`,
|
|
138
|
+
`Build mode: ${currentMode}`,
|
|
121
139
|
formatGraphDiff(diffGraphs(scope(prev), scope(current)))
|
|
122
140
|
].join('\n')
|
|
123
141
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {readFileSync, statSync} from 'node:fs'
|
|
2
|
+
import {resolveRepoPath} from '../repo-path.js'
|
|
3
|
+
import {isStructuralRelation} from '../graph/relations.js'
|
|
4
|
+
import {querySymbolPrecision} from '../precision/symbol-query.js'
|
|
5
|
+
import {toolResult} from './tool-result.mjs'
|
|
6
|
+
import {degreeOf, isSymbol, labelOf, resolveNodeInfo} from './graph-context.mjs'
|
|
7
|
+
|
|
8
|
+
const MAX_EXCERPT_CHARS = 4_000
|
|
9
|
+
const MAX_LOCATION_SAMPLES = 5
|
|
10
|
+
const MAX_GRAPH_OCCURRENCES = 100
|
|
11
|
+
|
|
12
|
+
const boundedInteger = (value, fallback, minimum, maximum) => {
|
|
13
|
+
const number = Number(value)
|
|
14
|
+
return Math.max(minimum, Math.min(maximum, Number.isFinite(number) ? Math.floor(number) : fallback))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sourceLine = (node) => {
|
|
18
|
+
const match = /L(\d+)/.exec(String(node?.source_location || ''))
|
|
19
|
+
return match ? Number(match[1]) : 1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function candidateNodes(g, query, limit = 20) {
|
|
23
|
+
const text = String(query || '').trim().toLowerCase()
|
|
24
|
+
if (!text) return []
|
|
25
|
+
const exact = g.byLabel.get(text)
|
|
26
|
+
const nodes = exact?.length ? exact : g.nodes.filter((node) =>
|
|
27
|
+
String(node.id).toLowerCase().includes(text) || String(node.label || '').toLowerCase().includes(text))
|
|
28
|
+
return nodes.slice(0, limit).map((node) => ({id: String(node.id), label: String(node.label || node.id), file: node.source_file || null}))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function excerpt(repoRoot, file, focusLine, contextLines) {
|
|
32
|
+
const resolved = resolveRepoPath(repoRoot, file)
|
|
33
|
+
if (!resolved.ok) return null
|
|
34
|
+
let size
|
|
35
|
+
try { size = statSync(resolved.path).size } catch { return null }
|
|
36
|
+
if (size > 2 * 1024 * 1024) return null
|
|
37
|
+
let lines
|
|
38
|
+
try { lines = readFileSync(resolved.path, 'utf8').split(/\r?\n/) } catch { return null }
|
|
39
|
+
const focus = Math.max(1, Math.min(lines.length, Number(focusLine) || 1))
|
|
40
|
+
const startLine = Math.max(1, focus - contextLines)
|
|
41
|
+
const endLine = Math.min(lines.length, focus + contextLines)
|
|
42
|
+
const rawText = lines.slice(startLine - 1, endLine).join('\n')
|
|
43
|
+
return {
|
|
44
|
+
file,
|
|
45
|
+
focusLine: focus,
|
|
46
|
+
startLine,
|
|
47
|
+
endLine,
|
|
48
|
+
text: rawText.length > MAX_EXCERPT_CHARS ? `${rawText.slice(0, MAX_EXCERPT_CHARS)}\n… [excerpt truncated]` : rawText,
|
|
49
|
+
truncated: rawText.length > MAX_EXCERPT_CHARS,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reverseImpact(g, targetId, depth = 3, cap = 40) {
|
|
54
|
+
const seen = new Map([[targetId, 0]])
|
|
55
|
+
const queue = [targetId]
|
|
56
|
+
for (let cursor = 0; cursor < queue.length; cursor++) {
|
|
57
|
+
const current = queue[cursor]
|
|
58
|
+
const currentDepth = seen.get(current)
|
|
59
|
+
if (currentDepth >= depth) continue
|
|
60
|
+
for (const edge of g.inn.get(current) || []) {
|
|
61
|
+
if (isStructuralRelation(edge.relation) || edge.barrelProxy === true) continue
|
|
62
|
+
const id = String(edge.id)
|
|
63
|
+
if (seen.has(id)) continue
|
|
64
|
+
seen.set(id, currentDepth + 1)
|
|
65
|
+
queue.push(id)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return [...seen.entries()]
|
|
69
|
+
.filter(([id]) => id !== targetId)
|
|
70
|
+
.map(([id, distance]) => ({id, label: labelOf(g, id), distance, degree: degreeOf(g, id)}))
|
|
71
|
+
.sort((left, right) => left.distance - right.distance || right.degree - left.degree)
|
|
72
|
+
.slice(0, cap)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function graphOccurrences(g, targetId) {
|
|
76
|
+
const all = (g.inn.get(targetId) || [])
|
|
77
|
+
.filter((edge) => !isStructuralRelation(edge.relation) && edge.barrelProxy !== true)
|
|
78
|
+
.map((edge) => ({
|
|
79
|
+
source: String(edge.id),
|
|
80
|
+
label: labelOf(g, edge.id),
|
|
81
|
+
relation: edge.relation || 'references',
|
|
82
|
+
provenance: edge.provenance || 'UNKNOWN',
|
|
83
|
+
...(Number.isInteger(edge.line) ? {line: edge.line} : {}),
|
|
84
|
+
...(edge.typeOnly === true ? {typeOnly: true} : {}),
|
|
85
|
+
...(edge.compileOnly === true ? {compileOnly: true} : {}),
|
|
86
|
+
}))
|
|
87
|
+
return {total: all.length, shown: all.slice(0, MAX_GRAPH_OCCURRENCES)}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function groupLocations(g, locations, cap) {
|
|
91
|
+
const groups = new Map()
|
|
92
|
+
for (const location of locations) {
|
|
93
|
+
const source = String(location.source || location.file || '')
|
|
94
|
+
if (!source) continue
|
|
95
|
+
const node = g.byId.get(source)
|
|
96
|
+
const file = String(location.file || node?.source_file || (isSymbol(source) ? source.split('#')[0] : source))
|
|
97
|
+
let group = groups.get(source)
|
|
98
|
+
if (!group) {
|
|
99
|
+
group = {id: source, label: String(node?.label || source), file, count: 0, classifications: {}, locations: []}
|
|
100
|
+
groups.set(source, group)
|
|
101
|
+
}
|
|
102
|
+
group.count++
|
|
103
|
+
const classification = String(location.classification || 'unknown')
|
|
104
|
+
group.classifications[classification] = (group.classifications[classification] || 0) + 1
|
|
105
|
+
if (group.locations.length < MAX_LOCATION_SAMPLES) group.locations.push({
|
|
106
|
+
line: location.line,
|
|
107
|
+
character: location.character,
|
|
108
|
+
...(Number.isInteger(location.endLine) ? {endLine: location.endLine} : {}),
|
|
109
|
+
...(Number.isInteger(location.endCharacter) ? {endCharacter: location.endCharacter} : {}),
|
|
110
|
+
classification,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
const all = [...groups.values()].sort((left, right) => right.count - left.count || left.file.localeCompare(right.file) || left.id.localeCompare(right.id))
|
|
114
|
+
return {all, shown: all.slice(0, cap)}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function textFor(result) {
|
|
118
|
+
if (result.status === 'NOT_FOUND') return `No symbol found matching "${result.query}".`
|
|
119
|
+
if (result.status === 'AMBIGUOUS') return [
|
|
120
|
+
`Symbol "${result.query}" is ambiguous (${result.candidates.length} candidate(s) shown). Supply an exact node ID:`,
|
|
121
|
+
...result.candidates.map((candidate) => ` ${candidate.label} [${candidate.id}]`),
|
|
122
|
+
].join('\n')
|
|
123
|
+
const definition = result.definition
|
|
124
|
+
const lines = [
|
|
125
|
+
`Symbol inspection: ${definition.label} [${definition.id}]`,
|
|
126
|
+
`Definition: ${definition.file}:${definition.line}`,
|
|
127
|
+
`Evidence: ${result.evidence.state}${result.evidence.cached ? ' (cache hit)' : ''}; ${result.exact.occurrences} exact reference occurrence(s) in ${result.exact.files} file(s) / ${result.exact.containers} container(s).`,
|
|
128
|
+
`Graph: ${result.graph.occurrenceTotal} direct occurrence edge(s) (${result.graph.occurrences.length} shown); ${result.graph.impact.length} dependent(s) shown.`,
|
|
129
|
+
]
|
|
130
|
+
if (result.evidence.reason) lines.push(`Completeness: ${result.evidence.reason}`)
|
|
131
|
+
if (result.exact.zeroReferences) lines.push('Exact result: zero non-declaration references in the completely covered project universe.')
|
|
132
|
+
if (result.exact.groups.length) {
|
|
133
|
+
lines.push('Reference containers:')
|
|
134
|
+
for (const group of result.exact.groups) lines.push(` ${group.count} site(s) ${group.label} [${group.id}]`)
|
|
135
|
+
}
|
|
136
|
+
if (result.source.definition) {
|
|
137
|
+
const source = result.source.definition
|
|
138
|
+
lines.push('', `Definition source (${source.file}:${source.startLine}-${source.endLine}):`, source.text)
|
|
139
|
+
}
|
|
140
|
+
for (const source of result.source.callers) {
|
|
141
|
+
lines.push('', `Caller source (${source.file}:${source.startLine}-${source.endLine}, focus ${source.focusLine}):`, source.text)
|
|
142
|
+
}
|
|
143
|
+
return lines.join('\n')
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function tInspectSymbol(g, args = {}, ctx = {}) {
|
|
147
|
+
const query = String(args.label || '').trim()
|
|
148
|
+
const info = resolveNodeInfo(g, query)
|
|
149
|
+
if (!info.node) return toolResult(textFor({status: 'NOT_FOUND', query}), {status: 'NOT_FOUND', query})
|
|
150
|
+
if (info.matches > 1 && !g.byId.has(query)) {
|
|
151
|
+
const result = {status: 'AMBIGUOUS', query, candidates: candidateNodes(g, query)}
|
|
152
|
+
return toolResult(textFor(result), result, {completeness: {status: 'blocked', reason: 'ambiguous symbol'}})
|
|
153
|
+
}
|
|
154
|
+
const node = info.node
|
|
155
|
+
const targetId = String(node.id)
|
|
156
|
+
if (!isSymbol(targetId)) {
|
|
157
|
+
const result = {status: 'UNSUPPORTED', query, candidates: [{id: targetId, label: node.label || targetId, file: node.source_file || targetId}]}
|
|
158
|
+
return toolResult('inspect_symbol requires a symbol node, not a file node.', result)
|
|
159
|
+
}
|
|
160
|
+
const maxReferences = boundedInteger(args.max_references, 1_000, 1, 5_000)
|
|
161
|
+
const maxContainers = boundedInteger(args.max_containers, 15, 1, 50)
|
|
162
|
+
const contextLines = boundedInteger(args.context_lines, 8, 0, 40)
|
|
163
|
+
const timeoutMs = boundedInteger(args.timeout_ms, 30_000, 1_000, 60_000)
|
|
164
|
+
const precision = ['auto', 'graph', 'lsp'].includes(args.precision) ? args.precision : 'auto'
|
|
165
|
+
const shouldQuery = precision === 'lsp' || (precision === 'auto' && g.graphPrecisionMode !== 'off')
|
|
166
|
+
let overlay = null
|
|
167
|
+
let cached = false
|
|
168
|
+
let elapsedMs = 0
|
|
169
|
+
let queryError = null
|
|
170
|
+
if (shouldQuery) {
|
|
171
|
+
try {
|
|
172
|
+
const queryResult = await querySymbolPrecision({repoRoot: ctx.repoRoot, graphPath: ctx.graphPath, targetId, maxReferences, timeoutMs})
|
|
173
|
+
overlay = queryResult.overlay
|
|
174
|
+
cached = queryResult.cached
|
|
175
|
+
elapsedMs = queryResult.elapsedMs
|
|
176
|
+
} catch (error) {
|
|
177
|
+
queryError = error?.message || 'exact symbol query failed'
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const locations = Array.isArray(overlay?.locations) ? overlay.locations : []
|
|
181
|
+
const grouped = groupLocations(g, locations, maxContainers)
|
|
182
|
+
const files = new Set(locations.map((location) => String(location.file || '')).filter(Boolean))
|
|
183
|
+
let state = 'GRAPH_ONLY'
|
|
184
|
+
if (overlay?.state === 'COMPLETE') state = 'EXACT'
|
|
185
|
+
else if (overlay?.state === 'PARTIAL') state = 'PARTIAL'
|
|
186
|
+
else if (overlay?.state === 'OFF') state = 'OFF'
|
|
187
|
+
else if (shouldQuery) state = 'UNAVAILABLE'
|
|
188
|
+
const reason = queryError || overlay?.reason || (shouldQuery ? null : 'semantic precision was not requested')
|
|
189
|
+
const graphRefs = graphOccurrences(g, targetId)
|
|
190
|
+
const impact = reverseImpact(g, targetId)
|
|
191
|
+
const definition = {
|
|
192
|
+
id: targetId,
|
|
193
|
+
label: String(node.label || targetId),
|
|
194
|
+
kind: String(node.symbol_kind || 'symbol'),
|
|
195
|
+
file: String(node.source_file || targetId.split('#')[0]),
|
|
196
|
+
line: sourceLine(node),
|
|
197
|
+
...(node.source_range ? {range: node.source_range} : {}),
|
|
198
|
+
...(node.complexity ? {complexity: node.complexity} : {}),
|
|
199
|
+
}
|
|
200
|
+
const definitionSource = excerpt(ctx.repoRoot, definition.file, definition.line, contextLines)
|
|
201
|
+
const callerSources = grouped.shown.slice(0, 5).map((group) => excerpt(ctx.repoRoot, group.file, group.locations[0]?.line || 1, contextLines)).filter(Boolean)
|
|
202
|
+
const result = {
|
|
203
|
+
status: 'OK',
|
|
204
|
+
definition,
|
|
205
|
+
evidence: {state, cached, elapsedMs, reason, provider: overlay?.engines?.[0]?.provider || null},
|
|
206
|
+
exact: {
|
|
207
|
+
occurrences: locations.length,
|
|
208
|
+
occurrencesWithDefinition: locations.length + 1,
|
|
209
|
+
files: files.size,
|
|
210
|
+
containers: grouped.all.length,
|
|
211
|
+
groups: grouped.shown,
|
|
212
|
+
zeroReferences: overlay?.noReferenceSymbols?.includes(targetId) === true,
|
|
213
|
+
capped: overlay?.coverage?.truncated === true || grouped.all.length > grouped.shown.length,
|
|
214
|
+
},
|
|
215
|
+
graph: {
|
|
216
|
+
occurrences: graphRefs.shown,
|
|
217
|
+
occurrenceTotal: graphRefs.total,
|
|
218
|
+
occurrencesCapped: graphRefs.total > graphRefs.shown.length,
|
|
219
|
+
impact,
|
|
220
|
+
},
|
|
221
|
+
source: {definition: definitionSource, callers: callerSources},
|
|
222
|
+
}
|
|
223
|
+
const warnings = state === 'PARTIAL' || state === 'UNAVAILABLE'
|
|
224
|
+
? [{code: 'SYMBOL_PRECISION_INCOMPLETE', message: reason || `semantic precision is ${state.toLowerCase()}`}]
|
|
225
|
+
: []
|
|
226
|
+
return toolResult(textFor(result), result, {
|
|
227
|
+
warnings,
|
|
228
|
+
completeness: {
|
|
229
|
+
status: state === 'EXACT' && !result.exact.capped ? 'complete' : state.toLowerCase(),
|
|
230
|
+
referenceLimit: maxReferences,
|
|
231
|
+
containerLimit: maxContainers,
|
|
232
|
+
returnedContainers: grouped.shown.length,
|
|
233
|
+
locationSamplesPerContainer: MAX_LOCATION_SAMPLES,
|
|
234
|
+
graphOccurrenceLimit: MAX_GRAPH_OCCURRENCES,
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
}
|