@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.
@@ -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 = renderSearchMarkdown(options.query, matches);
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(["models", "endpoints", "components", "modules", "tasks"]);
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 === "models" ||
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(["models", "endpoints", "components", "modules", "tasks", "functions"]);
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 score = scoreItem(queryTokens, {
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 (score <= 0)
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 score = scoreItem(queryTokens, {
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: Math.max(score, 0.2), // floor at 0.2 so literal hits still surface but rank below name matches
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
+ }