weavatrix 0.2.6 → 0.2.8
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 +76 -10
- package/SECURITY.md +14 -0
- package/docs/agent-task-benchmark.md +58 -0
- package/docs/releases/v0.2.8.md +82 -0
- package/package.json +6 -1
- package/scripts/run-agent-task-benchmark.mjs +119 -0
- package/skill/SKILL.md +27 -7
- package/src/analysis/allowed-test-runner.js +59 -0
- package/src/analysis/data-flow-evidence.js +114 -0
- package/src/analysis/dead-check.js +24 -2
- package/src/analysis/dead-code-review.js +51 -0
- package/src/analysis/dep-check-ecosystems.js +41 -2
- package/src/analysis/dep-check.js +42 -3
- package/src/analysis/duplicate-groups.js +84 -0
- package/src/analysis/endpoints.js +47 -13
- package/src/analysis/hot-path-review.js +20 -2
- package/src/analysis/internal-audit.collect.js +53 -20
- package/src/analysis/internal-audit.run.js +6 -1
- package/src/analysis/task-retrieval.js +116 -0
- package/src/graph/builder/lang-rust.js +49 -12
- package/src/mcp/catalog.mjs +26 -6
- package/src/mcp/graph-context.mjs +37 -2
- package/src/mcp/tools-graph.mjs +46 -3
- package/src/mcp/tools-health.mjs +37 -29
- package/src/mcp/tools-impact-change.mjs +1 -0
- package/src/mcp/tools-verified-change.mjs +185 -0
- package/src/path-classification.js +1 -1
- package/src/precision/symbol-query.js +51 -0
- package/src/security/malware-heuristics.exclusions.js +3 -3
- package/src/security/malware-heuristics.roots.js +4 -1
- package/src/security/malware-heuristics.scan.js +4 -2
- package/src/security/malware-scoring.js +22 -5
|
@@ -109,8 +109,9 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
109
109
|
calls: boundedInt(options.callThreshold, 12, 1, 10000),
|
|
110
110
|
loopDepth: boundedInt(options.loopDepthThreshold, 2, 1, 10),
|
|
111
111
|
timeRank: boundedInt(options.timeRankThreshold, 2, 0, 5),
|
|
112
|
-
minScore: boundedInt(options.minScore,
|
|
112
|
+
minScore: boundedInt(options.minScore, 85, 0, 100),
|
|
113
113
|
}
|
|
114
|
+
const defaultFocus = options.minScore == null
|
|
114
115
|
const topN = boundedInt(options.topN, 20, 1, 100)
|
|
115
116
|
const classifier = createPathClassifier(options.repoRoot || null)
|
|
116
117
|
const knownFiles = [...new Set((graph?.nodes || []).map((node) => normalize(node?.source_file)).filter(Boolean))]
|
|
@@ -152,8 +153,19 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
152
153
|
const nearest = staticByFile.get(file)?.nearestTests?.[0] || null
|
|
153
154
|
const coverageRisk = actualCoverage == null ? 0 : (1 - actualCoverage) * 100
|
|
154
155
|
const score = round(clamp(syntaxScore * 0.72 + graphScore * 0.2 + coverageRisk * 0.08))
|
|
155
|
-
if (score < thresholds.minScore) continue
|
|
156
156
|
const directHotEvidence = Array.isArray(complexity.hotEvidence) ? complexity.hotEvidence.slice(0, 12) : []
|
|
157
|
+
// The default queue is intentionally narrow. A small, locally expensive function can still be
|
|
158
|
+
// important even with little graph fan-in, so retain only a bounded strong-local fallback. An
|
|
159
|
+
// explicit minScore disables this fallback and gives the caller a strict numeric gate.
|
|
160
|
+
const bodyLines = startLine > 0 && endLine >= startLine ? endLine - startLine + 1 : Number.POSITIVE_INFINITY
|
|
161
|
+
const strongLocalEvidence = defaultFocus && directHotEvidence.length > 0 && bodyLines <= 80 && (
|
|
162
|
+
Number(complexity.recursionInLoops || 0) > 0
|
|
163
|
+
|| (bodyLines <= 40 && (
|
|
164
|
+
Number(complexity.sortsInLoops || 0) > 0
|
|
165
|
+
|| (Number(complexity.timeRank || 0) >= 4 && Number(complexity.maxLoopDepth || 0) >= 2)
|
|
166
|
+
))
|
|
167
|
+
)
|
|
168
|
+
if (score < thresholds.minScore && !strongLocalEvidence) continue
|
|
157
169
|
const confidence = directHotEvidence.length ? 'HIGH' : complexity.recursion ? 'LOW' : 'MEDIUM'
|
|
158
170
|
candidates.push({
|
|
159
171
|
id: String(node.id),
|
|
@@ -164,6 +176,7 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
164
176
|
endLine,
|
|
165
177
|
classification: entry.classification,
|
|
166
178
|
score,
|
|
179
|
+
selection: score >= thresholds.minScore ? 'SCORE_THRESHOLD' : 'STRONG_LOCAL_EVIDENCE',
|
|
167
180
|
confidence,
|
|
168
181
|
localSyntax: {
|
|
169
182
|
score: syntaxScore,
|
|
@@ -207,6 +220,11 @@ export function computeHotPathReview(graph, options = {}) {
|
|
|
207
220
|
includeClassified: options.includeClassified === true,
|
|
208
221
|
},
|
|
209
222
|
thresholds,
|
|
223
|
+
selectionPolicy: {
|
|
224
|
+
mode: defaultFocus ? 'FOCUSED_DEFAULT' : 'EXPLICIT_SCORE_THRESHOLD',
|
|
225
|
+
strongLocalFallback: defaultFocus,
|
|
226
|
+
broadenWith: 'Set min_score lower (0 restores the full diagnostic candidate set).',
|
|
227
|
+
},
|
|
210
228
|
analyzedSymbols: eligible.length,
|
|
211
229
|
candidateSymbols: candidates.length,
|
|
212
230
|
coverage: measuredCoverage.size
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
-
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps } from "./manifests.js";
|
|
6
|
+
import { parseRequirementsNames, parsePyprojectDeps, parsePipfileDeps, pep503 } from "./manifests.js";
|
|
7
7
|
import { createRepoBoundary } from "../repo-path.js";
|
|
8
8
|
import { childProcessEnv } from "../child-env.js";
|
|
9
9
|
import { filterWeavatrixIgnored } from "../path-ignore.js";
|
|
@@ -254,28 +254,61 @@ export function workspacePkgNames(repoRoot, pkg) {
|
|
|
254
254
|
|
|
255
255
|
export const TEST_FILE_RE = /(^|[/])(test|tests|__tests__|spec|e2e|__mocks__)([/]|$)|[._-](test|spec)\.[a-z0-9]+$/i;
|
|
256
256
|
|
|
257
|
-
// Python declared deps
|
|
257
|
+
// Python declared deps are owned by the nearest manifest root, matching nested service/monorepo
|
|
258
|
+
// layouts. `requirements/*.txt` belongs to the directory above `requirements`; a colocated
|
|
259
|
+
// requirements.txt, pyproject.toml or Pipfile owns its own directory.
|
|
258
260
|
// present=false (no manifest at all) softens missing-dep findings instead of suppressing them.
|
|
259
261
|
export function collectPyManifest(repoRoot) {
|
|
260
|
-
const deps = [];
|
|
261
|
-
let present = false;
|
|
262
|
-
let names = [];
|
|
263
262
|
const boundary = createRepoBoundary(repoRoot);
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
263
|
+
const scopes = new Map();
|
|
264
|
+
const scopeFor = (root) => {
|
|
265
|
+
const normalized = normRoot(root);
|
|
266
|
+
if (!scopes.has(normalized)) scopes.set(normalized, { root: normalized, present: false, deps: [], manifests: [] });
|
|
267
|
+
return scopes.get(normalized);
|
|
268
|
+
};
|
|
269
|
+
const addManifest = (root, manifest, parsedDeps, present = true) => {
|
|
270
|
+
if (!present) return;
|
|
271
|
+
const scope = scopeFor(root);
|
|
272
|
+
scope.present = true;
|
|
273
|
+
scope.manifests.push(manifest);
|
|
274
|
+
scope.deps.push(...parsedDeps.map((dep) => ({ ...dep, manifest })));
|
|
275
|
+
};
|
|
276
|
+
const files = listRepoFiles(repoRoot);
|
|
277
|
+
for (const file of files.filter((name) => /(^|\/)requirements[\w.-]*\.(?:txt|in)$/i.test(name)
|
|
278
|
+
|| /(^|\/)requirements\/[^/]+\.(?:txt|in)$/i.test(name))) {
|
|
279
|
+
const t = readRepoText(boundary, file);
|
|
271
280
|
if (t == null) continue;
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
281
|
+
const parent = normRoot(dirname(file));
|
|
282
|
+
const root = /(^|\/)requirements$/i.test(parent) ? normRoot(dirname(parent)) : parent;
|
|
283
|
+
const dev = /dev|test|lint|doc|ci/i.test(file.slice(file.lastIndexOf("/") + 1));
|
|
284
|
+
addManifest(root, file, parseRequirementsNames(t).map((dep) => ({ ...dep, dev })));
|
|
275
285
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
286
|
+
for (const file of files.filter((name) => /(^|\/)pyproject\.toml$/i.test(name))) {
|
|
287
|
+
const parsed = parsePyprojectDeps(readRepoText(boundary, file));
|
|
288
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
289
|
+
}
|
|
290
|
+
for (const file of files.filter((name) => /(^|\/)Pipfile$/i.test(name))) {
|
|
291
|
+
const parsed = parsePipfileDeps(readRepoText(boundary, file));
|
|
292
|
+
addManifest(dirname(file), file, parsed.deps, parsed.present);
|
|
293
|
+
}
|
|
294
|
+
const normalizedScopes = [...scopes.values()]
|
|
295
|
+
.map((scope) => {
|
|
296
|
+
const seen = new Set();
|
|
297
|
+
return {
|
|
298
|
+
...scope,
|
|
299
|
+
manifests: [...new Set(scope.manifests)].sort(),
|
|
300
|
+
deps: scope.deps.filter((dep) => {
|
|
301
|
+
const key = pep503(dep.name);
|
|
302
|
+
if (!key || seen.has(key)) return false;
|
|
303
|
+
seen.add(key);
|
|
304
|
+
return true;
|
|
305
|
+
}),
|
|
306
|
+
};
|
|
307
|
+
})
|
|
308
|
+
.sort((left, right) => right.root.length - left.root.length || left.root.localeCompare(right.root));
|
|
309
|
+
return {
|
|
310
|
+
present: normalizedScopes.some((scope) => scope.present),
|
|
311
|
+
deps: normalizedScopes.flatMap((scope) => scope.deps),
|
|
312
|
+
scopes: normalizedScopes,
|
|
313
|
+
};
|
|
281
314
|
}
|
|
@@ -300,15 +300,20 @@ export async function runInternalAudit(repoPath, { graph, advisoryStorePath, ski
|
|
|
300
300
|
findings: sorted,
|
|
301
301
|
dependencyReport: {
|
|
302
302
|
status: dependencyStatus,
|
|
303
|
+
evidenceModel: "MANIFEST_PLUS_INDEXED_SOURCE",
|
|
304
|
+
perFindingVerification: true,
|
|
305
|
+
verificationCoverage: { npm: "COMPLETE_FOR_GRAPH_SCOPE", go: "SUMMARY_ONLY", python: "SUMMARY_ONLY" },
|
|
303
306
|
declared: dep.declared.size + goDep.declared.size + pyDep.declared.size,
|
|
304
307
|
importedPackages: importedPackages.size,
|
|
305
308
|
importRecords: externalImports.length,
|
|
306
309
|
unused: dependencyFindings.filter((finding) => finding.rule === "unused-dep").length,
|
|
307
310
|
missing: dependencyFindings.filter((finding) => finding.rule === "missing-dep").length,
|
|
308
311
|
duplicateDeclarations: dependencyFindings.filter((finding) => finding.rule === "duplicate-dep").length,
|
|
312
|
+
unusedRequiringReview: dependencyFindings.filter((finding) => finding.rule === "unused-dep" && finding.verification?.decision === "REVIEW_REQUIRED").length,
|
|
313
|
+
missingWithSourceEvidence: dependencyFindings.filter((finding) => finding.rule === "missing-dep" && finding.verification?.indexedSourceImports?.status === "FOUND").length,
|
|
309
314
|
packageScopes: packageScopes.length,
|
|
310
315
|
reason: dependencyStatus === "COMPLETE"
|
|
311
|
-
? "All discovered dependency manifests were compared with the complete indexed import set."
|
|
316
|
+
? "All discovered dependency manifests were compared with the complete indexed import set; every npm unused/missing/duplicate finding carries manifest and source/config verification state."
|
|
312
317
|
: "The dependency result is scoped to a partial graph and is not a repository-wide clean bill.",
|
|
313
318
|
},
|
|
314
319
|
deadReport: {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {createPathClassifier, hasPathClass} from '../path-classification.js'
|
|
2
|
+
|
|
3
|
+
const words = (value) => new Set(String(value || '').toLowerCase().match(/[\p{L}_$][\p{L}\p{N}_$-]{2,}/gu) || [])
|
|
4
|
+
const fileOf = (node) => String(node?.source_file || (String(node?.id || '').includes('#') ? String(node.id).split('#')[0] : node?.id || '')).replace(/\\/g, '/')
|
|
5
|
+
const isSymbol = (id) => String(id || '').includes('#')
|
|
6
|
+
const NON_PRODUCT_CLASSES = Object.freeze(['test', 'e2e', 'generated', 'mock', 'story', 'docs', 'benchmark', 'temp'])
|
|
7
|
+
const CLASS_TERMS = Object.freeze({
|
|
8
|
+
test: ['test', 'tests', 'testing', 'spec', 'coverage', 'verify'],
|
|
9
|
+
e2e: ['e2e', 'playwright', 'cypress'],
|
|
10
|
+
generated: ['generated', 'autogenerated', 'dist'],
|
|
11
|
+
mock: ['mock', 'mocks', 'fixture', 'fixtures', 'fake'],
|
|
12
|
+
story: ['story', 'stories', 'storybook'],
|
|
13
|
+
docs: ['doc', 'docs', 'documentation', 'readme', 'guide'],
|
|
14
|
+
benchmark: ['benchmark', 'benchmarks', 'bench'],
|
|
15
|
+
temp: ['temp', 'temporary', 'tmp'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const INTENT_TRANSLATIONS = [
|
|
19
|
+
[/авториз|аутентиф|логин|сесси|токен/iu, 'auth authentication login session token'],
|
|
20
|
+
[/маршрут|роут|эндпоинт|апи|http/iu, 'route router endpoint api http'],
|
|
21
|
+
[/тест|покрыти/iu, 'test spec coverage verify'],
|
|
22
|
+
[/к[эе]ш|хранилищ/iu, 'cache store storage'],
|
|
23
|
+
[/баз[аы]|запрос|sql/iu, 'database query sql'],
|
|
24
|
+
[/конфиг|настройк/iu, 'config settings configuration'],
|
|
25
|
+
[/безопас|вредонос|секрет/iu, 'security malware secret'],
|
|
26
|
+
[/зависим|импорт/iu, 'dependency import module'],
|
|
27
|
+
[/дублик|клон/iu, 'duplicate clone'],
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export function expandTaskQuery(task) {
|
|
31
|
+
const text = String(task || '')
|
|
32
|
+
const expansions = INTENT_TRANSLATIONS.filter(([pattern]) => pattern.test(text)).map(([, value]) => value)
|
|
33
|
+
return [...new Set([text, ...expansions])].join(' ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function overlapScore(taskWords, node) {
|
|
37
|
+
const haystack = words(`${node?.label || ''} ${node?.id || ''} ${node?.symbol_kind || ''}`)
|
|
38
|
+
let score = 0
|
|
39
|
+
for (const word of taskWords) if (haystack.has(word)) score += 8
|
|
40
|
+
return score
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function addCandidate(map, node, score, reason) {
|
|
44
|
+
if (!node?.id) return
|
|
45
|
+
const id = String(node.id)
|
|
46
|
+
const current = map.get(id) || {node, score: 0, reasons: new Set()}
|
|
47
|
+
current.score += score
|
|
48
|
+
current.reasons.add(reason)
|
|
49
|
+
map.set(id, current)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function containedSymbols(g, node) {
|
|
53
|
+
if (isSymbol(node?.id)) return [node]
|
|
54
|
+
return (g.out.get(String(node?.id)) || [])
|
|
55
|
+
.filter((edge) => edge.relation === 'contains')
|
|
56
|
+
.map((edge) => g.byId.get(String(edge.id)))
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Combines intent-expanded search seeds with exact changed symbols. The result deliberately stays
|
|
61
|
+
// deterministic and source-free; exact LSP/source evidence is collected by context_bundle later.
|
|
62
|
+
export function retrieveTaskContext(g, {
|
|
63
|
+
task, semanticSeeds = [], changedSeedIds = [], maxSymbols = 3, repoRoot = null, includeClassified = false,
|
|
64
|
+
} = {}) {
|
|
65
|
+
const expandedTask = expandTaskQuery(task)
|
|
66
|
+
const taskWords = words(expandedTask)
|
|
67
|
+
const requestedClasses = new Set(Object.entries(CLASS_TERMS)
|
|
68
|
+
.filter(([, terms]) => terms.some((term) => taskWords.has(term)))
|
|
69
|
+
.map(([name]) => name))
|
|
70
|
+
if (requestedClasses.has('test')) requestedClasses.add('e2e')
|
|
71
|
+
if (requestedClasses.has('e2e')) requestedClasses.add('test')
|
|
72
|
+
const classifier = createPathClassifier(repoRoot)
|
|
73
|
+
const classificationCache = new Map()
|
|
74
|
+
const changedFiles = new Set((changedSeedIds || []).map((id) => fileOf(g.byId.get(String(id)) || {id})))
|
|
75
|
+
const pathAllowed = (node) => {
|
|
76
|
+
const file = fileOf(node)
|
|
77
|
+
if (!file || changedFiles.has(file) || includeClassified === true) return true
|
|
78
|
+
if (!classificationCache.has(file)) classificationCache.set(file, classifier.explain(file, {content: ''}))
|
|
79
|
+
const info = classificationCache.get(file)
|
|
80
|
+
const classes = NON_PRODUCT_CLASSES.filter((name) => hasPathClass(info, name))
|
|
81
|
+
if (!info?.excluded && !classes.length) return true
|
|
82
|
+
return classes.some((name) => requestedClasses.has(name))
|
|
83
|
+
}
|
|
84
|
+
const candidates = new Map()
|
|
85
|
+
for (const id of changedSeedIds || []) {
|
|
86
|
+
const node = g.byId.get(String(id))
|
|
87
|
+
if (node) addCandidate(candidates, node, 100 + overlapScore(taskWords, node), 'changed-symbol')
|
|
88
|
+
}
|
|
89
|
+
for (const seed of semanticSeeds || []) addCandidate(candidates, seed, 45 + overlapScore(taskWords, seed), 'task-intent')
|
|
90
|
+
|
|
91
|
+
for (const candidate of [...candidates.values()]) {
|
|
92
|
+
for (const symbol of containedSymbols(g, candidate.node)) {
|
|
93
|
+
const degree = (g.out.get(String(symbol.id)) || []).length + (g.inn.get(String(symbol.id)) || []).length
|
|
94
|
+
addCandidate(candidates, symbol, 22 + overlapScore(taskWords, symbol) + Math.min(12, degree), `symbol-in:${fileOf(candidate.node)}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const symbolCandidates = [...candidates.values()].filter((item) => isSymbol(item.node.id))
|
|
99
|
+
const ranked = symbolCandidates
|
|
100
|
+
.filter((item) => pathAllowed(item.node))
|
|
101
|
+
.sort((left, right) => right.score - left.score || String(left.node.id).localeCompare(String(right.node.id)))
|
|
102
|
+
const limit = Math.max(1, Math.min(5, Number(maxSymbols) || 3))
|
|
103
|
+
return {
|
|
104
|
+
method: 'intent-expanded graph retrieval + exact changed-symbol seeds',
|
|
105
|
+
status: ranked.length ? 'COMPLETE' : 'NO_SYMBOLS',
|
|
106
|
+
selected: ranked.slice(0, limit).map((item) => ({
|
|
107
|
+
id: String(item.node.id), label: item.node.label || String(item.node.id), file: fileOf(item.node),
|
|
108
|
+
kind: item.node.symbol_kind || null, score: item.score, reasons: [...item.reasons].sort(),
|
|
109
|
+
})),
|
|
110
|
+
candidateCount: ranked.length,
|
|
111
|
+
suppressedClassified: symbolCandidates.length - ranked.length,
|
|
112
|
+
pathPolicy: includeClassified === true
|
|
113
|
+
? {mode: 'ALL_CLASSIFIED'}
|
|
114
|
+
: {mode: 'PRODUCTION_FIRST', requestedClasses: [...requestedClasses].sort(), changedFilesPinned: changedFiles.size},
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -4,23 +4,43 @@
|
|
|
4
4
|
// `crate/self/super` paths resolve to repo-local .rs or */mod.rs files. External crates deliberately stay out
|
|
5
5
|
// of this adapter; Cargo dependency analysis owns them.
|
|
6
6
|
const SYMS_CORE = `
|
|
7
|
-
(function_item name: (identifier) @
|
|
8
|
-
(struct_item name: (type_identifier) @
|
|
9
|
-
(enum_item name: (type_identifier) @
|
|
10
|
-
(trait_item name: (type_identifier) @
|
|
11
|
-
(type_item name: (type_identifier) @
|
|
12
|
-
(mod_item name: (identifier) @
|
|
13
|
-
(const_item name: (identifier) @
|
|
14
|
-
(static_item name: (identifier) @
|
|
7
|
+
(function_item name: (identifier) @function)
|
|
8
|
+
(struct_item name: (type_identifier) @struct)
|
|
9
|
+
(enum_item name: (type_identifier) @enum)
|
|
10
|
+
(trait_item name: (type_identifier) @trait)
|
|
11
|
+
(type_item name: (type_identifier) @type)
|
|
12
|
+
(mod_item name: (identifier) @module)
|
|
13
|
+
(const_item name: (identifier) @constant)
|
|
14
|
+
(static_item name: (identifier) @static)`;
|
|
15
15
|
// Grammar-version-dependent node types, compiled SEPARATELY (one unknown type voids its whole query).
|
|
16
16
|
const SYMS_OPTIONAL = [
|
|
17
|
-
`(
|
|
18
|
-
`(
|
|
17
|
+
`(function_signature_item name: (identifier) @function)`,
|
|
18
|
+
`(macro_definition name: (identifier) @macro)`,
|
|
19
|
+
`(union_item name: (type_identifier) @union)`,
|
|
19
20
|
];
|
|
20
21
|
|
|
21
22
|
const cleanSegment = (part) => String(part || "").trim().replace(/^r#/, "");
|
|
22
23
|
const pathParts = (node) => String(node?.text || "").split("::").map(cleanSegment).filter(Boolean);
|
|
23
24
|
const under = (node, type) => { for (let p = node?.parent; p; p = p.parent) if (p.type === type) return true; return false; };
|
|
25
|
+
const ancestor = (node, types) => {
|
|
26
|
+
for (let parent = node?.parent; parent; parent = parent.parent) if (types.has(parent.type)) return parent;
|
|
27
|
+
return null;
|
|
28
|
+
};
|
|
29
|
+
const memberOwners = new Set(["impl_item", "trait_item"]);
|
|
30
|
+
const publicVisibility = (declaration, owner) => {
|
|
31
|
+
if (owner?.type === "trait_item") return "public";
|
|
32
|
+
const source = String(declaration?.text || "").trimStart();
|
|
33
|
+
if (/^pub\s/.test(source)) return "public";
|
|
34
|
+
if (/^pub\s*\(/.test(source)) return "protected";
|
|
35
|
+
return "private";
|
|
36
|
+
};
|
|
37
|
+
const ownerName = (owner, field) => {
|
|
38
|
+
if (!owner) return "";
|
|
39
|
+
if (owner.type === "trait_item") return field(owner, "name")?.text || "";
|
|
40
|
+
const type = field(owner, "type")?.text || "";
|
|
41
|
+
const withoutGenerics = type.replace(/<[^<>]*>/g, "");
|
|
42
|
+
return (withoutGenerics.match(/[A-Za-z_]\w*/g) || []).at(-1) || "";
|
|
43
|
+
};
|
|
24
44
|
|
|
25
45
|
function pathAttribute(modNode) {
|
|
26
46
|
for (let prev = modNode?.previousNamedSibling; prev?.type === "attribute_item"; prev = prev.previousNamedSibling) {
|
|
@@ -93,12 +113,29 @@ export default {
|
|
|
93
113
|
],
|
|
94
114
|
|
|
95
115
|
pass1(ctx) {
|
|
96
|
-
const { grammar, tree, fileRel, caps, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath } = ctx;
|
|
116
|
+
const { grammar, tree, fileRel, caps, field, addSym, addImportEdge, imports, resolveRustMod, resolveRustPath, links, nameToId } = ctx;
|
|
117
|
+
const owned = [];
|
|
97
118
|
for (const src of [SYMS_CORE, ...SYMS_OPTIONAL]) {
|
|
98
119
|
for (const cap of caps(grammar, src, tree.rootNode)) {
|
|
99
|
-
|
|
120
|
+
const declaration = cap.node.parent;
|
|
121
|
+
const owner = cap.name === "function" ? ancestor(cap.node, memberOwners) : null;
|
|
122
|
+
const memberOf = ownerName(owner, field);
|
|
123
|
+
const symbolKind = cap.name === "function" ? (memberOf ? "method" : "function") : cap.name;
|
|
124
|
+
const visibility = publicVisibility(declaration, owner);
|
|
125
|
+
const id = addSym(cap.node.text, cap.node.startPosition.row + 1, cap.name === "function", {
|
|
126
|
+
sourceNode: declaration,
|
|
127
|
+
selectionNode: cap.node,
|
|
128
|
+
symbolKind,
|
|
129
|
+
...(memberOf ? { memberOf, visibility } : { moduleDeclaration: true }),
|
|
130
|
+
...(!memberOf && visibility === "public" ? { exported: true } : {}),
|
|
131
|
+
});
|
|
132
|
+
if (id && memberOf) owned.push({owner: memberOf, id});
|
|
100
133
|
}
|
|
101
134
|
}
|
|
135
|
+
for (const member of owned) {
|
|
136
|
+
const ownerId = nameToId.get(member.owner);
|
|
137
|
+
if (ownerId && ownerId !== member.id) links.push({source: ownerId, target: member.id, relation: "method", confidence: "EXTRACTED"});
|
|
138
|
+
}
|
|
102
139
|
|
|
103
140
|
// File dependency edges are intentionally unique per source/target/relation. A qualified path can be
|
|
104
141
|
// nested in another qualified path and often repeats an existing `mod`/`use`; counting occurrences would
|
package/src/mcp/catalog.mjs
CHANGED
|
@@ -26,19 +26,38 @@ const PROFILE_CAPS = Object.freeze({
|
|
|
26
26
|
|
|
27
27
|
// The files whose mtime the stdio shell watches for hot reload — keep in sync with the imports in
|
|
28
28
|
// loadHotApi below (catalog.mjs itself is last: a change here re-derives the whole table).
|
|
29
|
-
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'catalog.mjs']
|
|
29
|
+
export const HOT_FILES = ['tools-graph.mjs', 'tools-impact.mjs', 'tools-health.mjs', 'tools-source.mjs', 'tools-context.mjs', 'tools-actions.mjs', 'tools-architecture.mjs', 'tools-history.mjs', 'tools-company.mjs', 'tools-verified-change.mjs', 'catalog.mjs']
|
|
30
30
|
|
|
31
|
-
function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
31
|
+
function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps}) {
|
|
32
32
|
const tools = [
|
|
33
33
|
{cap: 'graph', name: 'graph_stats', description: 'Return summary statistics: node count, edge count, communities, versioned edge-provenance/legacy-confidence breakdowns, and graph build time vs repo HEAD (staleness).', inputSchema: {type: 'object', properties: {}}, run: (g, a, ctx) => tg.tGraphStats(g, ctx)},
|
|
34
34
|
{cap: 'graph', name: 'get_node', description: 'Get full details for a specific node by label or ID.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID to look up'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNode(g, a, ctx)},
|
|
35
35
|
{cap: 'graph', name: 'get_neighbors', description: 'Get all direct neighbors of a node with edge details (1 hop, call sites deduped). For transitive impact use get_dependents; for the impact of your current branch changes use change_impact.', inputSchema: {type: 'object', properties: {label: {type: 'string'}, relation_filter: {type: 'string', description: 'Optional: filter by relation type'}}, required: ['label']}, run: (g, a, ctx) => tg.tGetNeighbors(g, a, ctx)},
|
|
36
|
-
{cap: 'graph', name: 'query_graph', description: 'Explore
|
|
36
|
+
{cap: 'graph', name: 'query_graph', description: 'Explore a focused production-first graph around a concept (BFS/DFS). Exact seed files stay pinned; classified paths and unreferenced constant/field leaves are suppressed unless the question or explicit flags request them. Reports policy and suppression instead of silently flooding the result.', inputSchema: {type: 'object', properties: {question: {type: 'string', description: 'Natural language question or keyword search'}, mode: {type: 'string', enum: ['bfs', 'dfs'], default: 'bfs'}, depth: {type: 'integer', default: 3}, context_filter: {type: 'array', items: {type: 'string'}}, seed_files: {type: 'array', items: {type: 'string'}, maxItems: 12, description: 'Optional exact repo-relative file paths. When resolved, these are the only seeds unless augment_seeds is true'}, augment_seeds: {type: 'boolean', default: false, description: 'With seed_files, also add fuzzy question-derived seeds; false keeps traversal strictly pinned'}, include_classified: {type: 'boolean', default: false, description: 'Allow traversal through tests/e2e/generated/mocks/stories/docs/benchmarks/temp and explicitly excluded paths. An explicit class term in the question enables only that class.'}, include_low_signal: {type: 'boolean', default: false, description: 'Include unreferenced constant/field leaf symbols that do not match a query term'}, token_budget: {type: 'integer', description: 'Higher budget shows more nodes/edges', default: 2000}}, required: ['question']}, run: (g, a, ctx) => tg.tQueryGraph(g, a, ctx)},
|
|
37
37
|
{cap: 'graph', name: 'god_nodes', description: 'Rank production-code connectivity hubs by unique call/import/reference neighbors, with class/method ownership reported separately from runtime connectivity. Repeated call sites do not inflate the rank; classified tests, generated/build output and other non-product paths are excluded by default.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', default: 10}, include_classified: {type: 'boolean', default: false, description: 'Include tests/e2e/generated/build output/mocks/stories/docs/benchmarks/temp and paths explicitly excluded by repository classification'}}}, run: (g, a, ctx) => tg.tGodNodes(g, a, ctx)},
|
|
38
38
|
{cap: 'graph', name: 'shortest_path', description: 'Find the shortest path between two concepts in the knowledge graph.', inputSchema: {type: 'object', properties: {source: {type: 'string'}, target: {type: 'string'}, max_hops: {type: 'integer', default: 8}}, required: ['source', 'target']}, run: (g, a) => tg.tShortestPath(g, a)},
|
|
39
39
|
{cap: 'graph', name: 'get_dependents', description: 'Transitive blast-radius of ONE node: everything that calls/imports/inherits it, directly or through intermediaries (reverse edges, ranked by proximity then connectivity). Symbol queries stay symbol-precise by default; set include_container_importers only for a conservative module-wide radius. Use before refactoring; get_neighbors shows only 1 hop, change_impact covers your whole current diff at once.', inputSchema: {type: 'object', properties: {label: {type: 'string', description: 'Node label or ID'}, depth: {type: 'integer', description: 'Max reverse hops, default 3', default: 3}, max_nodes: {type: 'integer', description: 'Max dependents to list, default 40', default: 40}, include_container_importers: {type: 'boolean', description: 'Also seed importers of the symbol containing file (broader, conservative; default false)', default: false}}, required: ['label']}, run: (g, a) => ti.tGetDependents(g, a)},
|
|
40
40
|
{cap: 'graph', name: 'change_impact', description: 'Verdict-first, symbol-aware blast radius. Parses a zero-context git diff, distinguishes additive exports from signature/body/removal risk, and reverse-traverses only exact changed seeds so a new API does not inherit every legacy importer of its file. Measured coverage is used when present; otherwise static test reachability is labelled actualCoverage=NOT_AVAILABLE. Pass `diff` for a not-checked-out PR; `files` without a diff remains a conservative fallback.', inputSchema: {type: 'object', properties: {base: {type: 'string', description: 'Base ref, e.g. origin/main or HEAD~1 (default: first existing of origin/HEAD, origin/main, origin/master, main, master)'}, diff: {type: 'string', maxLength: 2097152, description: 'Optional unified diff (prefer --unified=0) for a PR/change that is not checked out; enables symbol-level classification'}, files: {type: 'array', items: {type: 'string'}, maxItems: 500, description: 'Optional repo-relative changed-file hints. Without diff evidence these are classified conservatively rather than guessed additive'}, depth: {type: 'integer', description: 'Max reverse hops, default 2'}, max_nodes: {type: 'integer', description: 'Max impacted nodes to list, default 40'}}}, run: (g, a, ctx) => ti.tChangeImpact(g, a, ctx)},
|
|
41
41
|
{cap: 'graph', name: 'git_history', description: 'Behavioral architecture evidence from bounded local git history: churn × connectivity hotspots, hidden co-change coupling, and expected test/source coupling. Reads numstat only — never commit messages, authors, or source bodies.', inputSchema: {type: 'object', properties: {months: {type: 'integer', enum: [3, 6, 12], default: 6}, max_commits: {type: 'integer', minimum: 1, maximum: 5000, default: 1000}, min_pair_count: {type: 'integer', minimum: 2, maximum: 100, default: 3}, max_pairs: {type: 'integer', minimum: 1, maximum: 500, default: 100}, top_n: {type: 'integer', minimum: 1, maximum: 50, default: 10}}}, run: (g, a, ctx) => thi.tGitHistory(g, a, ctx)},
|
|
42
|
+
{
|
|
43
|
+
cap: 'graph', refreshGraph: true, name: 'verified_change',
|
|
44
|
+
description: 'Proof-carrying change workflow. Given a natural-language task and current diff/files, returns compact edit contexts, bounded call-argument data-flow, blast radius, graph/architecture/duplicate/API ratchets, affected tests, and one PASS/BLOCKED/UNKNOWN verdict. Package tests run only when explicitly requested and WEAVATRIX_ALLOW_TEST_RUNS=1.',
|
|
45
|
+
inputSchema: {type: 'object', additionalProperties: false, properties: {
|
|
46
|
+
task: {type: 'string', minLength: 1, maxLength: 4000}, phase: {type: 'string', enum: ['plan', 'verify'], default: 'plan'},
|
|
47
|
+
base_ref: {type: 'string', maxLength: 200, default: 'HEAD'}, diff: {type: 'string', maxLength: 2097152}, files: {type: 'array', items: {type: 'string'}, maxItems: 500},
|
|
48
|
+
precision: {type: 'string', enum: ['auto', 'graph', 'lsp'], default: 'auto'}, max_symbols: {type: 'integer', minimum: 1, maximum: 5, default: 3},
|
|
49
|
+
impact_depth: {type: 'integer', minimum: 1, maximum: 4, default: 2}, max_impact_nodes: {type: 'integer', minimum: 5, maximum: 120, default: 40},
|
|
50
|
+
data_flow_depth: {type: 'integer', minimum: 1, maximum: 3, default: 2}, max_data_flow_edges: {type: 'integer', minimum: 1, maximum: 60, default: 30},
|
|
51
|
+
duplicate_ratchet: {type: 'boolean', default: true},
|
|
52
|
+
api_contract: {type: 'object', additionalProperties: true, properties: {backend: {type: 'string'}, clients: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 20}, method: {type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']}, path: {type: 'string', maxLength: 2048}, changed_files: {type: 'array', items: {type: 'string'}, maxItems: 500}}, required: ['backend', 'clients']},
|
|
53
|
+
tests: {type: 'array', maxItems: 5, items: {type: 'object', additionalProperties: false, properties: {script: {type: 'string', maxLength: 120}, args: {type: 'array', maxItems: 40, items: {type: 'string', maxLength: 300}}}, required: ['script']}},
|
|
54
|
+
run_tests: {type: 'boolean', default: false}, test_timeout_ms: {type: 'integer', minimum: 1000, maximum: 300000, default: 60000},
|
|
55
|
+
}, required: ['task']},
|
|
56
|
+
run: (g, a, ctx) => tv.tVerifiedChange(g, a, ctx, {
|
|
57
|
+
impact: ti.tChangeImpact, context: tb.tContextBundle, inspect: ts.tInspectSymbol,
|
|
58
|
+
prepareChange: tar.tPrepareChange, verifyArchitecture: tar.tVerifyArchitecture, traceApi: tc.tTraceApiContract,
|
|
59
|
+
}, {source: caps.has('source'), health: caps.has('health'), crossrepo: caps.has('crossrepo')}),
|
|
60
|
+
},
|
|
42
61
|
{
|
|
43
62
|
cap: 'crossrepo',
|
|
44
63
|
name: 'trace_api_contract',
|
|
@@ -91,7 +110,7 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
|
91
110
|
{cap: 'health', name: 'find_dead_code', description: 'Conservative review queue for statically unreferenced files, functions, methods and symbols. Returns confidence, reason, bounded evidence and explicit framework/dynamic/reflection/public-API caveats; never an auto-delete verdict. Tests, generated code, mocks, stories, docs, benchmarks and temporary roots are excluded by default.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repo-relative path prefix'}, kinds: {type: 'array', items: {type: 'string', enum: ['file', 'function', 'method', 'symbol']}, maxItems: 4, uniqueItems: true, description: 'Optional candidate kinds; defaults to all'}, min_confidence: {type: 'string', enum: ['high', 'medium', 'low'], default: 'medium', description: 'Minimum confidence to include. low explicitly includes public/framework/dynamic review candidates'}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and paths explicitly classified as excluded; tests still require include_tests'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 30}}}, run: (g, a, ctx) => th.tFindDeadCode(g, a, ctx)},
|
|
92
111
|
{cap: 'health', name: 'run_audit', description: 'Full internal health audit. With base_ref, builds and audits an immutable Git checkout, derives changed files, and compares stable deterministic finding IDs; debt defaults to genuinely new findings. Supply-chain checks remain explicitly uncomparable across the source-only baseline. changed_files without base_ref is only changed-scope, never a new-debt claim.', inputSchema: {type: 'object', properties: {category: {type: 'string', enum: ['unused', 'structure', 'vulnerability', 'malware'], description: 'Only findings of this category'}, min_severity: {type: 'string', enum: ['critical', 'high', 'medium', 'low', 'info'], description: 'Minimum severity to include'}, max_findings: {type: 'integer', description: 'Max findings to list, default 30'}, include_malware_scan: {type: 'boolean', description: 'Also grep installed packages for malware heuristics (slow)', default: false}, base_ref: {type: 'string', maxLength: 200, description: 'Optional immutable Git baseline (for example HEAD~1 or origin/main). Enables honest new/existing/fixed debt comparison'}, changed_files: {type: 'array', items: {type: 'string'}, minItems: 1, maxItems: 500, description: 'Optional explicit repo-relative scope. Without base_ref this is changed-scope only; when omitted with base_ref, files are derived from the Git diff'}, debt: {type: 'string', enum: ['new', 'existing', 'all'], default: 'new', description: 'Baseline comparison view. Defaults to genuinely new deterministic findings when base_ref is present'}}}, run: (g, a, ctx) => th.tRunAudit(g, a, ctx)},
|
|
93
112
|
{cap: 'health', name: 'coverage_map', description: 'Map a real existing coverage report onto the graph. If no report exists, return clearly labelled static test reachability (a test imports/reaches a source file) with actualCoverage=NOT_AVAILABLE; reachability is never presented as measured coverage.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max risk hotspots to list, default 15'}, path: {type: 'string', description: 'Optional repo-relative path prefix filter, e.g. src/query'}}}, run: (g, a, ctx) => th.tCoverageMap(g, a, ctx)},
|
|
94
|
-
{cap: 'health', name: 'hot_path_review', description: 'Rank
|
|
113
|
+
{cap: 'health', name: 'hot_path_review', description: 'Rank a focused production-symbol hot-path queue from parser-derived local complexity, inside-loop allocations/copies/scans/sorts/recursion, graph fan-in/fan-out, and measured coverage or clearly labelled static test reachability. The default score gate is 85 with a narrow strong-local fallback; set min_score=0 for the full diagnostic queue. This is not profiler data or interprocedural Big-O.', inputSchema: {type: 'object', properties: {path: {type: 'string', maxLength: 1024, description: 'Optional repository-relative path prefix'}, top_n: {type: 'integer', minimum: 1, maximum: 100, default: 20}, min_score: {type: 'integer', minimum: 0, maximum: 100, default: 85, description: 'Focused default is 85; lower explicitly to broaden, or use 0 for every threshold-matching diagnostic candidate'}, cyclomatic_threshold: {type: 'integer', minimum: 2, maximum: 1000, default: 8}, call_threshold: {type: 'integer', minimum: 1, maximum: 10000, default: 12}, loop_depth_threshold: {type: 'integer', minimum: 1, maximum: 10, default: 2}, time_rank_threshold: {type: 'integer', minimum: 0, maximum: 5, default: 2}, include_tests: {type: 'boolean', default: false}, include_classified: {type: 'boolean', default: false, description: 'Include generated/mock/story/docs/benchmark/temp and explicitly excluded paths; tests still require include_tests'}}}, run: (g, a, ctx) => th.tHotPathReview(g, a, ctx)},
|
|
95
114
|
{cap: 'graph', name: 'list_communities', description: 'List graph communities named by their dominant folder (largest first) with sample files — a readable module overview; feed the list position into get_community.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max communities to list, default 20'}}}, run: (g, a, ctx) => th.tListCommunities(g, a, ctx)},
|
|
96
115
|
{cap: 'graph', name: 'module_map', description: 'Production-first folder architecture map with file/symbol counts and strongest module dependencies, separating runtime, TypeScript type-only and language compile-only coupling.', inputSchema: {type: 'object', properties: {top_n: {type: 'integer', description: 'Max modules to list, default 25'}, include_non_product: {type: 'boolean', default: false, description: 'Include tests, fixtures, benchmarks, generated output, docs and other classified non-product files; false by default'}}}, run: (g, a, ctx) => th.tModuleMap(g, a, ctx)},
|
|
97
116
|
{cap: 'source', refreshGraph: true, name: 'list_endpoints', description: 'Inventory of HTTP endpoints defined in the repo (Express/Fastify/Nest/Flask/FastAPI/Go mux/Rust axum and actix-web/Spring MVC and WebFlux): method, composed path, handler, and file:line.', inputSchema: {type: 'object', properties: {max_results: {type: 'integer', description: 'Max endpoints to list, default 100'}}}, run: (g, a, ctx) => th.tListEndpoints(g, a, ctx)},
|
|
@@ -134,7 +153,7 @@ function buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc}) {
|
|
|
134
153
|
// present string (even '') is an explicit selection, so "select nothing" really exposes nothing.
|
|
135
154
|
export async function loadHotApi(version, capsArg) {
|
|
136
155
|
const v = version ? `?v=${version}` : ''
|
|
137
|
-
const [tg, ti, th, ts, tb, ta, tar, thi, tc] = await Promise.all([
|
|
156
|
+
const [tg, ti, th, ts, tb, ta, tar, thi, tc, tv] = await Promise.all([
|
|
138
157
|
import(new URL(`./tools-graph.mjs${v}`, import.meta.url).href),
|
|
139
158
|
import(new URL(`./tools-impact.mjs${v}`, import.meta.url).href),
|
|
140
159
|
import(new URL(`./tools-health.mjs${v}`, import.meta.url).href),
|
|
@@ -144,12 +163,13 @@ export async function loadHotApi(version, capsArg) {
|
|
|
144
163
|
import(new URL(`./tools-architecture.mjs${v}`, import.meta.url).href),
|
|
145
164
|
import(new URL(`./tools-history.mjs${v}`, import.meta.url).href),
|
|
146
165
|
import(new URL(`./tools-company.mjs${v}`, import.meta.url).href),
|
|
166
|
+
import(new URL(`./tools-verified-change.mjs${v}`, import.meta.url).href),
|
|
147
167
|
])
|
|
148
|
-
const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc})
|
|
149
168
|
const raw = capsArg == null ? 'offline' : String(capsArg).trim()
|
|
150
169
|
const selected = PROFILE_CAPS[raw] || raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
151
170
|
.flatMap((cap) => cap === 'online' ? ['advisories', 'hosted'] : [cap])
|
|
152
171
|
const caps = new Set(selected)
|
|
172
|
+
const all = buildTools({tg, ti, th, ts, tb, ta, tar, thi, tc, tv, caps})
|
|
153
173
|
const tools = all.filter((t) => caps.has(t.cap))
|
|
154
174
|
return {
|
|
155
175
|
tools,
|
|
@@ -163,13 +163,37 @@ const CLASS_QUERY_TERMS = Object.freeze({
|
|
|
163
163
|
})
|
|
164
164
|
const CODE_FILE_RE = /\.(?:[cm]?[jt]sx?|py|go|java|rs|kt|kts|cs|rb|php)$/i
|
|
165
165
|
const DATA_OR_PROSE_RE = /\.(?:json|ya?ml|toml|ini|md|mdx|rst|adoc|html?|css|scss|less|svg)$/i
|
|
166
|
+
const LANGUAGE_EXTENSIONS = Object.freeze({
|
|
167
|
+
rust: ['rs'], python: ['py', 'pyi'], typescript: ['ts', 'tsx', 'mts', 'cts'],
|
|
168
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'], go: ['go'], java: ['java'], csharp: ['cs'],
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
function requestedLanguages(query) {
|
|
172
|
+
const raw = String(query || '')
|
|
173
|
+
const words = new Set(wordsOf(query))
|
|
174
|
+
const requested = new Set()
|
|
175
|
+
if (words.has('rust')) requested.add('rust')
|
|
176
|
+
if (words.has('python') || words.has('py')) requested.add('python')
|
|
177
|
+
if (words.has('typescript') || words.has('ts') || /typescript/i.test(raw)) requested.add('typescript')
|
|
178
|
+
if (words.has('javascript') || words.has('js') || words.has('nodejs') || /javascript|node\.?js/i.test(raw)) requested.add('javascript')
|
|
179
|
+
if (words.has('golang') || /(?:^|[^A-Za-z])Go(?:[^A-Za-z]|$)/.test(raw)) requested.add('go')
|
|
180
|
+
if (words.has('java')) requested.add('java')
|
|
181
|
+
if (words.has('csharp') || words.has('dotnet') || /csharp|(?:^|[^A-Za-z])C#(?:[^A-Za-z]|$)/i.test(raw)) requested.add('csharp')
|
|
182
|
+
return new Set([...requested].flatMap((language) => LANGUAGE_EXTENSIONS[language]))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const matchesLanguage = (node, extensions) => {
|
|
186
|
+
if (!extensions.size) return true
|
|
187
|
+
const match = /\.([^.\/]+)$/.exec(sourceFileOf(node))
|
|
188
|
+
return !!match && extensions.has(match[1].toLowerCase())
|
|
189
|
+
}
|
|
166
190
|
|
|
167
191
|
const sourceFileOf = (node) => {
|
|
168
192
|
const source = node?.source_file || String(node?.id ?? '').split('#', 1)[0]
|
|
169
193
|
return normPath(source)
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
function requestedPathClasses(query) {
|
|
196
|
+
export function requestedPathClasses(query) {
|
|
173
197
|
const words = new Set(wordsOf(query))
|
|
174
198
|
const requested = new Set()
|
|
175
199
|
for (const [category, terms] of Object.entries(CLASS_QUERY_TERMS)) {
|
|
@@ -266,6 +290,14 @@ function conceptScore(g, node, concept, queryContext) {
|
|
|
266
290
|
else if (words.has(term)) match = Math.max(match, primary ? 36 : 25)
|
|
267
291
|
else if (term.length >= 4 && term !== 'tool' && term !== 'tools' && (id.includes(term) || label.includes(term))) match = Math.max(match, primary ? 12 : 7)
|
|
268
292
|
})
|
|
293
|
+
const extension = (/\.([^.\/]+)$/.exec(source) || [])[1] || ''
|
|
294
|
+
const languageConcept = {
|
|
295
|
+
rust: ['rs'], python: ['py', 'pyi'], py: ['py', 'pyi'],
|
|
296
|
+
typescript: ['ts', 'tsx', 'mts', 'cts'], ts: ['ts', 'tsx', 'mts', 'cts'],
|
|
297
|
+
javascript: ['js', 'jsx', 'mjs', 'cjs'], js: ['js', 'jsx', 'mjs', 'cjs'], nodejs: ['js', 'jsx', 'mjs', 'cjs'],
|
|
298
|
+
golang: ['go'], go: ['go'], java: ['java'], csharp: ['cs'], dotnet: ['cs'],
|
|
299
|
+
}[concept.raw]
|
|
300
|
+
if (languageConcept?.includes(extension) && queryContext.languageExtensions.has(extension)) match = Math.max(match, 58)
|
|
269
301
|
const fileNode = !isSymbol(node.id)
|
|
270
302
|
const depth = source ? source.split('/').length : 9
|
|
271
303
|
if (concept.id === 'bootstrap') match = Math.max(match, entrypointSignal(g, node, source, stem))
|
|
@@ -288,13 +320,16 @@ export function findSeeds(g, query, limit = 8, {repoRoot = null} = {}) {
|
|
|
288
320
|
const concepts = queryConcepts(query)
|
|
289
321
|
if (!concepts.length || limit <= 0) return []
|
|
290
322
|
const requestedClasses = requestedPathClasses(query)
|
|
323
|
+
const languageExtensions = requestedLanguages(query)
|
|
291
324
|
const queryContext = {
|
|
292
325
|
runtimeIntent: concepts.some((concept) => concept.id === 'bootstrap' || concept.id === 'tool-execution'),
|
|
293
326
|
maintenanceIntent: wordsOf(query).some((word) => ['script', 'scripts', 'build', 'release', 'publish', 'packaging'].includes(word)),
|
|
327
|
+
languageExtensions,
|
|
294
328
|
}
|
|
295
329
|
const classifier = createPathClassifier(repoRoot)
|
|
296
330
|
const classificationCache = new Map()
|
|
297
|
-
const rows = g.nodes.filter((node) =>
|
|
331
|
+
const rows = g.nodes.filter((node) => matchesLanguage(node, languageExtensions)
|
|
332
|
+
&& isQueryEligible(node, requestedClasses, classificationCache, classifier)).map((node) => {
|
|
298
333
|
const scores = concepts.map((concept) => conceptScore(g, node, concept, queryContext))
|
|
299
334
|
return {node, scores, total: Math.max(...scores) + scores.reduce((sum, score) => sum + score, 0) / 10}
|
|
300
335
|
})
|