@toolbaux/guardian 0.1.22 → 0.1.23
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 +1 -1
- package/dist/adapters/runner.js +72 -3
- package/dist/adapters/typescript-adapter.js +24 -10
- package/dist/benchmarking/metrics/context-coverage.js +82 -0
- package/dist/benchmarking/metrics/drift-score.js +104 -0
- package/dist/benchmarking/metrics/search-recall.js +207 -0
- package/dist/benchmarking/metrics/token-efficiency.js +79 -0
- package/dist/benchmarking/report.js +131 -0
- package/dist/benchmarking/runner.js +175 -0
- package/dist/benchmarking/types.js +13 -0
- package/dist/cli.js +53 -10
- package/dist/commands/benchmark.js +62 -0
- package/dist/commands/discrepancy.js +1 -1
- package/dist/commands/doc-generate.js +1 -1
- package/dist/commands/doc-html.js +1 -1
- package/dist/commands/extract.js +1 -1
- package/dist/commands/feature-context.js +1 -1
- package/dist/commands/init.js +1 -0
- package/dist/commands/intel.js +47 -1
- package/dist/commands/mcp-serve.js +48 -321
- package/dist/commands/search.js +602 -14
- package/dist/db/file-specs-store.js +174 -0
- package/dist/db/fts-builder.js +305 -0
- package/dist/db/index.js +55 -0
- package/dist/db/specs-store.js +13 -0
- package/dist/db/sqlite-specs-store.js +441 -0
- package/dist/extract/codebase-intel.js +31 -2
- package/dist/extract/compress.js +70 -3
- package/dist/extract/context-block.js +11 -2
- package/dist/extract/function-intel.js +5 -2
- package/dist/extract/index.js +1 -23
- package/dist/extract/writer.js +6 -0
- package/package.json +3 -1
package/dist/commands/search.js
CHANGED
|
@@ -7,6 +7,55 @@ import { resolveMachineInputDir } from "../output-layout.js";
|
|
|
7
7
|
import { DEFAULT_SPECS_DIR } from "../config.js";
|
|
8
8
|
export async function runSearch(options) {
|
|
9
9
|
const inputDir = await resolveMachineInputDir(options.input || DEFAULT_SPECS_DIR);
|
|
10
|
+
// ── SQLite/FTS5 backend: BM25-ranked search via guardian.db ──────────────
|
|
11
|
+
// SQLite is primary for ALL formats when guardian.db exists.
|
|
12
|
+
// File-based search is only a fallback for backward compatibility.
|
|
13
|
+
if ((options.backend === "sqlite" || options.backend === "auto") && options.query) {
|
|
14
|
+
if (options.format === "json") {
|
|
15
|
+
// For JSON output (used by MCP): merge BM25-ranked files into querySearch output
|
|
16
|
+
const sqliteResult = await getSqliteFileList(options.input || DEFAULT_SPECS_DIR, options.query, options.topN ?? 20, options.backend);
|
|
17
|
+
if (sqliteResult !== null) {
|
|
18
|
+
const base = JSON.parse(await querySearch(inputDir, options.query));
|
|
19
|
+
base.files = sqliteResult.files;
|
|
20
|
+
base.search_signal = sqliteResult.signal;
|
|
21
|
+
console.log(JSON.stringify(base));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// No guardian.db — fall through to file-based querySearch below
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const handled = await runSearchSqlite(options.input || DEFAULT_SPECS_DIR, options.query, options.topN ?? 20, options.backend);
|
|
28
|
+
if (handled)
|
|
29
|
+
return; // false = no guardian.db, fall through to file search
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// ── Mode dispatch: intel-based lookups ──
|
|
33
|
+
if (options.orient) {
|
|
34
|
+
console.log(await queryOrient(inputDir));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (options.file) {
|
|
38
|
+
console.log(await queryFile(inputDir, options.file));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (options.model) {
|
|
42
|
+
console.log(await queryModel(inputDir, options.model));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (options.impact) {
|
|
46
|
+
console.log(await queryImpact(inputDir, options.impact));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
// ── Semantic search ──
|
|
50
|
+
if (!options.query) {
|
|
51
|
+
console.error("Error: --query is required for semantic search (or use --orient / --file / --model / --impact)");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
if (options.format === "json") {
|
|
55
|
+
// Fallback: file-based categorical search (no guardian.db available)
|
|
56
|
+
console.log(await querySearch(inputDir, options.query));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
10
59
|
const { architecture, ux } = await loadSnapshots(inputDir);
|
|
11
60
|
const heatmap = await loadHeatmap(inputDir);
|
|
12
61
|
const funcIntel = await loadFunctionIntelligence(inputDir);
|
|
@@ -22,7 +71,9 @@ export async function runSearch(options) {
|
|
|
22
71
|
projectRoot,
|
|
23
72
|
topN: options.topN ?? 10,
|
|
24
73
|
});
|
|
25
|
-
const content =
|
|
74
|
+
const content = options.verbose
|
|
75
|
+
? renderSearchMarkdownVerbose(options.query, matches)
|
|
76
|
+
: renderSearchMarkdown(options.query, matches);
|
|
26
77
|
if (options.output) {
|
|
27
78
|
const outputPath = path.resolve(options.output);
|
|
28
79
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
@@ -32,6 +83,107 @@ export async function runSearch(options) {
|
|
|
32
83
|
}
|
|
33
84
|
console.log(content);
|
|
34
85
|
}
|
|
86
|
+
// ── SQLite / FTS5 search path ────────────────────────────────────────────────
|
|
87
|
+
/**
|
|
88
|
+
* Preprocess a user query before FTS5 matching.
|
|
89
|
+
* Strips commit-message noise (issue numbers, conventional commit prefixes, PR refs)
|
|
90
|
+
* and expands camelCase/snake_case identifiers so BM25 ranks them correctly.
|
|
91
|
+
*/
|
|
92
|
+
function preprocessSearchQuery(q) {
|
|
93
|
+
return q
|
|
94
|
+
// Remove PR/issue references: (#1234) or #1234
|
|
95
|
+
.replace(/\(#\d+\)/g, "")
|
|
96
|
+
.replace(/#\d+\s*/g, "")
|
|
97
|
+
// Remove conventional commit prefixes: "Fixed #37016 --", "Refs #28455 --"
|
|
98
|
+
.replace(/^(?:Fixed|Refs|Closes|Resolved)\s*(?:#\d+\s*)?--?\s*/i, "")
|
|
99
|
+
// Remove conventional commit types: "feat(deps)!:", "chore:", "docs:", etc.
|
|
100
|
+
.replace(/^(?:feat|fix|chore|docs|test|refactor|style|perf|ci|build)(?:\([^)]+\))?!?:\s*/i, "")
|
|
101
|
+
// Remove double dashes
|
|
102
|
+
.replace(/\s*--\s*/g, " ")
|
|
103
|
+
// Expand camelCase: getUserById → get user by id
|
|
104
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
105
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
106
|
+
// Expand snake_case: get_user_by_id → get user by id
|
|
107
|
+
.replace(/_/g, " ")
|
|
108
|
+
// Normalize whitespace
|
|
109
|
+
.replace(/\s+/g, " ")
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Returns false if no guardian.db found and backend is "auto" (caller should fall through to file search).
|
|
114
|
+
* Exits the process if backend is "sqlite" and no db found.
|
|
115
|
+
*/
|
|
116
|
+
async function runSearchSqlite(specsInput, query, limit, backend = "sqlite") {
|
|
117
|
+
const { openSpecsStore } = await import("../db/index.js");
|
|
118
|
+
const { SqliteSpecsStore } = await import("../db/sqlite-specs-store.js");
|
|
119
|
+
const { getOutputLayout } = await import("../output-layout.js");
|
|
120
|
+
const layout = getOutputLayout(path.resolve(specsInput));
|
|
121
|
+
const store = await openSpecsStore(layout, { backend });
|
|
122
|
+
try {
|
|
123
|
+
if (!(store instanceof SqliteSpecsStore)) {
|
|
124
|
+
if (backend === "auto")
|
|
125
|
+
return false; // fall through to file search
|
|
126
|
+
console.error("guardian.db not found — run `guardian extract --backend sqlite` first.");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const cleaned = preprocessSearchQuery(query);
|
|
130
|
+
let results = store.searchWithGraph(cleaned, limit);
|
|
131
|
+
// If preprocessed query returns nothing, try the raw query as a fallback
|
|
132
|
+
if (results.length === 0 && cleaned !== query) {
|
|
133
|
+
results = store.searchWithGraph(query, limit);
|
|
134
|
+
}
|
|
135
|
+
if (results.length === 0) {
|
|
136
|
+
if (backend === "auto")
|
|
137
|
+
return false; // fall through to file-based search
|
|
138
|
+
console.log(`No FTS results for "${query}"`);
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
const lines = [`## FTS5 search: "${query}"\n`];
|
|
142
|
+
for (const r of results) {
|
|
143
|
+
const rank = Math.abs(r.rank).toFixed(3);
|
|
144
|
+
lines.push(`### \`${r.file_path}\` (score: ${rank})`);
|
|
145
|
+
if (r.symbol_name)
|
|
146
|
+
lines.push(` symbols: ${r.symbol_name}`);
|
|
147
|
+
if (r.imports.length)
|
|
148
|
+
lines.push(` imports: ${r.imports.join(", ")}`);
|
|
149
|
+
if (r.used_by.length)
|
|
150
|
+
lines.push(` used by: ${r.used_by.join(", ")}`);
|
|
151
|
+
lines.push("");
|
|
152
|
+
}
|
|
153
|
+
console.log(lines.join("\n"));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await store.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function getSqliteFileList(specsInput, query, limit, backend = "auto") {
|
|
161
|
+
const { openSpecsStore } = await import("../db/index.js");
|
|
162
|
+
const { SqliteSpecsStore } = await import("../db/sqlite-specs-store.js");
|
|
163
|
+
const { getOutputLayout } = await import("../output-layout.js");
|
|
164
|
+
const layout = getOutputLayout(path.resolve(specsInput));
|
|
165
|
+
const store = await openSpecsStore(layout, { backend });
|
|
166
|
+
try {
|
|
167
|
+
if (!(store instanceof SqliteSpecsStore)) {
|
|
168
|
+
return null; // no guardian.db — caller uses file-based fallback
|
|
169
|
+
}
|
|
170
|
+
const cleaned = preprocessSearchQuery(query);
|
|
171
|
+
let results = store.searchWithGraph(cleaned, limit);
|
|
172
|
+
// If preprocessed query returns nothing, try raw query
|
|
173
|
+
if (results.length === 0 && cleaned !== query) {
|
|
174
|
+
results = store.searchWithGraph(query, limit);
|
|
175
|
+
}
|
|
176
|
+
// Return null on 0 results so caller can fall back to querySearch()
|
|
177
|
+
if (results.length === 0)
|
|
178
|
+
return null;
|
|
179
|
+
const signal = store.querySignal(query);
|
|
180
|
+
return { files: results.map((r) => r.file_path), signal };
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await store.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── File-based snapshots loader (original, unchanged) ────────────────────────
|
|
35
187
|
async function loadSnapshots(inputDir) {
|
|
36
188
|
const architecturePath = path.join(inputDir, "architecture.snapshot.yaml");
|
|
37
189
|
const uxPath = path.join(inputDir, "ux.snapshot.yaml");
|
|
@@ -55,24 +207,21 @@ async function loadSnapshots(inputDir) {
|
|
|
55
207
|
};
|
|
56
208
|
}
|
|
57
209
|
function normalizeTypes(types) {
|
|
210
|
+
const ALL_TYPES = ["models", "endpoints", "components", "modules", "tasks", "files"];
|
|
58
211
|
if (!types || types.length === 0) {
|
|
59
|
-
return new Set(
|
|
212
|
+
return new Set(ALL_TYPES);
|
|
60
213
|
}
|
|
61
214
|
const normalized = new Set();
|
|
62
215
|
for (const entry of types) {
|
|
63
216
|
for (const part of entry.split(",").map((value) => value.trim().toLowerCase())) {
|
|
64
|
-
if (part === "
|
|
65
|
-
part === "endpoints" ||
|
|
66
|
-
part === "components" ||
|
|
67
|
-
part === "modules" ||
|
|
68
|
-
part === "tasks") {
|
|
217
|
+
if (ALL_TYPES.includes(part) || part === "functions") {
|
|
69
218
|
normalized.add(part);
|
|
70
219
|
}
|
|
71
220
|
}
|
|
72
221
|
}
|
|
73
222
|
return normalized.size > 0
|
|
74
223
|
? normalized
|
|
75
|
-
: new Set([
|
|
224
|
+
: new Set([...ALL_TYPES, "functions"]);
|
|
76
225
|
}
|
|
77
226
|
function tokenize(value) {
|
|
78
227
|
return value
|
|
@@ -124,6 +273,31 @@ function searchSnapshots(params) {
|
|
|
124
273
|
entry.id,
|
|
125
274
|
entry.score
|
|
126
275
|
]));
|
|
276
|
+
// PageRank scores per file — prefer file-level heatmap, fall back to module-level
|
|
277
|
+
// (maps absolute or relative file path → pagerank score in [0,1])
|
|
278
|
+
const filePrFromFileLevel = new Map((heatmap?.levels.find((level) => level.level === "file")?.entries ?? []).map((entry) => [
|
|
279
|
+
entry.id,
|
|
280
|
+
entry.components.pagerank ?? 0
|
|
281
|
+
]));
|
|
282
|
+
// Build file→module map so we can use module-level PR when file-level is unavailable
|
|
283
|
+
const fileToModuleId = new Map();
|
|
284
|
+
for (const mod of architecture.modules) {
|
|
285
|
+
for (const f of mod.files) {
|
|
286
|
+
fileToModuleId.set(f, mod.id);
|
|
287
|
+
fileToModuleId.set(path.join(projectRoot, f), mod.id);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const modulePrMap = new Map((heatmap?.levels.find((level) => level.level === "module")?.entries ?? []).map((entry) => [
|
|
291
|
+
entry.id,
|
|
292
|
+
entry.components.pagerank ?? 0
|
|
293
|
+
]));
|
|
294
|
+
const getFilePr = (filePath) => {
|
|
295
|
+
const direct = filePrFromFileLevel.get(filePath);
|
|
296
|
+
if (direct !== undefined)
|
|
297
|
+
return direct;
|
|
298
|
+
const modId = fileToModuleId.get(filePath) ?? fileToModuleId.get(path.relative(projectRoot, filePath));
|
|
299
|
+
return modulePrMap.get(modId ?? "") ?? 0;
|
|
300
|
+
};
|
|
127
301
|
if (types.has("models")) {
|
|
128
302
|
for (const model of architecture.data_models) {
|
|
129
303
|
const score = scoreItem(queryTokens, {
|
|
@@ -244,6 +418,57 @@ function searchSnapshots(params) {
|
|
|
244
418
|
});
|
|
245
419
|
}
|
|
246
420
|
}
|
|
421
|
+
if (types.has("files")) {
|
|
422
|
+
const allFiles = new Map(); // keyed by normalized project-relative path
|
|
423
|
+
// Helper: normalize a path to project-relative form
|
|
424
|
+
const normalizePath = (rawPath, moduleId) => {
|
|
425
|
+
if (rawPath.startsWith("frontend/") || rawPath.startsWith("backend/"))
|
|
426
|
+
return rawPath;
|
|
427
|
+
// UX snapshot stores paths relative to frontend root (e.g. "app/parent/login.tsx")
|
|
428
|
+
if (moduleId.startsWith("frontend/"))
|
|
429
|
+
return `frontend/${rawPath}`;
|
|
430
|
+
return rawPath;
|
|
431
|
+
};
|
|
432
|
+
for (const mod of architecture.modules) {
|
|
433
|
+
for (const f of mod.files) {
|
|
434
|
+
const norm = normalizePath(f, mod.id);
|
|
435
|
+
const pr = getFilePr(f) || getFilePr(norm);
|
|
436
|
+
allFiles.set(norm, { filePath: norm, module: mod.id, pagerank: pr });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Also collect ux component files (may not be in arch modules)
|
|
440
|
+
for (const comp of ux.components) {
|
|
441
|
+
if (!comp.file)
|
|
442
|
+
continue;
|
|
443
|
+
const norm = normalizePath(comp.file, "frontend/app");
|
|
444
|
+
if (!allFiles.has(norm)) {
|
|
445
|
+
allFiles.set(norm, { filePath: norm, module: "frontend/app", pagerank: getFilePr(norm) });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
for (const { filePath, module: modId, pagerank } of allFiles.values()) {
|
|
449
|
+
const filename = path.basename(filePath);
|
|
450
|
+
const stem = filename.replace(/\.[^.]+$/, ""); // without extension
|
|
451
|
+
// Score: query overlap against path segments + filename stem
|
|
452
|
+
const pathSegments = filePath.split("/");
|
|
453
|
+
const queryScore = scoreItem(queryTokens, {
|
|
454
|
+
name: stem,
|
|
455
|
+
file: filePath,
|
|
456
|
+
text: pathSegments
|
|
457
|
+
});
|
|
458
|
+
if (queryScore <= 0)
|
|
459
|
+
continue;
|
|
460
|
+
// Blend query relevance + PageRank (architecturally important files surface higher)
|
|
461
|
+
const score = 0.7 * queryScore + 0.3 * pagerank;
|
|
462
|
+
matches.push({
|
|
463
|
+
type: "files",
|
|
464
|
+
name: filePath,
|
|
465
|
+
score,
|
|
466
|
+
markdown: [
|
|
467
|
+
`${filePath} [${modId}]${pagerank > 0.5 ? " · high-pagerank" : ""}`
|
|
468
|
+
]
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
247
472
|
if (types.has("functions") && funcIntel) {
|
|
248
473
|
const queryTokens = tokenize(query);
|
|
249
474
|
const fnMatches = [];
|
|
@@ -282,13 +507,16 @@ function searchSnapshots(params) {
|
|
|
282
507
|
};
|
|
283
508
|
// 1. Name match — function / theorem name contains a query token
|
|
284
509
|
for (const fn of funcIntel.functions) {
|
|
285
|
-
const
|
|
510
|
+
const queryScore = scoreItem(queryTokens, {
|
|
286
511
|
name: fn.name,
|
|
287
512
|
file: fn.file,
|
|
288
513
|
text: [...fn.stringLiterals, ...fn.regexPatterns, ...fn.calls, fn.language],
|
|
289
514
|
});
|
|
290
|
-
if (
|
|
515
|
+
if (queryScore <= 0)
|
|
291
516
|
continue;
|
|
517
|
+
// Blend: 70% query relevance + 30% file PageRank (importance of the file in the graph)
|
|
518
|
+
const pr = getFilePr(fn.file);
|
|
519
|
+
const score = 0.7 * queryScore + 0.3 * pr;
|
|
292
520
|
const relFile = relativize(fn.file);
|
|
293
521
|
const lineRange = `${fn.lines[0]}–${fn.lines[1]}`;
|
|
294
522
|
const detail = buildDetail(fn, relFile);
|
|
@@ -316,17 +544,19 @@ function searchSnapshots(params) {
|
|
|
316
544
|
const fn = funcIntel.functions.find((f) => f.file === hit.file && f.name === hit.function);
|
|
317
545
|
if (!fn)
|
|
318
546
|
continue;
|
|
319
|
-
const
|
|
547
|
+
const queryScore = scoreItem(queryTokens, {
|
|
320
548
|
name: fn.name,
|
|
321
549
|
file: fn.file,
|
|
322
550
|
text: [...fn.stringLiterals, ...fn.regexPatterns, ...fn.calls, fn.language],
|
|
323
551
|
});
|
|
552
|
+
const pr = getFilePr(fn.file);
|
|
553
|
+
const score = Math.max(0.7 * queryScore + 0.3 * pr, 0.2);
|
|
324
554
|
const relFile = relativize(fn.file);
|
|
325
555
|
const detail = buildDetail(fn, relFile);
|
|
326
556
|
fnMatches.push({
|
|
327
557
|
type: "functions",
|
|
328
558
|
name: `${fn.name} (${fn.language})`,
|
|
329
|
-
score
|
|
559
|
+
score,
|
|
330
560
|
markdown: [
|
|
331
561
|
`**${fn.name}** · ${relFile}:${fn.lines[0]}–${fn.lines[1]} · ${fn.language}`,
|
|
332
562
|
`Matched literal/pattern containing "${tok}"`,
|
|
@@ -379,7 +609,92 @@ function formatProps(props) {
|
|
|
379
609
|
.map((prop) => `${prop.name}${prop.optional ? "?" : ""}: ${prop.type}`)
|
|
380
610
|
.join(", ");
|
|
381
611
|
}
|
|
612
|
+
/**
|
|
613
|
+
* Compact file-first renderer — the default for agent navigation.
|
|
614
|
+
*
|
|
615
|
+
* Deduplicates by file path and emits one line per file:
|
|
616
|
+
* backend/service-auth/main.py [create_child, PersonaCreateRequest, ...]
|
|
617
|
+
*
|
|
618
|
+
* Keeps total output small so LLMs can extract the answer without wading
|
|
619
|
+
* through hundreds of match lines. Capped at 15 files max.
|
|
620
|
+
*/
|
|
382
621
|
function renderSearchMarkdown(query, matches) {
|
|
622
|
+
if (matches.length === 0) {
|
|
623
|
+
return `# Search: "${query}"\n\n*No matches found.*`;
|
|
624
|
+
}
|
|
625
|
+
// Build a file → {score, symbols} map. Each match contributes its file path
|
|
626
|
+
// and a short symbol label extracted from the first markdown line.
|
|
627
|
+
const fileMap = new Map();
|
|
628
|
+
const extractFile = (md, matchType) => {
|
|
629
|
+
// Modules are collections — their path isn't a usable file path; skip them.
|
|
630
|
+
if (matchType === "modules")
|
|
631
|
+
return null;
|
|
632
|
+
const first = md[0] ?? "";
|
|
633
|
+
// Endpoint format: "POST /path → handler (file.py)"
|
|
634
|
+
let m = first.match(/\(([^)]+)\)\s*$/);
|
|
635
|
+
if (m)
|
|
636
|
+
return m[1].trim();
|
|
637
|
+
// Files type: bare path at start, no bold markdown — check before model format
|
|
638
|
+
// "path/to/file [module]" or "path/to/file [module] · high-pagerank"
|
|
639
|
+
m = first.match(/^([^\s[*]+)\s+\[/);
|
|
640
|
+
if (m)
|
|
641
|
+
return m[1].trim();
|
|
642
|
+
// Model/component/task/function: "**Name** · file.py ..."
|
|
643
|
+
m = first.match(/·\s+([^\s·:]+)\s*(?:·|$)/);
|
|
644
|
+
if (m)
|
|
645
|
+
return m[1].trim();
|
|
646
|
+
return null;
|
|
647
|
+
};
|
|
648
|
+
const extractSymbol = (md, matchType) => {
|
|
649
|
+
const first = md[0] ?? "";
|
|
650
|
+
if (matchType === "endpoints") {
|
|
651
|
+
// "POST /path → handler (file)" → extract "handler"
|
|
652
|
+
const m = first.match(/→\s+(\S+)\s+\(/);
|
|
653
|
+
return m ? m[1] : null;
|
|
654
|
+
}
|
|
655
|
+
if (matchType === "models" || matchType === "tasks" || matchType === "functions") {
|
|
656
|
+
// "**Name** · file" → extract "Name"
|
|
657
|
+
const m = first.match(/\*\*([^*]+)\*\*/);
|
|
658
|
+
return m ? m[1] : null;
|
|
659
|
+
}
|
|
660
|
+
if (matchType === "components") {
|
|
661
|
+
const m = first.match(/\*\*([^*]+)\*\*/);
|
|
662
|
+
return m ? m[1] : null;
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
};
|
|
666
|
+
for (const match of matches) {
|
|
667
|
+
const file = extractFile(match.markdown, match.type);
|
|
668
|
+
if (!file)
|
|
669
|
+
continue;
|
|
670
|
+
const existing = fileMap.get(file);
|
|
671
|
+
const symbol = extractSymbol(match.markdown, match.type);
|
|
672
|
+
if (existing) {
|
|
673
|
+
if (match.score > existing.score)
|
|
674
|
+
existing.score = match.score;
|
|
675
|
+
if (symbol && !existing.symbols.includes(symbol))
|
|
676
|
+
existing.symbols.push(symbol);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
fileMap.set(file, { score: match.score, symbols: symbol ? [symbol] : [] });
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Sort files by best score descending, cap at 15
|
|
683
|
+
const ranked = Array.from(fileMap.entries())
|
|
684
|
+
.sort(([, a], [, b]) => b.score - a.score)
|
|
685
|
+
.slice(0, 15);
|
|
686
|
+
const lines = [];
|
|
687
|
+
lines.push(`# Search: "${query}" — ${ranked.length} relevant files\n`);
|
|
688
|
+
for (const [file, { symbols }] of ranked) {
|
|
689
|
+
const sym = symbols.slice(0, 6).join(", ");
|
|
690
|
+
lines.push(sym ? `${file} [${sym}]` : file);
|
|
691
|
+
}
|
|
692
|
+
return lines.join("\n").trimEnd();
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Verbose grouped renderer — kept for human inspection (`--verbose`).
|
|
696
|
+
*/
|
|
697
|
+
function renderSearchMarkdownVerbose(query, matches) {
|
|
383
698
|
const grouped = new Map();
|
|
384
699
|
for (const match of matches) {
|
|
385
700
|
const entry = grouped.get(match.type) ?? [];
|
|
@@ -392,6 +707,7 @@ function renderSearchMarkdown(query, matches) {
|
|
|
392
707
|
["components", "Components"],
|
|
393
708
|
["modules", "Modules"],
|
|
394
709
|
["tasks", "Tasks"],
|
|
710
|
+
["files", "Files"],
|
|
395
711
|
["functions", "Functions"],
|
|
396
712
|
];
|
|
397
713
|
const lines = [];
|
|
@@ -403,9 +719,8 @@ function renderSearchMarkdown(query, matches) {
|
|
|
403
719
|
}
|
|
404
720
|
for (const [type, label] of labels) {
|
|
405
721
|
const entries = grouped.get(type) ?? [];
|
|
406
|
-
if (entries.length === 0)
|
|
722
|
+
if (entries.length === 0)
|
|
407
723
|
continue;
|
|
408
|
-
}
|
|
409
724
|
lines.push(`## ${label} (${entries.length})`);
|
|
410
725
|
lines.push("");
|
|
411
726
|
for (const entry of entries.slice(0, 8)) {
|
|
@@ -415,3 +730,276 @@ function renderSearchMarkdown(query, matches) {
|
|
|
415
730
|
}
|
|
416
731
|
return lines.join("\n").trimEnd();
|
|
417
732
|
}
|
|
733
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
734
|
+
// Intel-based query functions
|
|
735
|
+
// Read from pre-built intelligence files (written by VSCode plugin / guardian extract).
|
|
736
|
+
// These are the authoritative implementations — MCP tools call the CLI which calls these.
|
|
737
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
738
|
+
async function loadCodebaseIntel(inputDir) {
|
|
739
|
+
const intelPath = path.join(inputDir, "codebase-intelligence.json");
|
|
740
|
+
try {
|
|
741
|
+
const raw = await fs.readFile(intelPath, "utf8");
|
|
742
|
+
return JSON.parse(raw);
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return { api_registry: {}, model_registry: {}, service_map: [], frontend_pages: [], enum_registry: {}, background_tasks: [], meta: {} };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
async function loadFuncIntelRaw(inputDir) {
|
|
749
|
+
const fnPath = path.join(inputDir, "function-intelligence.json");
|
|
750
|
+
try {
|
|
751
|
+
const raw = await fs.readFile(fnPath, "utf8");
|
|
752
|
+
return JSON.parse(raw);
|
|
753
|
+
}
|
|
754
|
+
catch {
|
|
755
|
+
return null;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
// ── Scoring (same algorithm as MCP, kept in sync) ──
|
|
759
|
+
const SKIP_SERVICES = new Set(["str", "dict", "int", "len", "float", "max", "join", "getattr", "lower", "open", "params.append", "updates.append"]);
|
|
760
|
+
function isGenericCall(s) {
|
|
761
|
+
if (SKIP_SERVICES.has(s))
|
|
762
|
+
return true;
|
|
763
|
+
const genericPrefixes = ["service.", "self.", "db.", "session.", "response.", "request.", "app.", "router.", "logger.", "config.", "os.", "json.", "re.", "datetime.", "uuid."];
|
|
764
|
+
return genericPrefixes.some(p => s.toLowerCase().startsWith(p));
|
|
765
|
+
}
|
|
766
|
+
function scoreQueryIntel(query, fields) {
|
|
767
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
768
|
+
let best = 0;
|
|
769
|
+
for (const { value, weight } of fields) {
|
|
770
|
+
if (!value)
|
|
771
|
+
continue;
|
|
772
|
+
const low = value.toLowerCase();
|
|
773
|
+
if (low === query.toLowerCase()) {
|
|
774
|
+
best = Math.max(best, weight * 1.0);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (low.includes(query.toLowerCase())) {
|
|
778
|
+
best = Math.max(best, weight * 0.8);
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (tokens.length > 1 && tokens.every(t => low.includes(t))) {
|
|
782
|
+
best = Math.max(best, weight * 0.6);
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
const matched = tokens.filter(t => t.length >= 3 && low.includes(t)).length;
|
|
786
|
+
if (matched > 0) {
|
|
787
|
+
best = Math.max(best, weight * (matched >= 2 ? 0.45 : 0.3));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return best;
|
|
791
|
+
}
|
|
792
|
+
function normalizeFilePath(p) {
|
|
793
|
+
return p.replace(/^\.\//, "").replace(/\/\//g, "/");
|
|
794
|
+
}
|
|
795
|
+
function findModuleForFile(data, file) {
|
|
796
|
+
const f = normalizeFilePath(file);
|
|
797
|
+
return data.service_map?.find((m) => {
|
|
798
|
+
const mp = normalizeFilePath(m.path || "");
|
|
799
|
+
return mp && (f.startsWith(mp + "/") || f === mp);
|
|
800
|
+
}) || data.service_map?.find((m) => {
|
|
801
|
+
const mid = normalizeFilePath(m.id || "");
|
|
802
|
+
return mid && f.includes(mid);
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function findEndpointsInFile(data, file) {
|
|
806
|
+
const f = normalizeFilePath(file);
|
|
807
|
+
const basename = path.basename(f);
|
|
808
|
+
return Object.values(data.api_registry || {}).filter((ep) => {
|
|
809
|
+
const ef = normalizeFilePath(ep.file || "");
|
|
810
|
+
return ef && (f.includes(ef) || ef.includes(f) || ef.endsWith(basename));
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function findModelsInFile(data, file) {
|
|
814
|
+
const f = normalizeFilePath(file);
|
|
815
|
+
const basename = path.basename(f);
|
|
816
|
+
return Object.values(data.model_registry || {}).filter((m) => {
|
|
817
|
+
const mf = normalizeFilePath(m.file || "");
|
|
818
|
+
return mf && (f.includes(mf) || mf.includes(f) || mf.endsWith(basename));
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
// ── orient: architecture-context.md as compact JSON ──
|
|
822
|
+
export async function queryOrient(inputDir) {
|
|
823
|
+
const contextPath = path.join(inputDir, "architecture-context.md");
|
|
824
|
+
try {
|
|
825
|
+
const raw = await fs.readFile(contextPath, "utf8");
|
|
826
|
+
const match = raw.match(/<!-- guardian:context[^>]*-->([\s\S]*?)<!-- \/guardian:context -->/);
|
|
827
|
+
if (match) {
|
|
828
|
+
const lines = match[1].split("\n").map(l => l.trim()).filter(Boolean);
|
|
829
|
+
const desc = raw.match(/Description: (.+)/)?.[1]?.slice(0, 120) ?? "";
|
|
830
|
+
const map = lines.find(l => l.startsWith("**Backend:**")) ?? "";
|
|
831
|
+
const modules = lines
|
|
832
|
+
.filter(l => /^- \*\*[^*]+\*\*\s*\([^)]+\)/.test(l))
|
|
833
|
+
.map(l => { const m = l.match(/\*\*([^*]+)\*\*\s*\(([^)]+)\)/); return m ? `${m[1]} (${m[2]})` : null; })
|
|
834
|
+
.filter((x) => x !== null);
|
|
835
|
+
const deps = lines.filter(l => l.includes("→")).map(l => l.replace(/^- /, ""));
|
|
836
|
+
const coupling = lines.filter(l => /score \d/.test(l)).map(l => l.replace(/^- /, "")).slice(0, 5);
|
|
837
|
+
const modelEp = lines.filter(l => l.includes("endpoints) ->")).map(l => l.replace(/^- /, ""));
|
|
838
|
+
return JSON.stringify({ desc, map, modules, deps, coupling, modelEp });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
catch { }
|
|
842
|
+
const d = await loadCodebaseIntel(inputDir);
|
|
843
|
+
const c = d.meta?.counts || {};
|
|
844
|
+
const pages = (d.frontend_pages || []).map((p) => p.path);
|
|
845
|
+
return JSON.stringify({ p: d.meta?.project, ep: c.endpoints, models: c.models, pg: c.pages, pages });
|
|
846
|
+
}
|
|
847
|
+
// ── file: per-file or per-endpoint context ──
|
|
848
|
+
export async function queryFile(inputDir, target) {
|
|
849
|
+
const d = await loadCodebaseIntel(inputDir);
|
|
850
|
+
const epMatch = target.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(.+)$/i);
|
|
851
|
+
if (epMatch) {
|
|
852
|
+
const ep = d.api_registry?.[`${epMatch[1].toUpperCase()} ${epMatch[2]}`]
|
|
853
|
+
|| Object.values(d.api_registry || {}).find((e) => e.method === epMatch[1].toUpperCase() && e.path === epMatch[2]);
|
|
854
|
+
if (!ep)
|
|
855
|
+
return JSON.stringify({ err: "not found" });
|
|
856
|
+
const calls = (ep.service_calls || []).filter((s) => !SKIP_SERVICES.has(s));
|
|
857
|
+
return JSON.stringify({ ep: `${ep.method} ${ep.path}`, h: ep.handler, f: ep.file, m: ep.module, req: ep.request_schema, res: ep.response_schema, calls, ai: ep.ai_operations?.length || 0 });
|
|
858
|
+
}
|
|
859
|
+
const file = normalizeFilePath(target);
|
|
860
|
+
const mod = findModuleForFile(d, file);
|
|
861
|
+
const eps = findEndpointsInFile(d, file);
|
|
862
|
+
const models = findModelsInFile(d, file);
|
|
863
|
+
const fileName = path.basename(file, path.extname(file));
|
|
864
|
+
const calledBy = [];
|
|
865
|
+
for (const ep of Object.values(d.api_registry || {})) {
|
|
866
|
+
if (ep.service_calls?.some((s) => s.toLowerCase().includes(fileName.toLowerCase()))) {
|
|
867
|
+
calledBy.push(`${ep.method} ${ep.path}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
const calls = eps.flatMap((ep) => (ep.service_calls || []).filter((s) => !SKIP_SERVICES.has(s)));
|
|
871
|
+
return JSON.stringify({ f: file, mod: mod ? [mod.id, mod.layer] : null, ep: eps.map((e) => `${e.method} ${e.path}`), models: models.map((m) => [m.name, m.fields?.length || 0]), calls: [...new Set(calls)], calledBy: calledBy.slice(0, 8) });
|
|
872
|
+
}
|
|
873
|
+
// ── model: model details + usage ──
|
|
874
|
+
export async function queryModel(inputDir, name) {
|
|
875
|
+
const d = await loadCodebaseIntel(inputDir);
|
|
876
|
+
const m = d.model_registry?.[name];
|
|
877
|
+
if (!m)
|
|
878
|
+
return JSON.stringify({ err: "not found", name });
|
|
879
|
+
const usedBy = Object.values(d.api_registry || {})
|
|
880
|
+
.filter((ep) => ep.request_schema === name || ep.response_schema === name)
|
|
881
|
+
.map((ep) => `${ep.method} ${ep.path}`);
|
|
882
|
+
return JSON.stringify({ name: m.name, fw: m.framework, f: m.file, fields: m.fields, rels: m.relationships, usedBy });
|
|
883
|
+
}
|
|
884
|
+
// ── impact: what breaks if you change this file ──
|
|
885
|
+
export async function queryImpact(inputDir, target) {
|
|
886
|
+
const d = await loadCodebaseIntel(inputDir);
|
|
887
|
+
const file = normalizeFilePath(target);
|
|
888
|
+
const eps = findEndpointsInFile(d, file);
|
|
889
|
+
const models = findModelsInFile(d, file);
|
|
890
|
+
const modelNames = new Set(models.map((m) => m.name));
|
|
891
|
+
const affectedEps = Object.values(d.api_registry || {}).filter((ep) => (ep.request_schema && modelNames.has(ep.request_schema)) ||
|
|
892
|
+
(ep.response_schema && modelNames.has(ep.response_schema)));
|
|
893
|
+
const mod = findModuleForFile(d, file);
|
|
894
|
+
const depMods = mod ? (d.service_map || []).filter((m) => m.imports?.includes(mod.id)) : [];
|
|
895
|
+
const affectedPages = (d.frontend_pages || []).filter((p) => p.api_calls?.some((call) => eps.some((ep) => call.includes(ep.path?.split("{")[0]))));
|
|
896
|
+
const total = eps.length + affectedEps.length + depMods.length + affectedPages.length;
|
|
897
|
+
return JSON.stringify({ f: file, risk: total > 5 ? "HIGH" : total > 2 ? "MED" : "LOW", ep: eps.map((e) => `${e.method} ${e.path}`), models: models.map((m) => m.name), affectedEp: affectedEps.map((e) => `${e.method} ${e.path}`), depMods: depMods.map((m) => m.id), pages: affectedPages.map((p) => p.path) });
|
|
898
|
+
}
|
|
899
|
+
// ── querySearch --format json: categorical search from codebase-intelligence.json ──
|
|
900
|
+
export async function querySearch(inputDir, query) {
|
|
901
|
+
const d = await loadCodebaseIntel(inputDir);
|
|
902
|
+
const q = query;
|
|
903
|
+
const scoredEps = [];
|
|
904
|
+
for (const ep of Object.values(d.api_registry || {})) {
|
|
905
|
+
const score = scoreQueryIntel(q, [
|
|
906
|
+
{ value: ep.path, weight: 1.0 }, { value: ep.handler, weight: 0.9 },
|
|
907
|
+
...(ep.service_calls || []).filter((s) => !isGenericCall(s)).map((s) => ({ value: s, weight: 0.5 })),
|
|
908
|
+
]);
|
|
909
|
+
if (score > 0)
|
|
910
|
+
scoredEps.push({ item: ep, score });
|
|
911
|
+
}
|
|
912
|
+
scoredEps.sort((a, b) => b.score - a.score);
|
|
913
|
+
const eps = scoredEps.slice(0, 8).map(({ item: ep }) => `${ep.method} ${ep.path} [${ep.module}]`);
|
|
914
|
+
const scoredModels = [];
|
|
915
|
+
for (const m of Object.values(d.model_registry || {})) {
|
|
916
|
+
const score = scoreQueryIntel(q, [{ value: m.name, weight: 1.0 }, ...(m.fields || []).map((f) => ({ value: f, weight: 0.6 }))]);
|
|
917
|
+
if (score > 0)
|
|
918
|
+
scoredModels.push({ item: m, score });
|
|
919
|
+
}
|
|
920
|
+
scoredModels.sort((a, b) => b.score - a.score);
|
|
921
|
+
const models = scoredModels.slice(0, 8).map(({ item: m }) => `${m.name}:${m.fields?.length}f`);
|
|
922
|
+
const mods = (d.service_map || []).filter((m) => scoreQueryIntel(q, [{ value: m.id, weight: 1.0 }, ...(m.imports || []).map((i) => ({ value: i, weight: 0.5 }))]) > 0).slice(0, 5).map((m) => `${m.id}:${m.file_count}files [${m.layer}]`);
|
|
923
|
+
const scoredExports = [];
|
|
924
|
+
for (const m of d.service_map || []) {
|
|
925
|
+
for (const sym of m.exports || []) {
|
|
926
|
+
const score = scoreQueryIntel(q, [{ value: sym, weight: 1.0 }]);
|
|
927
|
+
if (score > 0)
|
|
928
|
+
scoredExports.push({ item: `${sym} [${m.id}]`, score });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
scoredExports.sort((a, b) => b.score - a.score);
|
|
932
|
+
const ASSET_EXTS = new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".css", ".scss", ".less", ".lock", ".map"]);
|
|
933
|
+
const isMigration = (f) => /alembic\/versions|migrations\/\d/.test(f);
|
|
934
|
+
const scoredFiles = [];
|
|
935
|
+
for (const m of d.service_map || []) {
|
|
936
|
+
for (const f of m.files || []) {
|
|
937
|
+
if (ASSET_EXTS.has(path.extname(f).toLowerCase()) || isMigration(f))
|
|
938
|
+
continue;
|
|
939
|
+
const score = scoreQueryIntel(q, [{ value: path.basename(f), weight: 1.0 }, { value: f, weight: 0.5 }]);
|
|
940
|
+
if (score > 0)
|
|
941
|
+
scoredFiles.push({ item: f, score });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
scoredFiles.sort((a, b) => b.score - a.score);
|
|
945
|
+
const enums = Object.values(d.enum_registry || {}).filter((e) => scoreQueryIntel(q, [{ value: e.name, weight: 1.0 }, ...(e.values || []).map((v) => ({ value: v, weight: 0.6 }))]) > 0).slice(0, 5).map((e) => `${e.name} [${e.file}]`);
|
|
946
|
+
const tasks = (d.background_tasks || []).filter((t) => scoreQueryIntel(q, [{ value: t.name, weight: 1.0 }, { value: t.kind, weight: 0.6 }]) > 0).slice(0, 5).map((t) => `${t.name} [${t.kind}] ${t.file}`);
|
|
947
|
+
const pages = (d.frontend_pages || []).filter((p) => scoreQueryIntel(q, [
|
|
948
|
+
{ value: p.path, weight: 1.0 },
|
|
949
|
+
{ value: p.component, weight: 0.9 },
|
|
950
|
+
{ value: p.file ?? "", weight: 0.8 },
|
|
951
|
+
...(p.api_calls || []).map((c) => ({ value: c, weight: 0.5 })),
|
|
952
|
+
...(p.components || []).map((c) => ({ value: c, weight: 0.4 })),
|
|
953
|
+
]) > 0).slice(0, 5).map((p) => p.file ? `${p.path} [${p.file}]` : `${p.path} → ${p.component}`);
|
|
954
|
+
const fnHits = [];
|
|
955
|
+
const fi = await loadFuncIntelRaw(inputDir);
|
|
956
|
+
if (fi) {
|
|
957
|
+
const scored = [];
|
|
958
|
+
const seen = new Set();
|
|
959
|
+
for (const fn of (fi.functions ?? [])) {
|
|
960
|
+
const nameNorm = (fn.name ?? "").toLowerCase();
|
|
961
|
+
const fileNorm = (fn.file ?? "").toLowerCase();
|
|
962
|
+
const callsNorm = (fn.calls ?? []).map((c) => c.toLowerCase());
|
|
963
|
+
const litsNorm = [...(fn.stringLiterals ?? []), ...(fn.regexPatterns ?? [])].map((l) => l.toLowerCase());
|
|
964
|
+
let score = 0;
|
|
965
|
+
if (nameNorm === q)
|
|
966
|
+
score = 1.0;
|
|
967
|
+
else if (nameNorm.includes(q))
|
|
968
|
+
score = 0.7;
|
|
969
|
+
else if (callsNorm.some((c) => c.includes(q)))
|
|
970
|
+
score = 0.5;
|
|
971
|
+
else if (litsNorm.some((l) => l.includes(q)))
|
|
972
|
+
score = 0.3;
|
|
973
|
+
else if (fileNorm.includes(q))
|
|
974
|
+
score = 0.2;
|
|
975
|
+
if (score > 0) {
|
|
976
|
+
scored.push({ fn, score });
|
|
977
|
+
seen.add(`${fn.file}:${fn.name}`);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
const litIndex = fi.literal_index ?? {};
|
|
981
|
+
for (const [key, hits] of Object.entries(litIndex)) {
|
|
982
|
+
if (!key.includes(q))
|
|
983
|
+
continue;
|
|
984
|
+
for (const h of hits) {
|
|
985
|
+
const uid = `${h.file}:${h.function}`;
|
|
986
|
+
if (seen.has(uid))
|
|
987
|
+
continue;
|
|
988
|
+
seen.add(uid);
|
|
989
|
+
const fn = fi.functions.find((f) => f.file === h.file && f.name === h.function);
|
|
990
|
+
scored.push({ fn: fn ?? { name: h.function, file: h.file, lines: [h.line, h.line] }, score: 0.25 });
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
scored.sort((a, b) => b.score - a.score);
|
|
994
|
+
for (const { fn } of scored.slice(0, 10)) {
|
|
995
|
+
fnHits.push(`${fn.name} [${fn.file}:${fn.lines?.[0]}]`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return JSON.stringify({
|
|
999
|
+
ep: eps, mod: models, m: mods,
|
|
1000
|
+
exports: scoredExports.slice(0, 10).map(e => e.item),
|
|
1001
|
+
files: scoredFiles.slice(0, 8).map(f => f.item),
|
|
1002
|
+
enums, tasks, pages,
|
|
1003
|
+
...(fnHits.length > 0 ? { fns: fnHits } : {}),
|
|
1004
|
+
});
|
|
1005
|
+
}
|