@toolbaux/guardian 0.1.23 → 0.2.0
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 +6 -4
- package/dist/cli.js +1 -1
- package/dist/commands/context.js +87 -29
- package/dist/commands/extract.js +4 -1
- package/dist/commands/generate.js +83 -10
- package/dist/commands/init.js +88 -56
- package/dist/commands/intel.js +23 -0
- package/dist/commands/mcp-serve.js +112 -0
- package/dist/commands/search.js +43 -3
- package/dist/config.js +1 -0
- package/dist/db/embeddings.js +113 -0
- package/dist/db/fts-builder.js +85 -0
- package/dist/db/sqlite-specs-store.js +496 -3
- package/package.json +2 -1
|
@@ -50,6 +50,58 @@ function splitIdentifiers(s) {
|
|
|
50
50
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
51
51
|
.toLowerCase();
|
|
52
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Normalise a callee name by stripping receiver/object/package prefix.
|
|
55
|
+
* "engine.handleHTTPRequest" → "handleHTTPRequest"
|
|
56
|
+
* "self.add_to_class" → "add_to_class"
|
|
57
|
+
* "apps.get_model" → "get_model"
|
|
58
|
+
* "fmt.Println" → "Println"
|
|
59
|
+
* "bare_name" → "bare_name" (unchanged)
|
|
60
|
+
*/
|
|
61
|
+
function normalizeCallee(name) {
|
|
62
|
+
const lastDot = name.lastIndexOf(".");
|
|
63
|
+
if (lastDot >= 0) {
|
|
64
|
+
const bare = name.slice(lastDot + 1);
|
|
65
|
+
if (bare && /^[A-Za-z_]\w*$/.test(bare))
|
|
66
|
+
return bare;
|
|
67
|
+
}
|
|
68
|
+
return name;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract meaningful directory-segment tokens from a file path.
|
|
72
|
+
* "fastapi/dependencies/utils.py" → "fastapi dependencies"
|
|
73
|
+
* "lib/router/layer.js" → "router layer"
|
|
74
|
+
*
|
|
75
|
+
* Skips generic segments that add noise but no recall value.
|
|
76
|
+
*/
|
|
77
|
+
const PATH_NOISE = new Set([
|
|
78
|
+
"src", "lib", "app", "pkg", "internal", "cmd", "api", "dist", "build",
|
|
79
|
+
"test", "tests", "spec", "specs", "docs", "doc", "examples", "example",
|
|
80
|
+
"scripts", "utils", "helpers", "common", "shared", "core", "main",
|
|
81
|
+
]);
|
|
82
|
+
function filePathTokens(fp) {
|
|
83
|
+
return fp
|
|
84
|
+
.split("/")
|
|
85
|
+
.slice(0, -1) // exclude the filename itself
|
|
86
|
+
.filter(s => s && !PATH_NOISE.has(s.toLowerCase()))
|
|
87
|
+
.map(splitIdentifiers)
|
|
88
|
+
.join(" ");
|
|
89
|
+
}
|
|
90
|
+
/** L2 norm of a Float32Array. */
|
|
91
|
+
function vecNorm(v) {
|
|
92
|
+
let sum = 0;
|
|
93
|
+
for (let i = 0; i < v.length; i++)
|
|
94
|
+
sum += v[i] * v[i];
|
|
95
|
+
return Math.sqrt(sum);
|
|
96
|
+
}
|
|
97
|
+
/** Cosine similarity between two unit-normalised Float32Arrays. */
|
|
98
|
+
function cosineSim(a, b) {
|
|
99
|
+
let dot = 0;
|
|
100
|
+
const len = Math.min(a.length, b.length);
|
|
101
|
+
for (let i = 0; i < len; i++)
|
|
102
|
+
dot += a[i] * b[i];
|
|
103
|
+
return dot;
|
|
104
|
+
}
|
|
53
105
|
export const DB_FILENAME = "guardian.db";
|
|
54
106
|
export class SqliteSpecsStore {
|
|
55
107
|
storeDir;
|
|
@@ -351,6 +403,431 @@ export class SqliteSpecsStore {
|
|
|
351
403
|
);
|
|
352
404
|
`);
|
|
353
405
|
}
|
|
406
|
+
// Per-function FTS table — one row per function/class/symbol with line number.
|
|
407
|
+
// file_path and line are UNINDEXED (stored but not tokenised); name + body are searched.
|
|
408
|
+
this.db.exec(`
|
|
409
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS functions_fts USING fts5(
|
|
410
|
+
file_path UNINDEXED,
|
|
411
|
+
line UNINDEXED,
|
|
412
|
+
name,
|
|
413
|
+
body,
|
|
414
|
+
tokenize='porter unicode61'
|
|
415
|
+
);
|
|
416
|
+
`);
|
|
417
|
+
// Call-graph edges — caller → callee name mapping.
|
|
418
|
+
// caller_file stored so test callers can be excluded from in-degree authority ranking.
|
|
419
|
+
this.db.exec(`
|
|
420
|
+
CREATE TABLE IF NOT EXISTS function_calls (
|
|
421
|
+
caller_name TEXT NOT NULL,
|
|
422
|
+
callee_name TEXT NOT NULL,
|
|
423
|
+
caller_file TEXT NOT NULL DEFAULT '',
|
|
424
|
+
PRIMARY KEY (caller_name, callee_name)
|
|
425
|
+
);
|
|
426
|
+
CREATE INDEX IF NOT EXISTS function_calls_callee ON function_calls(callee_name);
|
|
427
|
+
`);
|
|
428
|
+
// Migration: add caller_file column to existing DBs that predate this schema.
|
|
429
|
+
try {
|
|
430
|
+
this.db.exec("ALTER TABLE function_calls ADD COLUMN caller_file TEXT NOT NULL DEFAULT ''");
|
|
431
|
+
}
|
|
432
|
+
catch { /* column already exists — fine */ }
|
|
433
|
+
// Migration: normalise dotted callee names from older extractions.
|
|
434
|
+
// "engine.handleHTTPRequest" → "handleHTTPRequest", "self.method" → "method", etc.
|
|
435
|
+
// Uses UPDATE OR IGNORE to skip rows that would violate the (caller_name, callee_name) PK.
|
|
436
|
+
// Filters exclude Go parenthetical expressions: "(**time.Time)", "(*t).Equal", etc.
|
|
437
|
+
try {
|
|
438
|
+
this.db.exec(`
|
|
439
|
+
UPDATE OR IGNORE function_calls
|
|
440
|
+
SET callee_name = SUBSTR(callee_name, INSTR(callee_name, '.') + 1)
|
|
441
|
+
WHERE INSTR(callee_name, '.') > 0
|
|
442
|
+
AND INSTR(callee_name, '(') = 0
|
|
443
|
+
AND INSTR(callee_name, ' ') = 0
|
|
444
|
+
AND INSTR(SUBSTR(callee_name, INSTR(callee_name, '.') + 1), '.') = 0
|
|
445
|
+
AND INSTR(SUBSTR(callee_name, INSTR(callee_name, '.') + 1), ')') = 0
|
|
446
|
+
`);
|
|
447
|
+
}
|
|
448
|
+
catch { /* non-critical */ }
|
|
449
|
+
// Vector embeddings for semantic (non-keyword) search.
|
|
450
|
+
// vec is a Float32Array stored as BLOB (dim=256, model=text-embedding-3-small).
|
|
451
|
+
// Optional — only populated when OPENAI_API_KEY is present during extract.
|
|
452
|
+
this.db.exec(`
|
|
453
|
+
CREATE TABLE IF NOT EXISTS function_embeddings (
|
|
454
|
+
file_path TEXT NOT NULL,
|
|
455
|
+
name TEXT NOT NULL,
|
|
456
|
+
line INTEGER NOT NULL,
|
|
457
|
+
vec BLOB NOT NULL,
|
|
458
|
+
PRIMARY KEY (file_path, name, line)
|
|
459
|
+
);
|
|
460
|
+
`);
|
|
461
|
+
// ── Normalised fact tables (DB-first backend, Phase 1) ─────────────────────
|
|
462
|
+
//
|
|
463
|
+
// These tables store raw extracted facts with no rendering or formatting.
|
|
464
|
+
// Human docs and machine docs remain derived views generated from these facts.
|
|
465
|
+
// Future: generate.ts and context.ts read from these tables instead of files.
|
|
466
|
+
this.db.exec(`
|
|
467
|
+
-- Full FunctionRecord — one row per extracted function/method/symbol.
|
|
468
|
+
-- calls, string_lits, regex_pats are JSON arrays (compact, no pretty-print).
|
|
469
|
+
CREATE TABLE IF NOT EXISTS functions_raw (
|
|
470
|
+
file_path TEXT NOT NULL,
|
|
471
|
+
name TEXT NOT NULL,
|
|
472
|
+
line_start INTEGER NOT NULL,
|
|
473
|
+
line_end INTEGER NOT NULL,
|
|
474
|
+
language TEXT NOT NULL DEFAULT '',
|
|
475
|
+
is_async INTEGER NOT NULL DEFAULT 0,
|
|
476
|
+
docstring TEXT NOT NULL DEFAULT '',
|
|
477
|
+
calls TEXT NOT NULL DEFAULT '[]',
|
|
478
|
+
string_lits TEXT NOT NULL DEFAULT '[]',
|
|
479
|
+
regex_pats TEXT NOT NULL DEFAULT '[]',
|
|
480
|
+
PRIMARY KEY (file_path, name, line_start)
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
-- API endpoint registry — one row per route/handler pair.
|
|
484
|
+
-- service_calls is a JSON array.
|
|
485
|
+
CREATE TABLE IF NOT EXISTS endpoints_raw (
|
|
486
|
+
method TEXT NOT NULL DEFAULT '',
|
|
487
|
+
path TEXT NOT NULL,
|
|
488
|
+
handler TEXT NOT NULL DEFAULT '',
|
|
489
|
+
file_path TEXT NOT NULL DEFAULT '',
|
|
490
|
+
module TEXT NOT NULL DEFAULT '',
|
|
491
|
+
service_calls TEXT NOT NULL DEFAULT '[]',
|
|
492
|
+
request_schema TEXT NOT NULL DEFAULT '',
|
|
493
|
+
response_schema TEXT NOT NULL DEFAULT '',
|
|
494
|
+
PRIMARY KEY (method, path)
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
-- ORM/data-model registry — one row per model/schema.
|
|
498
|
+
-- fields and relationships are JSON arrays.
|
|
499
|
+
CREATE TABLE IF NOT EXISTS models_raw (
|
|
500
|
+
name TEXT PRIMARY KEY,
|
|
501
|
+
file_path TEXT NOT NULL DEFAULT '',
|
|
502
|
+
module TEXT NOT NULL DEFAULT '',
|
|
503
|
+
fields TEXT NOT NULL DEFAULT '[]',
|
|
504
|
+
relationships TEXT NOT NULL DEFAULT '[]'
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
-- Structural intelligence per module — one row per SI report.
|
|
508
|
+
-- Populated by rebuildModuleMetrics() called from guardian intel --backend sqlite.
|
|
509
|
+
CREATE TABLE IF NOT EXISTS module_metrics (
|
|
510
|
+
module TEXT PRIMARY KEY,
|
|
511
|
+
depth_level TEXT NOT NULL DEFAULT '',
|
|
512
|
+
propagation TEXT NOT NULL DEFAULT '',
|
|
513
|
+
compressible TEXT NOT NULL DEFAULT '',
|
|
514
|
+
pattern TEXT NOT NULL DEFAULT '',
|
|
515
|
+
confidence REAL NOT NULL DEFAULT 0,
|
|
516
|
+
confidence_level TEXT NOT NULL DEFAULT '',
|
|
517
|
+
nodes INTEGER NOT NULL DEFAULT 0,
|
|
518
|
+
edges INTEGER NOT NULL DEFAULT 0
|
|
519
|
+
);
|
|
520
|
+
`);
|
|
521
|
+
}
|
|
522
|
+
// ── Per-function index ──────────────────────────────────────────────────────
|
|
523
|
+
/**
|
|
524
|
+
* Populate functions_fts and function_calls from FunctionRecord data.
|
|
525
|
+
* One row per function/class/symbol — enables line-level search + call-graph authority.
|
|
526
|
+
*/
|
|
527
|
+
rebuildFunctionIndex(functions) {
|
|
528
|
+
this.db.prepare("DELETE FROM functions_fts").run();
|
|
529
|
+
this.db.prepare("DELETE FROM function_calls").run();
|
|
530
|
+
this.db.prepare("DELETE FROM functions_raw").run();
|
|
531
|
+
const insFts = this.db.prepare("INSERT INTO functions_fts (file_path, line, name, body) VALUES (?, ?, ?, ?)");
|
|
532
|
+
const insCall = this.db.prepare("INSERT OR IGNORE INTO function_calls (caller_name, callee_name, caller_file) VALUES (?, ?, ?)");
|
|
533
|
+
const insRaw = this.db.prepare(`
|
|
534
|
+
INSERT OR REPLACE INTO functions_raw
|
|
535
|
+
(file_path, name, line_start, line_end, language, is_async, docstring, calls, string_lits, regex_pats)
|
|
536
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
537
|
+
`);
|
|
538
|
+
this.db.transaction(() => {
|
|
539
|
+
for (const fn of functions) {
|
|
540
|
+
const fp = normPath(fn.file);
|
|
541
|
+
const line = String(fn.lines[0]);
|
|
542
|
+
// Store the original name for display and symbolMatch comparison.
|
|
543
|
+
// FTS5 porter/unicode61 tokenizer splits on '_' naturally;
|
|
544
|
+
// camelCase tokens are added to body so porter stemming applies to them too.
|
|
545
|
+
const pathToks = filePathTokens(fp);
|
|
546
|
+
const bodyParts = [
|
|
547
|
+
splitIdentifiers(fn.name), // camelCase expansion for FTS recall
|
|
548
|
+
pathToks, // dir segments: "fastapi dependencies" etc.
|
|
549
|
+
...(fn.calls ?? []).map(c => splitIdentifiers(normalizeCallee(c))),
|
|
550
|
+
...(fn.stringLiterals ?? []),
|
|
551
|
+
fn.docstring ?? "",
|
|
552
|
+
].join(" ");
|
|
553
|
+
insFts.run(fp, line, fn.name, bodyParts);
|
|
554
|
+
// Store call edges — normalise callee names to bare identifiers so the JOIN
|
|
555
|
+
// in searchSymbols matches function names (strips "engine.", "self.", etc.).
|
|
556
|
+
for (const callee of fn.calls ?? []) {
|
|
557
|
+
const bare = normalizeCallee(callee);
|
|
558
|
+
if (bare && bare !== fn.name)
|
|
559
|
+
insCall.run(fn.name, bare, fp);
|
|
560
|
+
}
|
|
561
|
+
// Normalised fact row — all fields stored losslessly, no rendering.
|
|
562
|
+
insRaw.run(fp, fn.name, fn.lines[0], fn.lines[1], fn.language ?? "", fn.isAsync ? 1 : 0, fn.docstring ?? "", JSON.stringify(fn.calls ?? []), JSON.stringify(fn.stringLiterals ?? []), JSON.stringify(fn.regexPatterns ?? []));
|
|
563
|
+
}
|
|
564
|
+
})();
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Store structural-intelligence reports per module.
|
|
568
|
+
* Called from `guardian intel --backend sqlite` after reading structural-intelligence.json.
|
|
569
|
+
* Idempotent: replaces all rows on each call.
|
|
570
|
+
*/
|
|
571
|
+
rebuildModuleMetrics(reports) {
|
|
572
|
+
this.db.prepare("DELETE FROM module_metrics").run();
|
|
573
|
+
const ins = this.db.prepare(`
|
|
574
|
+
INSERT OR REPLACE INTO module_metrics
|
|
575
|
+
(module, depth_level, propagation, compressible, pattern, confidence, confidence_level, nodes, edges)
|
|
576
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
577
|
+
`);
|
|
578
|
+
this.db.transaction(() => {
|
|
579
|
+
for (const r of reports) {
|
|
580
|
+
ins.run(r.feature, r.classification.depth_level, r.classification.propagation, r.classification.compressible, r.recommendation.primary.pattern, r.confidence.value, r.confidence.level, r.structure.nodes, r.structure.edges);
|
|
581
|
+
}
|
|
582
|
+
})();
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Read all module_metrics rows — used by generate/context to load SI reports from DB.
|
|
586
|
+
*/
|
|
587
|
+
readModuleMetrics() {
|
|
588
|
+
try {
|
|
589
|
+
return this.db.prepare("SELECT * FROM module_metrics ORDER BY module").all();
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Store API endpoint facts.
|
|
597
|
+
* Called from populateFTSIndex() after reading intel/arch objects.
|
|
598
|
+
* Idempotent: replaces all rows on each call.
|
|
599
|
+
*/
|
|
600
|
+
rebuildEndpointsRaw(endpoints) {
|
|
601
|
+
this.db.prepare("DELETE FROM endpoints_raw").run();
|
|
602
|
+
const ins = this.db.prepare(`
|
|
603
|
+
INSERT OR REPLACE INTO endpoints_raw
|
|
604
|
+
(method, path, handler, file_path, module, service_calls, request_schema, response_schema)
|
|
605
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
606
|
+
`);
|
|
607
|
+
this.db.transaction(() => {
|
|
608
|
+
for (const ep of endpoints) {
|
|
609
|
+
ins.run(ep.method ?? "", ep.path, ep.handler ?? "", normPath(ep.file_path ?? ""), ep.module ?? "", JSON.stringify(ep.service_calls ?? []), ep.request_schema ?? "", ep.response_schema ?? "");
|
|
610
|
+
}
|
|
611
|
+
})();
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Store ORM/data-model facts.
|
|
615
|
+
* Called from populateFTSIndex() after reading intel/arch objects.
|
|
616
|
+
* Idempotent: replaces all rows on each call.
|
|
617
|
+
*/
|
|
618
|
+
rebuildModelsRaw(models) {
|
|
619
|
+
this.db.prepare("DELETE FROM models_raw").run();
|
|
620
|
+
const ins = this.db.prepare(`
|
|
621
|
+
INSERT OR REPLACE INTO models_raw
|
|
622
|
+
(name, file_path, module, fields, relationships)
|
|
623
|
+
VALUES (?, ?, ?, ?, ?)
|
|
624
|
+
`);
|
|
625
|
+
this.db.transaction(() => {
|
|
626
|
+
for (const m of models) {
|
|
627
|
+
ins.run(m.name, normPath(m.file_path ?? ""), m.module ?? "", JSON.stringify(m.fields ?? []), JSON.stringify(m.relationships ?? []));
|
|
628
|
+
}
|
|
629
|
+
})();
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Store vector embeddings for semantic search.
|
|
633
|
+
* vec is a Float32Array serialised as Buffer (dim=256, text-embedding-3-small).
|
|
634
|
+
*/
|
|
635
|
+
rebuildEmbeddings(rows) {
|
|
636
|
+
this.db.prepare("DELETE FROM function_embeddings").run();
|
|
637
|
+
const ins = this.db.prepare("INSERT OR REPLACE INTO function_embeddings (file_path, name, line, vec) VALUES (?, ?, ?, ?)");
|
|
638
|
+
this.db.transaction(() => {
|
|
639
|
+
for (const r of rows) {
|
|
640
|
+
ins.run(r.file_path, r.name, r.line, Buffer.from(r.vec.buffer));
|
|
641
|
+
}
|
|
642
|
+
})();
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Vector similarity search — returns top-k functions closest to the query embedding.
|
|
646
|
+
* Cosine similarity computed in JS over all stored embeddings (fast for <100k functions).
|
|
647
|
+
*/
|
|
648
|
+
searchByVector(queryVec, limit = 20) {
|
|
649
|
+
let all;
|
|
650
|
+
try {
|
|
651
|
+
all = this.db.prepare("SELECT file_path, name, line, vec FROM function_embeddings").all();
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return [];
|
|
655
|
+
}
|
|
656
|
+
if (all.length === 0)
|
|
657
|
+
return [];
|
|
658
|
+
// Normalise query vector once.
|
|
659
|
+
const qNorm = vecNorm(queryVec);
|
|
660
|
+
if (qNorm === 0)
|
|
661
|
+
return [];
|
|
662
|
+
const qUnit = queryVec.map(v => v / qNorm);
|
|
663
|
+
const scored = all.map(row => {
|
|
664
|
+
const vec = new Float32Array(row.vec.buffer, row.vec.byteOffset, row.vec.byteLength / 4);
|
|
665
|
+
return { file_path: row.file_path, name: row.name, line: row.line, score: cosineSim(qUnit, vec) };
|
|
666
|
+
});
|
|
667
|
+
scored.sort((a, b) => b.score - a.score);
|
|
668
|
+
return scored.slice(0, limit);
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Hybrid symbol search: BM25 + call-graph authority + callee traversal + optional vector.
|
|
672
|
+
*
|
|
673
|
+
* Three-tier candidate pool:
|
|
674
|
+
* 1. BM25 candidates — direct FTS matches, scored by bm25_norm + auth_norm + vec_sim
|
|
675
|
+
* 2. Callee expansion — 1-hop outbound callees of BM25 candidates (source-only),
|
|
676
|
+
* scored by callee_hits_norm + auth_norm + vec_sim.
|
|
677
|
+
* Surfaces functions called BY what matches the query (e.g.
|
|
678
|
+
* "resolve dependency injection" → surfaces solve_dependencies
|
|
679
|
+
* called by the route handler BM25 match).
|
|
680
|
+
*
|
|
681
|
+
* Ranking formula:
|
|
682
|
+
* BM25 tier: W_BM25 * bm25_norm + W_AUTH * auth_norm + W_VEC * vec_sim
|
|
683
|
+
* Callee tier: W_CALLEE * hits_norm + W_AUTH * auth_norm + W_VEC * vec_sim
|
|
684
|
+
*
|
|
685
|
+
* Test-file penalty: 0.5× applied to any result whose file matches test/spec/bench/mock.
|
|
686
|
+
*/
|
|
687
|
+
searchSymbols(query, limit = 10, queryVec) {
|
|
688
|
+
const tokens = this._buildTokens(query);
|
|
689
|
+
if (tokens.length === 0)
|
|
690
|
+
return [];
|
|
691
|
+
const ftsQuery = tokens.join(" OR ");
|
|
692
|
+
// Pull a wider candidate pool so reranking has enough material.
|
|
693
|
+
const candidateLimit = Math.max(limit * 5, 60);
|
|
694
|
+
let rows;
|
|
695
|
+
try {
|
|
696
|
+
rows = this.db.prepare(`
|
|
697
|
+
WITH candidates AS (
|
|
698
|
+
SELECT file_path, line, name,
|
|
699
|
+
bm25(functions_fts, 0.2, 1.0, 0.5) AS bm25
|
|
700
|
+
FROM functions_fts
|
|
701
|
+
WHERE functions_fts MATCH ?
|
|
702
|
+
ORDER BY bm25
|
|
703
|
+
LIMIT ?
|
|
704
|
+
)
|
|
705
|
+
SELECT c.file_path, c.line, c.name, c.bm25,
|
|
706
|
+
COUNT(CASE
|
|
707
|
+
WHEN fc.caller_file NOT LIKE '%test%'
|
|
708
|
+
AND fc.caller_file NOT LIKE '%spec%'
|
|
709
|
+
AND fc.caller_file NOT LIKE '%mock%'
|
|
710
|
+
AND fc.caller_file NOT LIKE '%fixture%'
|
|
711
|
+
AND fc.caller_file NOT LIKE '%example%'
|
|
712
|
+
AND fc.caller_file NOT LIKE '%demo%'
|
|
713
|
+
AND fc.caller_file NOT LIKE '%sample%'
|
|
714
|
+
THEN 1 END) AS indegree
|
|
715
|
+
FROM candidates c
|
|
716
|
+
LEFT JOIN function_calls fc ON fc.callee_name = c.name
|
|
717
|
+
GROUP BY c.file_path, c.line, c.name, c.bm25
|
|
718
|
+
ORDER BY c.bm25
|
|
719
|
+
`).all(ftsQuery, candidateLimit);
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
if (rows.length === 0)
|
|
725
|
+
return [];
|
|
726
|
+
const bm25Names = rows.map(r => r.name);
|
|
727
|
+
const bm25NameSet = new Set(bm25Names);
|
|
728
|
+
let calleeRows = [];
|
|
729
|
+
if (bm25Names.length > 0) {
|
|
730
|
+
try {
|
|
731
|
+
// Limit IN clause to avoid excess query plan cost on large candidate pools.
|
|
732
|
+
const callerNames = bm25Names.slice(0, 30);
|
|
733
|
+
const phs = callerNames.map(() => "?").join(",");
|
|
734
|
+
calleeRows = this.db.prepare(`
|
|
735
|
+
SELECT f.file_path, f.line, f.name,
|
|
736
|
+
COUNT(*) AS callee_hits
|
|
737
|
+
FROM function_calls fc
|
|
738
|
+
JOIN functions_fts f ON f.name = fc.callee_name
|
|
739
|
+
WHERE fc.caller_name IN (${phs})
|
|
740
|
+
AND fc.caller_file NOT LIKE '%test%'
|
|
741
|
+
AND fc.caller_file NOT LIKE '%spec%'
|
|
742
|
+
AND fc.caller_file NOT LIKE '%mock%'
|
|
743
|
+
AND fc.caller_file NOT LIKE '%fixture%'
|
|
744
|
+
AND fc.caller_file NOT LIKE '%example%'
|
|
745
|
+
AND fc.caller_file NOT LIKE '%demo%'
|
|
746
|
+
AND fc.caller_file NOT LIKE '%sample%'
|
|
747
|
+
GROUP BY f.file_path, f.line, f.name
|
|
748
|
+
ORDER BY callee_hits DESC
|
|
749
|
+
LIMIT ?
|
|
750
|
+
`).all(...callerNames, 40);
|
|
751
|
+
}
|
|
752
|
+
catch { /* graceful — callee expansion is additive only */ }
|
|
753
|
+
}
|
|
754
|
+
// Build the callee membership set BEFORE removing BM25 overlap.
|
|
755
|
+
// This is used to apply a score bonus to BM25-tier functions that are also
|
|
756
|
+
// call-graph targets (e.g. handleHTTPRequest: low BM25 rank, but called by ServeHTTP).
|
|
757
|
+
const calleeNameSet = new Set(calleeRows.map(r => r.name));
|
|
758
|
+
// Remove BM25 names from the separate callee tier to avoid double-counting.
|
|
759
|
+
calleeRows = calleeRows.filter(r => !bm25NameSet.has(r.name));
|
|
760
|
+
// ── Normalisation scalars ─────────────────────────────────────────────────
|
|
761
|
+
// BM25: negative (more negative = better), invert then normalise.
|
|
762
|
+
const bm25Scores = rows.map(r => -r.bm25);
|
|
763
|
+
const bm25Max = Math.max(...bm25Scores);
|
|
764
|
+
const bm25Min = Math.min(...bm25Scores);
|
|
765
|
+
const bm25Range = bm25Max - bm25Min || 1;
|
|
766
|
+
// In-degree: BM25 tier only (callees don't carry their own in-degree here).
|
|
767
|
+
const indegreeMax = Math.max(...rows.map(r => r.indegree)) || 1;
|
|
768
|
+
// Callee hits: normalise by pool size so a single edge (1 of N candidates) = tiny score.
|
|
769
|
+
// This prevents callee expansion from flooding results when the BM25 signal is weak.
|
|
770
|
+
const maxCalleeHits = Math.max(bm25Names.slice(0, 30).length, 1);
|
|
771
|
+
// ── Vector scores (optional) ──────────────────────────────────────────────
|
|
772
|
+
const vecScores = new Map();
|
|
773
|
+
if (queryVec) {
|
|
774
|
+
const allNames = new Set([...bm25Names, ...calleeRows.map(r => r.name)]);
|
|
775
|
+
const vecResults = this.searchByVector(queryVec, candidateLimit * 2);
|
|
776
|
+
for (const v of vecResults) {
|
|
777
|
+
if (allNames.has(v.name))
|
|
778
|
+
vecScores.set(`${v.file_path}::${v.name}::${v.line}`, v.score);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const hasVec = vecScores.size > 0;
|
|
782
|
+
// ── Weight tables ─────────────────────────────────────────────────────────
|
|
783
|
+
const W_BM25 = hasVec ? 0.50 : 0.70;
|
|
784
|
+
const W_AUTH = hasVec ? 0.20 : 0.30;
|
|
785
|
+
const W_VEC = hasVec ? 0.30 : 0.00;
|
|
786
|
+
// Callee tier: scored on hit count + vector (no separate in-degree).
|
|
787
|
+
const W_CALLEE = hasVec ? 0.35 : 0.45;
|
|
788
|
+
const W_CA_VEC = hasVec ? 0.30 : 0.00;
|
|
789
|
+
// Callee bonus: applied only to BM25-tier functions with WEAK BM25 signal (bm25Norm < 0.20)
|
|
790
|
+
// that also appear in the callee expansion. This targets long-tail pool members like
|
|
791
|
+
// handleHTTPRequest (rank ~90/150) without boosting already-competitive functions
|
|
792
|
+
// (e.g. render_template at rank ~40) which could displace authority-ranked results.
|
|
793
|
+
const W_CALLEE_BONUS = 0.28;
|
|
794
|
+
const CALLEE_BM25_THRESHOLD = 0.20; // only boost if bm25Norm below this
|
|
795
|
+
// ── Test/example-file penalty ─────────────────────────────────────────────
|
|
796
|
+
// Applied 0.5× to test files and example/demo/sample directories.
|
|
797
|
+
// Checks both the filename AND all directory segments so that files like
|
|
798
|
+
// "examples/static-files/index.js" are caught even if their basename is generic.
|
|
799
|
+
const TEST_PENALTY = 0.50;
|
|
800
|
+
const isNonSourceFile = (fp) => {
|
|
801
|
+
const parts = fp.split("/");
|
|
802
|
+
const filename = parts[parts.length - 1] ?? fp;
|
|
803
|
+
return /test|spec|bench|mock|fixture/i.test(filename) ||
|
|
804
|
+
parts.some(p => /^examples?$|^demos?$|^samples?$/i.test(p));
|
|
805
|
+
};
|
|
806
|
+
// ── Score BM25 tier ───────────────────────────────────────────────────────
|
|
807
|
+
const scored = rows.map(r => {
|
|
808
|
+
const bm25Norm = ((-r.bm25) - bm25Min) / bm25Range;
|
|
809
|
+
const authNorm = r.indegree / indegreeMax;
|
|
810
|
+
const key = `${r.file_path}::${r.name}::${r.line}`;
|
|
811
|
+
const vecSim = vecScores.get(key) ?? 0;
|
|
812
|
+
// Callee bonus only applies to source-file functions (test files are already penalised).
|
|
813
|
+
const calleeBonus = (!isNonSourceFile(r.file_path) && calleeNameSet.has(r.name)
|
|
814
|
+
&& bm25Norm < CALLEE_BM25_THRESHOLD)
|
|
815
|
+
? W_CALLEE_BONUS : 0;
|
|
816
|
+
const raw = W_BM25 * bm25Norm + W_AUTH * authNorm + W_VEC * vecSim + calleeBonus;
|
|
817
|
+
return { file_path: r.file_path, name: r.name, line: parseInt(r.line, 10),
|
|
818
|
+
score: isNonSourceFile(r.file_path) ? raw * TEST_PENALTY : raw };
|
|
819
|
+
});
|
|
820
|
+
// ── Score callee tier (functions NOT in BM25 pool) and merge ─────────────
|
|
821
|
+
for (const r of calleeRows) {
|
|
822
|
+
const hitsNorm = r.callee_hits / maxCalleeHits;
|
|
823
|
+
const key = `${r.file_path}::${r.name}::${r.line}`;
|
|
824
|
+
const vecSim = vecScores.get(key) ?? 0;
|
|
825
|
+
const raw = W_CALLEE * hitsNorm + W_CA_VEC * vecSim;
|
|
826
|
+
scored.push({ file_path: r.file_path, name: r.name, line: parseInt(r.line, 10),
|
|
827
|
+
score: isNonSourceFile(r.file_path) ? raw * TEST_PENALTY : raw });
|
|
828
|
+
}
|
|
829
|
+
scored.sort((a, b) => b.score - a.score);
|
|
830
|
+
return scored.slice(0, limit);
|
|
354
831
|
}
|
|
355
832
|
// ── Dependency graph ────────────────────────────────────────────────────────
|
|
356
833
|
/** Replace all import edges (run once per guardian extract --backend sqlite). */
|
|
@@ -411,6 +888,9 @@ export class SqliteSpecsStore {
|
|
|
411
888
|
catch {
|
|
412
889
|
return [];
|
|
413
890
|
}
|
|
891
|
+
// Build a set of bare query stems for matching_symbols computation.
|
|
892
|
+
// Strip the trailing '*' added by _buildTokens so we can do prefix matching.
|
|
893
|
+
const queryStems = tokens.map(t => t.replace(/\*$/, ""));
|
|
414
894
|
// Apply quality reranking using dependency-graph authority score.
|
|
415
895
|
const reranked = rows.map(r => {
|
|
416
896
|
const imports = r.imports_ ? r.imports_.split(",").filter(Boolean) : [];
|
|
@@ -426,14 +906,27 @@ export class SqliteSpecsStore {
|
|
|
426
906
|
// authority_ratio ∈ [0, 1]: 1.0 = pure authority (many things import this file)
|
|
427
907
|
// 0.0 = pure hub (imports many, nothing imports it)
|
|
428
908
|
const authority = usedByN / (usedByN + importsN);
|
|
429
|
-
//
|
|
430
|
-
//
|
|
909
|
+
// Range [0.7, 1.0]: hub files that import many things but aren't imported
|
|
910
|
+
// get a slight penalty vs authority files. Explicit path penalty handles examples.
|
|
431
911
|
quality = 0.7 + 0.3 * authority;
|
|
432
912
|
}
|
|
913
|
+
// Path-based hard penalty for example/demo/sample directories — belt-and-suspenders
|
|
914
|
+
// on top of the authority demotion, for repos where dep graph may be sparse.
|
|
915
|
+
const pathParts = r.file_path.split("/");
|
|
916
|
+
if (pathParts.some(p => /^examples?$|^demos?$|^samples?$/i.test(p))) {
|
|
917
|
+
quality *= 0.5;
|
|
918
|
+
}
|
|
433
919
|
// bm25 is negative (more negative = better). Multiplying by quality < 1
|
|
434
920
|
// moves the score toward 0 — making low-quality files rank worse.
|
|
435
921
|
const combined = r.rank * quality;
|
|
436
|
-
|
|
922
|
+
// Snippet equivalent: which named symbols in this file match query stems?
|
|
923
|
+
// symbol_name is a space-separated list of all symbols extracted from the file.
|
|
924
|
+
const fileSymbols = r.symbol_name ? r.symbol_name.split(/\s+/).filter(Boolean) : [];
|
|
925
|
+
const matching_symbols = fileSymbols.filter(sym => {
|
|
926
|
+
const symLower = splitIdentifiers(sym); // "isPublished" → "is published"
|
|
927
|
+
return queryStems.some(stem => symLower.includes(stem) || sym.toLowerCase().includes(stem));
|
|
928
|
+
}).slice(0, 6); // cap at 6 per file
|
|
929
|
+
return { file_path: r.file_path, symbol_name: r.symbol_name, rank: combined, imports, used_by, matching_symbols };
|
|
437
930
|
});
|
|
438
931
|
reranked.sort((a, b) => a.rank - b.rank);
|
|
439
932
|
return reranked.slice(0, limit);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@toolbaux/guardian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Architectural intelligence for codebases. Verify that AI-generated code matches your architectural intent.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"benchmark:llm": "tsx scripts/benchmark-llm-context/index.ts"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"@xenova/transformers": "^2.17.2",
|
|
56
57
|
"better-sqlite3": "^12.8.0",
|
|
57
58
|
"commander": "^12.1.0",
|
|
58
59
|
"dotenv": "^17.3.1",
|