knodin 0.11.0 → 0.12.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/dist/bin/cli.js +41 -4
- package/dist/src/cli-model.js +1 -0
- package/dist/src/engine/index.js +371 -74
- package/dist/src/graph-query-health.js +9 -3
- package/dist/src/init.js +81 -0
- package/dist/src/lifecycle-health.js +46 -3
- package/dist/src/repair-progress.js +12 -0
- package/dist/src/tools/knodin-tools.js +16 -0
- package/docs/releases/0.12.0.md +281 -0
- package/package.json +3 -1
package/dist/bin/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ import { diagnoseFailure, } from "../src/failure-diagnosis.js";
|
|
|
35
35
|
import { gitExecutable } from "../src/git-executable.js";
|
|
36
36
|
import { decorateGraphQueryResult, inspectGraphQueryHealth } from "../src/graph-query-health.js";
|
|
37
37
|
import { createIndexActivityReporter } from "../src/index-activity.js";
|
|
38
|
-
import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, } from "../src/init.js";
|
|
38
|
+
import { InitializationHealthError, initializeRepository, inspectRepositoryIntegrationStatus, readRepositoryIntegrationConfig, refreshFromGitEvent, repairLifecycleRouting, } from "../src/init.js";
|
|
39
39
|
import { createInitProgressRenderer } from "../src/init-progress.js";
|
|
40
40
|
import { attachLifecycleHealth, attachRepairLifecycle } from "../src/lifecycle-health.js";
|
|
41
41
|
import { acknowledgeUpdateFailure, queryOwnerAvailability, readManagerUpdateState, readUpdateJournal, resolveManagerOwnership, setManualPin, unpinUpdate, updateAttention, writeManagerUpdateState, } from "../src/manager-update.js";
|
|
@@ -60,7 +60,7 @@ import { KNODIN_VERSION } from "../src/version.js";
|
|
|
60
60
|
import { writeVisualization, } from "../src/visualization.js";
|
|
61
61
|
import { waitForFresh } from "../src/wait-for-fresh.js";
|
|
62
62
|
import { inspectWorktrees, reconcileWorktrees, removeManagedWorktree, } from "../src/worktree-lifecycle.js";
|
|
63
|
-
import { indexOrSeed } from "../src/worktree-seed.js";
|
|
63
|
+
import { indexOrSeed, seedWorktreeIndex } from "../src/worktree-seed.js";
|
|
64
64
|
function explicitScope(args) {
|
|
65
65
|
const index = args.indexOf("--scope");
|
|
66
66
|
if (index >= 0) {
|
|
@@ -138,7 +138,7 @@ function formatRepairHuman(result) {
|
|
|
138
138
|
}
|
|
139
139
|
if (result.verified) {
|
|
140
140
|
if (result.lifecycle?.status === "degraded")
|
|
141
|
-
return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin
|
|
141
|
+
return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols), but lifecycle routing is degraded. Run \`knodin repair --lifecycle\`, then \`knodin status\`.\n`;
|
|
142
142
|
return `Repair verified: graph is healthy (${coverage.indexedFiles} indexed files, ${coverage.filesWithSymbols} files with symbols).\n`;
|
|
143
143
|
}
|
|
144
144
|
const outstanding = result.outstandingIssues?.total ??
|
|
@@ -198,7 +198,7 @@ function formatLifecycleLine(lifecycle, isMirror) {
|
|
|
198
198
|
if (lifecycle.status === "healthy")
|
|
199
199
|
return "Hooks: installed and executable.\n";
|
|
200
200
|
const issue = lifecycle.issues[0] ?? "refresh capability is not verified";
|
|
201
|
-
return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin
|
|
201
|
+
return `Lifecycle refresh: ${lifecycle.status}; ${issue}. Run \`knodin repair --lifecycle\`.\n`;
|
|
202
202
|
}
|
|
203
203
|
/** Renders the top extensions of a tally as `.md 180, .yml 74, +3 more`. */
|
|
204
204
|
function formatTally(counts, limit = 4) {
|
|
@@ -1522,6 +1522,7 @@ async function main() {
|
|
|
1522
1522
|
let result;
|
|
1523
1523
|
let repairOutput;
|
|
1524
1524
|
let repairWasPlan = false;
|
|
1525
|
+
let repairWasLifecycle = false;
|
|
1525
1526
|
let repairExitCode = 0;
|
|
1526
1527
|
let statusWasWatched = false;
|
|
1527
1528
|
let agentEventOutput = false;
|
|
@@ -2381,6 +2382,27 @@ async function main() {
|
|
|
2381
2382
|
repairWasPlan = true;
|
|
2382
2383
|
break;
|
|
2383
2384
|
}
|
|
2385
|
+
if (options.lifecycle) {
|
|
2386
|
+
result = await repairLifecycleRouting(repo, { command: runtimeCommand });
|
|
2387
|
+
repairOutput = options.output;
|
|
2388
|
+
repairWasLifecycle = true;
|
|
2389
|
+
break;
|
|
2390
|
+
}
|
|
2391
|
+
// Cold start only. With no database at all there is nothing to rebuild
|
|
2392
|
+
// *from*, so repair would index the whole corpus while a sibling
|
|
2393
|
+
// worktree already holds a reusable baseline — reflink-copying it and
|
|
2394
|
+
// reconciling the differing paths is seconds against minutes
|
|
2395
|
+
// (KNODIN-14). Repair's contract is untouched: it still rebuilds from
|
|
2396
|
+
// this working tree, it just starts from a baseline instead of zero.
|
|
2397
|
+
//
|
|
2398
|
+
// A database that exists but is damaged is deliberately excluded. That
|
|
2399
|
+
// is the case repair exists for, and seeding it would replace a
|
|
2400
|
+
// verifiable local rebuild with another checkout's state.
|
|
2401
|
+
if (!fs.existsSync(resolveDbPath(repo))) {
|
|
2402
|
+
const seed = await seedWorktreeIndex(repo, engine, KNODIN_SCHEMA_VERSION);
|
|
2403
|
+
if (seed.seeded)
|
|
2404
|
+
process.stderr.write("[repair:seed] Seeded baseline from an indexed sibling worktree\n");
|
|
2405
|
+
}
|
|
2384
2406
|
const progressMode = resolveRepairProgressMode(options, process.env, process.stderr.isTTY);
|
|
2385
2407
|
const renderer = progressMode === "tty"
|
|
2386
2408
|
? createProgressWorkerRenderer("repair-progress-worker", {
|
|
@@ -2787,6 +2809,12 @@ async function main() {
|
|
|
2787
2809
|
}
|
|
2788
2810
|
result = decorateGraphQueryResult(result, verifiedQueryHealth.state, verifiedQueryHealth.graph.freshness);
|
|
2789
2811
|
}
|
|
2812
|
+
// A target that does not exist is operator error, not a negative
|
|
2813
|
+
// answer, so it exits non-zero like an unavailable graph rather than
|
|
2814
|
+
// like real dead code (KNODIN-28). `resolved` with zero rows keeps
|
|
2815
|
+
// exit 0: the symbol exists and genuinely has no callers.
|
|
2816
|
+
if (result?.targetResolution === "not-found")
|
|
2817
|
+
process.exitCode = 1;
|
|
2790
2818
|
break;
|
|
2791
2819
|
}
|
|
2792
2820
|
case "rename": {
|
|
@@ -2999,6 +3027,15 @@ async function main() {
|
|
|
2999
3027
|
process.stdout.write(formatConfigureStatusHuman(boundedResult));
|
|
3000
3028
|
return;
|
|
3001
3029
|
}
|
|
3030
|
+
if (cmd === "repair" && repairOutput === "human" && repairWasLifecycle) {
|
|
3031
|
+
// A lifecycle repair carries no graph coverage, so it cannot go through
|
|
3032
|
+
// the graph-repair renderer, and saying nothing about what it left alone
|
|
3033
|
+
// would omit the only reason to reach for it (KNODIN-31).
|
|
3034
|
+
const lifecycle = boundedResult;
|
|
3035
|
+
process.stdout.write(`Lifecycle routing rewritten: ${lifecycle.gitHooks.length} managed Git hook(s) and the background indexer, in ${lifecycle.hooksDirectory}. Agent configuration untouched. Run \`knodin status\` to confirm.\n`);
|
|
3036
|
+
process.exitCode = finalExitCode;
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
3002
3039
|
if (cmd === "repair" && repairOutput === "human" && !repairWasPlan) {
|
|
3003
3040
|
process.stdout.write(formatRepairHuman(boundedResult));
|
|
3004
3041
|
process.exitCode = finalExitCode;
|
package/dist/src/cli-model.js
CHANGED
|
@@ -309,6 +309,7 @@ function createCliProgram(capture = () => { }) {
|
|
|
309
309
|
.addOption(option("--timeout <seconds>", "deadline", "number"));
|
|
310
310
|
leaf(program, "repair", "audit and reconcile graph state", capture)
|
|
311
311
|
.option("--plan", "report the repair plan without mutation")
|
|
312
|
+
.option("--lifecycle", "rewrite lifecycle hooks only, leaving agent configuration untouched")
|
|
312
313
|
.option("--jsonl", "stream JSONL progress")
|
|
313
314
|
.option("--progress <mode>", "tty, jsonl, plain, or none")
|
|
314
315
|
.option("--progress-interval <duration>", "progress interval such as 750ms, 30s, or 2m");
|
package/dist/src/engine/index.js
CHANGED
|
@@ -826,6 +826,97 @@ function freshnessMechanismFor(repoPath, policy) {
|
|
|
826
826
|
// Bumped whenever any repo is (re)indexed, so cached community-detection results
|
|
827
827
|
// below can be invalidated cheaply instead of recomputed on every call.
|
|
828
828
|
export const KNODIN_SCHEMA_VERSION = 24;
|
|
829
|
+
/**
|
|
830
|
+
* Reciprocal Rank Fusion's smoothing constant. Was written as a bare `60` at
|
|
831
|
+
* each use site; named so the ranking formula can be read and changed in one
|
|
832
|
+
* place. Adjacent ranks differ by roughly 0.00026 at this k, which is the unit
|
|
833
|
+
* every other ranking weight has to be reasoned about in.
|
|
834
|
+
*/
|
|
835
|
+
const RRF_K = 60;
|
|
836
|
+
/**
|
|
837
|
+
* How hard graph centrality pulls on ranking, as a fraction of one full RRF
|
|
838
|
+
* component.
|
|
839
|
+
*
|
|
840
|
+
* Semantic similarity alone ranks a minutes-old duplicate above the
|
|
841
|
+
* implementation it duplicates: the duplicate is short and made almost entirely
|
|
842
|
+
* of the query's own words, while the canonical version is diluted by a real
|
|
843
|
+
* body. The searcher then reads their own new leaf as evidence that nothing
|
|
844
|
+
* existed (KNODIN-15). Community and degree data was already in the graph and
|
|
845
|
+
* already in the output; it simply was not weighted into ranking.
|
|
846
|
+
*
|
|
847
|
+
* 0.05 caps the boost at `0.05 / (RRF_K + 1)` ≈ 0.00082, about three rank
|
|
848
|
+
* positions. That is deliberately modest, because the opposite failure is
|
|
849
|
+
* equally real and lands on the same symbols: a *seam* is by definition a
|
|
850
|
+
* well-documented symbol with no callers, and those are already the hardest
|
|
851
|
+
* things to retrieve (KNODIN-28, KNODIN-30). A large centrality weight would
|
|
852
|
+
* bury them to fix this, trading one silent miss for another. So a connected
|
|
853
|
+
* implementation climbs past neighbours it is scoring near, and does not
|
|
854
|
+
* override a decisively stronger semantic match.
|
|
855
|
+
*
|
|
856
|
+
* Zero-in-degree symbols are excluded from the centrality ranking rather than
|
|
857
|
+
* ranked last, so an uncalled symbol is never *penalised* — it only forgoes a
|
|
858
|
+
* boost it has no evidence for.
|
|
859
|
+
*/
|
|
860
|
+
const CENTRALITY_RRF_WEIGHT = 0.05;
|
|
861
|
+
/**
|
|
862
|
+
* The live ranking weights, exported so a benchmark can record which values
|
|
863
|
+
* produced its numbers.
|
|
864
|
+
*
|
|
865
|
+
* Comparing two ranking runs whose constants differed is not a measurement, and
|
|
866
|
+
* the difference is invisible unless the run writes them down. Reading them here
|
|
867
|
+
* rather than copying them into the benchmark keeps that record from going stale
|
|
868
|
+
* the first time someone tunes a weight.
|
|
869
|
+
*/
|
|
870
|
+
/**
|
|
871
|
+
* How hard a partial (OR) lexical match pulls, as a fraction of one full RRF
|
|
872
|
+
* component, and how many of them are considered.
|
|
873
|
+
*
|
|
874
|
+
* The weight buys a statable property: at 0.75 a top-ranked partial match
|
|
875
|
+
* contributes `0.75 / (RRF_K + 1)` ≈ 0.0123, which is enough to lift a symbol
|
|
876
|
+
* sitting around semantic rank 90 above an unsupported semantic rank 1. So
|
|
877
|
+
* lexical evidence can rescue a symbol the embedder under-ranked, and cannot
|
|
878
|
+
* resurrect one the embedder judged irrelevant. At 1.0 the loose channel becomes
|
|
879
|
+
* a full peer of semantic similarity and wins from arbitrarily deep, which is
|
|
880
|
+
* over-correction.
|
|
881
|
+
*
|
|
882
|
+
* The limit is a separate lever and conflating the two is how this gets
|
|
883
|
+
* mistuned. RRF has a floor — the hundredth partial match still scores
|
|
884
|
+
* `w / (RRF_K + 100)` — so an unbounded channel gives a meaningful shove to a
|
|
885
|
+
* hundred symbols at once on any query full of common words. Truncating raises
|
|
886
|
+
* the per-item floor slightly while applying it to under a third as many rows,
|
|
887
|
+
* which is the trade that actually reduces noise.
|
|
888
|
+
*
|
|
889
|
+
* Both are starting points measured against `benchmarks/evaluations/c103-prose-retrieval`,
|
|
890
|
+
* not settled constants. Re-run it before changing either.
|
|
891
|
+
*/
|
|
892
|
+
const LOOSE_LEXICAL_RRF_WEIGHT = 0.75;
|
|
893
|
+
/**
|
|
894
|
+
* How many query words the quorum check will probe, one bounded FTS query each.
|
|
895
|
+
*
|
|
896
|
+
* The check exists because a single incidental token match is not evidence, and
|
|
897
|
+
* this index cannot tell the difference on its own: FTS5 here has no stemming
|
|
898
|
+
* and does not split identifiers, so "validations" misses "validation" and
|
|
899
|
+
* `SessionAuthenticator` is one opaque token. The cap keeps a pathologically
|
|
900
|
+
* long query from turning into a pathological number of probes; beyond it the
|
|
901
|
+
* leading words decide, which is where the discriminating terms usually are.
|
|
902
|
+
*/
|
|
903
|
+
const LEXICAL_QUORUM_TOKEN_CAP = 8;
|
|
904
|
+
const LOOSE_LEXICAL_LIMIT = 30;
|
|
905
|
+
/**
|
|
906
|
+
* The live ranking weights, exported so a benchmark can record which values
|
|
907
|
+
* produced its numbers.
|
|
908
|
+
*
|
|
909
|
+
* Comparing two ranking runs whose constants differed is not a measurement, and
|
|
910
|
+
* the difference is invisible unless the run writes them down. Reading them here
|
|
911
|
+
* rather than copying them into the benchmark keeps that record from going stale
|
|
912
|
+
* the first time someone tunes a weight.
|
|
913
|
+
*/
|
|
914
|
+
export const RANKING_CONSTANTS = {
|
|
915
|
+
rrfK: RRF_K,
|
|
916
|
+
centralityWeight: CENTRALITY_RRF_WEIGHT,
|
|
917
|
+
looseLexicalWeight: LOOSE_LEXICAL_RRF_WEIGHT,
|
|
918
|
+
looseLexicalLimit: LOOSE_LEXICAL_LIMIT,
|
|
919
|
+
};
|
|
829
920
|
const LAST_REBUILD_SCHEMA_VERSION = 24;
|
|
830
921
|
let indexGeneration = 0;
|
|
831
922
|
const resourceReachabilityCache = new Map();
|
|
@@ -1631,12 +1722,48 @@ function createLanguageMemo() {
|
|
|
1631
1722
|
return name;
|
|
1632
1723
|
};
|
|
1633
1724
|
}
|
|
1725
|
+
/**
|
|
1726
|
+
* Node types a `//` line can carry across the grammars this indexes. They
|
|
1727
|
+
* disagree on the name, so matching one of them is not enough on its own — see
|
|
1728
|
+
* `isLineComment` below, which also requires the `//` prefix.
|
|
1729
|
+
*/
|
|
1730
|
+
const LINE_COMMENT_NODE_TYPES = new Set(["comment", "line_comment", "hash_comment"]);
|
|
1634
1731
|
/** Clean up and format comment/docstring blocks in JavaScript/TypeScript. */
|
|
1635
1732
|
function getPrecedingComment(node) {
|
|
1636
1733
|
let target = node;
|
|
1637
1734
|
while (target.parent && target.parent.startPosition.row === target.startPosition.row) {
|
|
1638
1735
|
target = target.parent;
|
|
1639
1736
|
}
|
|
1737
|
+
// Only decorators and modifiers may sit between a doc comment and the thing
|
|
1738
|
+
// it documents. The walk used to step over ANY five siblings looking for a
|
|
1739
|
+
// comment, so a symbol with no doc comment of its own adopted whichever
|
|
1740
|
+
// comment preceded a nearby earlier symbol — attaching prose to a symbol it
|
|
1741
|
+
// was never written about, in both the embedding input and the FTS `summary`
|
|
1742
|
+
// column.
|
|
1743
|
+
//
|
|
1744
|
+
// That is not a corner case. Measured on this repository before the fix, 857
|
|
1745
|
+
// of 1,329 documented symbols under `src/` — 64% — shared summary text with
|
|
1746
|
+
// another symbol, and spot checks showed plain misattribution rather than
|
|
1747
|
+
// genuine repetition: an interface carrying a neighbouring function's
|
|
1748
|
+
// docstring, two unrelated types sharing a third symbol's sentence. Prose
|
|
1749
|
+
// retrieval matching that text then returns the wrong symbol confidently,
|
|
1750
|
+
// which is the same class of failure as the truncation above and strictly
|
|
1751
|
+
// worse: absent prose loses a hit, misattributed prose manufactures one.
|
|
1752
|
+
//
|
|
1753
|
+
// Stopping at the first non-skippable node is the whole fix. A row-adjacency
|
|
1754
|
+
// check was tried alongside it and removed: decorators are children of the
|
|
1755
|
+
// declaration in some grammars and siblings in others, so "directly above"
|
|
1756
|
+
// is not portable, and it silently dropped the doc comment of every
|
|
1757
|
+
// decorated class. The walk already refuses to cross a declaration, which is
|
|
1758
|
+
// what the misattribution needed.
|
|
1759
|
+
const SKIPPABLE_BEFORE_DOC = new Set([
|
|
1760
|
+
"decorator",
|
|
1761
|
+
"export",
|
|
1762
|
+
"default",
|
|
1763
|
+
"async",
|
|
1764
|
+
"abstract",
|
|
1765
|
+
"declare",
|
|
1766
|
+
]);
|
|
1640
1767
|
let prev = target.previousSibling;
|
|
1641
1768
|
let count = 0;
|
|
1642
1769
|
while (prev && count < 5) {
|
|
@@ -1646,6 +1773,8 @@ function getPrecedingComment(node) {
|
|
|
1646
1773
|
prev.type === "line_comment") {
|
|
1647
1774
|
break;
|
|
1648
1775
|
}
|
|
1776
|
+
if (!SKIPPABLE_BEFORE_DOC.has(prev.type))
|
|
1777
|
+
return null;
|
|
1649
1778
|
prev = prev.previousSibling;
|
|
1650
1779
|
count++;
|
|
1651
1780
|
}
|
|
@@ -1654,6 +1783,48 @@ function getPrecedingComment(node) {
|
|
|
1654
1783
|
prev.type === "hash_comment" ||
|
|
1655
1784
|
prev.type === "block_comment" ||
|
|
1656
1785
|
prev.type === "line_comment")) {
|
|
1786
|
+
// Walk back over a run of consecutive `//` lines and rejoin them.
|
|
1787
|
+
//
|
|
1788
|
+
// tree-sitter gives every `//` line its OWN comment node, so `prev` is the
|
|
1789
|
+
// LAST line of a block, and the `split("\n")` below could never fire for
|
|
1790
|
+
// this style. Every multi-line `//` doc comment in every indexed
|
|
1791
|
+
// repository was therefore truncated to its final line before it reached
|
|
1792
|
+
// the embedding or the FTS index — silently, since a one-line summary
|
|
1793
|
+
// looks perfectly well-formed.
|
|
1794
|
+
//
|
|
1795
|
+
// That is a retrieval defect, not a cosmetic one: the first line of a doc
|
|
1796
|
+
// comment is usually the sentence that says what the thing is for, which
|
|
1797
|
+
// is exactly what a capability question matches on. `/* */` blocks are a
|
|
1798
|
+
// single node and were never affected, so the damage was invisible in any
|
|
1799
|
+
// codebase that preferred them.
|
|
1800
|
+
//
|
|
1801
|
+
// Adjacency is required: a blank line or any code between two comment
|
|
1802
|
+
// nodes ends the block, so a stray earlier comment is not absorbed.
|
|
1803
|
+
// Keyed on the `//` PREFIX rather than on one node type. The surrounding
|
|
1804
|
+
// checks accept `comment`, `line_comment`, `hash_comment` and
|
|
1805
|
+
// `block_comment` because grammars disagree about the name, and an
|
|
1806
|
+
// earlier version of this rejoin tested `type === "comment"` alone — so
|
|
1807
|
+
// in any grammar that calls a `//` line `line_comment`, multi-line
|
|
1808
|
+
// comments went on being truncated to their last line while appearing
|
|
1809
|
+
// fixed everywhere else. The prefix is the thing that actually decides
|
|
1810
|
+
// whether a node is one line of a multi-line run.
|
|
1811
|
+
const isLineComment = (node) => LINE_COMMENT_NODE_TYPES.has(node.type) && node.text.trim().startsWith("//");
|
|
1812
|
+
if (isLineComment(prev)) {
|
|
1813
|
+
const lines = [];
|
|
1814
|
+
let line = prev;
|
|
1815
|
+
while (line &&
|
|
1816
|
+
isLineComment(line) &&
|
|
1817
|
+
// Consecutive source lines, and nothing but whitespace between them.
|
|
1818
|
+
(lines.length === 0 || line.endPosition.row + 1 === prev.startPosition.row)) {
|
|
1819
|
+
lines.unshift(line.text.trim());
|
|
1820
|
+
prev = line;
|
|
1821
|
+
line = line.previousSibling;
|
|
1822
|
+
}
|
|
1823
|
+
return lines
|
|
1824
|
+
.map((entry) => entry.replace(/^\/\/+\s*/, ""))
|
|
1825
|
+
.join("\n")
|
|
1826
|
+
.trim();
|
|
1827
|
+
}
|
|
1657
1828
|
let text = prev.text.trim();
|
|
1658
1829
|
if (text.startsWith("/*")) {
|
|
1659
1830
|
text = text
|
|
@@ -6988,6 +7159,34 @@ function repairPathsAffectTypeScriptDi(db, repoPath, repairPaths) {
|
|
|
6988
7159
|
// measurements.
|
|
6989
7160
|
const DEFAULT_EMBEDDING_BATCH_SIZE = 1;
|
|
6990
7161
|
const MAX_EMBEDDING_BATCH_SIZE = 32;
|
|
7162
|
+
/**
|
|
7163
|
+
* Total characters embedded per symbol.
|
|
7164
|
+
*
|
|
7165
|
+
* A `EMBEDDING_SOURCE_BUDGET` capping the source body inside this was tried and
|
|
7166
|
+
* reverted. It looked like a win — the diluted implementation's similarity rose
|
|
7167
|
+
* 0.163 to 0.212 against its own fresh duplicate — but that was measured on the
|
|
7168
|
+
* suite's bag-of-words test embedder, and re-measuring on the real MiniLM model
|
|
7169
|
+
* across two body shapes, three query phrasings and both budgets showed the cap
|
|
7170
|
+
* never changes the winner in any of the twelve configurations. Its apparent
|
|
7171
|
+
* benefit came from the fixture: `fillerBody` emitted forty copies of one line,
|
|
7172
|
+
* and truncating identical lines removes a redundancy bag-of-words punishes and
|
|
7173
|
+
* a transformer largely ignores. On realistic varied code the cap is neutral to
|
|
7174
|
+
* slightly negative.
|
|
7175
|
+
*
|
|
7176
|
+
* The general lesson, which cost a forced re-embed to learn: a short document
|
|
7177
|
+
* made almost entirely of query tokens beats a long one under cosine
|
|
7178
|
+
* similarity, and no reweighting of a single vector's inputs changes that.
|
|
7179
|
+
* KNODIN-15's remedy is a non-similarity signal — graph degree, in the rank
|
|
7180
|
+
* fusion — not the representation.
|
|
7181
|
+
*/
|
|
7182
|
+
const EMBEDDING_INPUT_BUDGET = 1000;
|
|
7183
|
+
/**
|
|
7184
|
+
* The text embedded for one symbol: what it is called, what kind of thing it
|
|
7185
|
+
* is, what its docstring says it does, and then its body.
|
|
7186
|
+
*/
|
|
7187
|
+
function embeddingInputFor(symbol, source) {
|
|
7188
|
+
return `Name: ${symbol.name}\nKind: ${symbol.kind}\nSummary: ${symbol.summary || ""}\nSource:\n${source}`.slice(0, EMBEDDING_INPUT_BUDGET);
|
|
7189
|
+
}
|
|
6991
7190
|
// A full embedding pass can touch many symbols per source file. Keep only a
|
|
6992
7191
|
// bounded LRU of split lines so large repositories avoid re-reading a file for
|
|
6993
7192
|
// every symbol without turning source preparation into an unbounded memory sink.
|
|
@@ -7182,10 +7381,7 @@ async function indexEmbeddings(db, repoPath, progress, signal) {
|
|
|
7182
7381
|
throwIfAborted(signal);
|
|
7183
7382
|
const prepared = missing.slice(offset, offset + batchSize).map((sym) => {
|
|
7184
7383
|
const source = sourceForEmbedding(sym.filePath, sym.startLine, sym.endLine);
|
|
7185
|
-
return {
|
|
7186
|
-
symbol: sym,
|
|
7187
|
-
text: `Name: ${sym.name}\nKind: ${sym.kind}\nSummary: ${sym.summary || ""}\nSource:\n${source}`.slice(0, 1000),
|
|
7188
|
-
};
|
|
7384
|
+
return { symbol: sym, text: embeddingInputFor(sym, source) };
|
|
7189
7385
|
});
|
|
7190
7386
|
const generated = await embedPreparedBatch(prepared, onModelProgress);
|
|
7191
7387
|
throwIfAborted(signal);
|
|
@@ -8004,50 +8200,6 @@ const freshnessChecks = new Map();
|
|
|
8004
8200
|
* deterministic, rather than in wall-clock milliseconds, which is flaky.
|
|
8005
8201
|
*/
|
|
8006
8202
|
const freshnessStats = { probes: 0, reconciles: 0, cacheHits: 0 };
|
|
8007
|
-
/** Read HEAD without spawning Git. `null` means this is not a Git checkout;
|
|
8008
|
-
* `undefined` means Git metadata exists but could not be resolved safely. */
|
|
8009
|
-
function readGitHeadFast(repoPath) {
|
|
8010
|
-
const marker = path.join(repoPath, ".git");
|
|
8011
|
-
if (!fs.existsSync(marker))
|
|
8012
|
-
return null;
|
|
8013
|
-
try {
|
|
8014
|
-
const markerStat = fs.statSync(marker);
|
|
8015
|
-
const gitDir = markerStat.isDirectory()
|
|
8016
|
-
? marker
|
|
8017
|
-
: path.resolve(repoPath, fs
|
|
8018
|
-
.readFileSync(marker, "utf8")
|
|
8019
|
-
.trim()
|
|
8020
|
-
.replace(/^gitdir:\s*/, ""));
|
|
8021
|
-
const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
|
|
8022
|
-
if (/^[a-f0-9]{40}$/i.test(head))
|
|
8023
|
-
return head.toLowerCase();
|
|
8024
|
-
const refPrefix = "ref: ";
|
|
8025
|
-
const ref = head.startsWith(refPrefix) ? head.slice(refPrefix.length).trim() : "";
|
|
8026
|
-
if (!ref)
|
|
8027
|
-
return undefined;
|
|
8028
|
-
let commonDir = gitDir;
|
|
8029
|
-
const commonMarker = path.join(gitDir, "commondir");
|
|
8030
|
-
if (fs.existsSync(commonMarker))
|
|
8031
|
-
commonDir = path.resolve(gitDir, fs.readFileSync(commonMarker, "utf8").trim());
|
|
8032
|
-
for (const base of [gitDir, commonDir]) {
|
|
8033
|
-
const loose = path.join(base, ref);
|
|
8034
|
-
if (fs.existsSync(loose))
|
|
8035
|
-
return fs.readFileSync(loose, "utf8").trim().toLowerCase();
|
|
8036
|
-
}
|
|
8037
|
-
const packed = path.join(commonDir, "packed-refs");
|
|
8038
|
-
if (!fs.existsSync(packed))
|
|
8039
|
-
return undefined;
|
|
8040
|
-
return fs
|
|
8041
|
-
.readFileSync(packed, "utf8")
|
|
8042
|
-
.split("\n")
|
|
8043
|
-
.find((line) => line.endsWith(` ${ref}`))
|
|
8044
|
-
?.split(" ", 1)[0]
|
|
8045
|
-
?.toLowerCase();
|
|
8046
|
-
}
|
|
8047
|
-
catch {
|
|
8048
|
-
return undefined;
|
|
8049
|
-
}
|
|
8050
|
-
}
|
|
8051
8203
|
/**
|
|
8052
8204
|
* Paths the live watcher for `repoPath` currently has registered, flattened to
|
|
8053
8205
|
* repo-relative form. Empty when no watcher is running (test mode, or after
|
|
@@ -8272,15 +8424,20 @@ async function ensureIndexFresh(repoPath, db) {
|
|
|
8272
8424
|
// already reached its event queue. Git worktrees therefore take the bounded
|
|
8273
8425
|
// porcelain proof on every answer. The long watcher lease remains safe for
|
|
8274
8426
|
// non-Git directories, where the watcher is the only new-file signal.
|
|
8427
|
+
//
|
|
8428
|
+
// The lease used to be paired with a spawn-free HEAD read, so a leased
|
|
8429
|
+
// answer could still be invalidated by a commit. Once git-backed repos took
|
|
8430
|
+
// `lease = 0` that read became unreachable — it was called only when `.git`
|
|
8431
|
+
// was absent, which was its own first bail-out — and the term it fed was
|
|
8432
|
+
// inert. Both are gone (KNODIN-16); a lease is now reached only where there
|
|
8433
|
+
// is no HEAD to check.
|
|
8275
8434
|
const gitBacked = fs.existsSync(path.join(key, ".git"));
|
|
8276
8435
|
const lease = gitBacked
|
|
8277
8436
|
? 0
|
|
8278
8437
|
: watchQueues.get(key)?.ready
|
|
8279
8438
|
? WATCHED_FRESHNESS_LEASE_MS
|
|
8280
8439
|
: FRESHNESS_PROBE_TTL_MS;
|
|
8281
|
-
|
|
8282
|
-
const headUnchanged = head === null || (head !== undefined && head === getMeta(db, "lastIndexedHead"));
|
|
8283
|
-
if (cached && Date.now() - cached.at < lease && headUnchanged) {
|
|
8440
|
+
if (cached && Date.now() - cached.at < lease) {
|
|
8284
8441
|
freshnessStats.cacheHits++;
|
|
8285
8442
|
return cached.staleness;
|
|
8286
8443
|
}
|
|
@@ -14447,9 +14604,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14447
14604
|
.split(/\s+/)
|
|
14448
14605
|
.filter((word) => word.length >= 4)
|
|
14449
14606
|
.map((word) => `"${word}"`);
|
|
14450
|
-
const
|
|
14451
|
-
if (tokens.length === 0)
|
|
14452
|
-
return [];
|
|
14607
|
+
const runFtsMatch = (match) => {
|
|
14453
14608
|
try {
|
|
14454
14609
|
const statement = repo.db.query(`
|
|
14455
14610
|
SELECT symbolId FROM symbols_fts
|
|
@@ -14458,7 +14613,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14458
14613
|
LIMIT 100
|
|
14459
14614
|
`);
|
|
14460
14615
|
const ids = statement
|
|
14461
|
-
.all(
|
|
14616
|
+
.all(match)
|
|
14462
14617
|
.map(({ symbolId }) => symbolId)
|
|
14463
14618
|
.filter((id) => allowedIds.has(id));
|
|
14464
14619
|
statement.finalize();
|
|
@@ -14469,8 +14624,43 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14469
14624
|
return [];
|
|
14470
14625
|
}
|
|
14471
14626
|
};
|
|
14627
|
+
const runFts = (separator) => tokens.length === 0 ? [] : runFtsMatch(tokens.join(separator));
|
|
14628
|
+
const runFtsTokens = (subset) => subset.length === 0 ? [] : runFtsMatch(subset.join(" OR "));
|
|
14472
14629
|
const and = measurePerfPhaseSync("fts", () => runFts(" "));
|
|
14473
|
-
|
|
14630
|
+
// The OR set, restricted to candidates matching at least two of the
|
|
14631
|
+
// query's words.
|
|
14632
|
+
//
|
|
14633
|
+
// A single incidental token match is not evidence. FTS5 here has no
|
|
14634
|
+
// stemming and does not split identifiers, so for "session
|
|
14635
|
+
// authentication and token validations" the class that actually does
|
|
14636
|
+
// the work matches NOTHING — its docstring says "sessions" and
|
|
14637
|
+
// "validation" — while a handler whose docstring happens to contain
|
|
14638
|
+
// "session" matches once and collects the full partial-match credit.
|
|
14639
|
+
// Fusing that put the caller above the implementation it calls.
|
|
14640
|
+
//
|
|
14641
|
+
// Requiring a quorum keeps the signal that matters (a symbol echoing
|
|
14642
|
+
// several of the query's words) and drops the one that misleads. It
|
|
14643
|
+
// costs one bounded FTS query per token, which is why it is capped.
|
|
14644
|
+
const or = measurePerfPhaseSync("fts", () => {
|
|
14645
|
+
if (tokens.length < 2)
|
|
14646
|
+
return and;
|
|
14647
|
+
const loose = runFts(" OR ");
|
|
14648
|
+
if (loose.length === 0)
|
|
14649
|
+
return loose;
|
|
14650
|
+
const hits = new Map();
|
|
14651
|
+
for (const token of tokens.slice(0, LEXICAL_QUORUM_TOKEN_CAP)) {
|
|
14652
|
+
for (const id of runFtsTokens([token])) {
|
|
14653
|
+
hits.set(id, (hits.get(id) ?? 0) + 1);
|
|
14654
|
+
}
|
|
14655
|
+
}
|
|
14656
|
+
// No fallback when nothing clears the bar. An earlier version
|
|
14657
|
+
// returned the unfiltered set in that case, which reinstated the
|
|
14658
|
+
// exact failure the quorum exists to prevent — and did so
|
|
14659
|
+
// precisely in the small-corpus situation where one weak match is
|
|
14660
|
+
// most likely to be the only one. A lexical channel with nothing
|
|
14661
|
+
// trustworthy to say should say nothing.
|
|
14662
|
+
return loose.filter((id) => (hits.get(id) ?? 0) >= 2);
|
|
14663
|
+
});
|
|
14474
14664
|
const lexicalSeeds = or
|
|
14475
14665
|
.map((id) => snapshot.rowById.get(id))
|
|
14476
14666
|
.filter((row) => row !== undefined)
|
|
@@ -14526,6 +14716,15 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14526
14716
|
// 1. Generate query embedding once
|
|
14527
14717
|
const queryVec = await generateMeasuredEmbedding(query, true);
|
|
14528
14718
|
const rankedCandidates = [];
|
|
14719
|
+
// Inbound reference counts for the centrality term. Computed once for the
|
|
14720
|
+
// federation rather than per repo, and only on the route that actually
|
|
14721
|
+
// fuses ranks — the structural and exact-match routes above return before
|
|
14722
|
+
// here and must keep their deterministic ordering.
|
|
14723
|
+
//
|
|
14724
|
+
// Not wrapped in a perf phase: the phase keys are a closed set that the
|
|
14725
|
+
// instrumentation contract asserts on, and adding one to measure a fix
|
|
14726
|
+
// would change what that contract describes.
|
|
14727
|
+
const referenceDegree = computeResolvedReferenceDegree(allRepos, formatPath);
|
|
14529
14728
|
let totalMatches = 0;
|
|
14530
14729
|
for (const repo of allRepos) {
|
|
14531
14730
|
const db = repo.db;
|
|
@@ -14574,12 +14773,35 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14574
14773
|
}
|
|
14575
14774
|
// Sort by semantic score descending
|
|
14576
14775
|
const sortedSemantics = semanticMatches.sort((a, b) => b.score - a.score);
|
|
14577
|
-
const semanticScoresAreTied = sortedSemantics.length > 1 &&
|
|
14578
|
-
sortedSemantics.every((item) => item.score === sortedSemantics[0]?.score);
|
|
14579
14776
|
// 3. Reuse the lexical and bounded-graph ranks computed before query inference.
|
|
14777
|
+
//
|
|
14778
|
+
// Both operators are fused, as separate channels. The selection used to
|
|
14779
|
+
// be `semanticScoresAreTied ? or : and`, and semantic scores are never
|
|
14780
|
+
// all bit-identical under a real embedding model — so the OR set was
|
|
14781
|
+
// computed on every query and then discarded on every query.
|
|
14782
|
+
//
|
|
14783
|
+
// That made the AND clause the entire lexical channel, and for a query
|
|
14784
|
+
// phrased as prose the AND clause is empty: it demands one symbol
|
|
14785
|
+
// containing every surviving query word, function words included.
|
|
14786
|
+
// Nothing matches, so `ftsPart` was zero for every candidate and
|
|
14787
|
+
// "hybrid search" was pure vector search with a centrality nudge. The
|
|
14788
|
+
// docstring's BM25 evidence existed the whole time, in the OR set that
|
|
14789
|
+
// was thrown away (KNODIN-30).
|
|
14790
|
+
//
|
|
14791
|
+
// Fusing both rather than falling back from one to the other avoids a
|
|
14792
|
+
// discontinuity triggered by corpus contents rather than query intent:
|
|
14793
|
+
// with a fallback, one incidental symbol matching every word would flip
|
|
14794
|
+
// the channel from a broad candidate set to a single row, silently.
|
|
14795
|
+
//
|
|
14796
|
+
// `and ⊆ or` by construction — same tokens, narrower operator — so an
|
|
14797
|
+
// AND match collects BOTH terms while an OR-only match collects one.
|
|
14798
|
+
// The precision bonus falls out of that containment exactly, with no
|
|
14799
|
+
// extra machinery to express or tune.
|
|
14580
14800
|
const lexical = lexicalRanks.get(repo.path);
|
|
14581
|
-
const
|
|
14582
|
-
const
|
|
14801
|
+
const strictMatches = [...new Set(lexical?.and ?? [])].filter((id) => allowedIds.has(id));
|
|
14802
|
+
const looseMatches = [...new Set(lexical?.or ?? [])]
|
|
14803
|
+
.filter((id) => allowedIds.has(id))
|
|
14804
|
+
.slice(0, LOOSE_LEXICAL_LIMIT);
|
|
14583
14805
|
// Rank mappings for Reciprocal Rank Fusion (RRF)
|
|
14584
14806
|
const semanticRankMap = new Map();
|
|
14585
14807
|
const semanticById = new Map();
|
|
@@ -14587,19 +14809,57 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14587
14809
|
semanticRankMap.set(item.id, idx + 1);
|
|
14588
14810
|
semanticById.set(item.id, item);
|
|
14589
14811
|
});
|
|
14590
|
-
const
|
|
14591
|
-
|
|
14592
|
-
|
|
14812
|
+
const strictRankMap = new Map();
|
|
14813
|
+
strictMatches.forEach((id, idx) => {
|
|
14814
|
+
strictRankMap.set(id, idx + 1);
|
|
14593
14815
|
});
|
|
14816
|
+
const looseRankMap = new Map();
|
|
14817
|
+
looseMatches.forEach((id, idx) => {
|
|
14818
|
+
looseRankMap.set(id, idx + 1);
|
|
14819
|
+
});
|
|
14820
|
+
// Rank the matched candidates that anything actually depends on, most
|
|
14821
|
+
// depended-upon first. Symbols with no inbound references are left out
|
|
14822
|
+
// entirely rather than ranked last — see CENTRALITY_RRF_WEIGHT.
|
|
14823
|
+
const centralityRankMap = new Map();
|
|
14824
|
+
{
|
|
14825
|
+
const connected = [];
|
|
14826
|
+
for (const id of new Set([
|
|
14827
|
+
...semanticRankMap.keys(),
|
|
14828
|
+
...strictRankMap.keys(),
|
|
14829
|
+
...looseRankMap.keys(),
|
|
14830
|
+
])) {
|
|
14831
|
+
const row = semanticById.get(id)?.row ?? snapshot.rowById.get(id);
|
|
14832
|
+
if (!row)
|
|
14833
|
+
continue;
|
|
14834
|
+
const inDegree = referenceDegree.get(`${formatPath(repo.path, row.filePath)}::${row.name}`)
|
|
14835
|
+
?.inDegree ?? 0;
|
|
14836
|
+
if (inDegree > 0)
|
|
14837
|
+
connected.push({ id, inDegree });
|
|
14838
|
+
}
|
|
14839
|
+
connected.sort((a, b) => b.inDegree - a.inDegree || a.id - b.id);
|
|
14840
|
+
connected.forEach((item, idx) => {
|
|
14841
|
+
centralityRankMap.set(item.id, idx + 1);
|
|
14842
|
+
});
|
|
14843
|
+
}
|
|
14594
14844
|
// Perform Reciprocal Rank Fusion (RRF)
|
|
14595
14845
|
const topRrf = measurePerfPhaseSync("fusion", () => {
|
|
14596
|
-
const allMatchedIds = new Set([
|
|
14846
|
+
const allMatchedIds = new Set([
|
|
14847
|
+
...semanticRankMap.keys(),
|
|
14848
|
+
...strictRankMap.keys(),
|
|
14849
|
+
...looseRankMap.keys(),
|
|
14850
|
+
]);
|
|
14597
14851
|
const rrfResults = Array.from(allMatchedIds).map((id) => {
|
|
14598
14852
|
const semRank = semanticRankMap.get(id);
|
|
14599
|
-
const
|
|
14600
|
-
const
|
|
14601
|
-
const
|
|
14602
|
-
|
|
14853
|
+
const strictRank = strictRankMap.get(id);
|
|
14854
|
+
const looseRank = looseRankMap.get(id);
|
|
14855
|
+
const centralityRank = centralityRankMap.get(id);
|
|
14856
|
+
const semPart = semRank !== undefined ? 1 / (RRF_K + semRank) : 0;
|
|
14857
|
+
const strictPart = strictRank !== undefined ? 1 / (RRF_K + strictRank) : 0;
|
|
14858
|
+
const loosePart = looseRank !== undefined ? LOOSE_LEXICAL_RRF_WEIGHT * (1 / (RRF_K + looseRank)) : 0;
|
|
14859
|
+
const centralityPart = centralityRank !== undefined
|
|
14860
|
+
? CENTRALITY_RRF_WEIGHT * (1 / (RRF_K + centralityRank))
|
|
14861
|
+
: 0;
|
|
14862
|
+
return { id, rrfScore: semPart + strictPart + loosePart + centralityPart };
|
|
14603
14863
|
});
|
|
14604
14864
|
// Sort by RRF score descending and take top N
|
|
14605
14865
|
return rrfResults.sort((a, b) => b.rrfScore - a.rrfScore).slice(0, candidateLimit);
|
|
@@ -14775,6 +15035,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14775
15035
|
...(impactOptions.mode !== "file" ? ["impact"] : []),
|
|
14776
15036
|
]);
|
|
14777
15037
|
let selectedTarget;
|
|
15038
|
+
let targetResolution;
|
|
14778
15039
|
if (target && symbolPatterns.has(pattern)) {
|
|
14779
15040
|
const resolved = resolveSymbolRows(db, primary.path, target, selector);
|
|
14780
15041
|
if (resolved.ambiguity)
|
|
@@ -14804,6 +15065,26 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14804
15065
|
ambiguity: resolved.ambiguity,
|
|
14805
15066
|
};
|
|
14806
15067
|
selectedTarget = resolved.selected;
|
|
15068
|
+
// Graph health and target resolution are separate axes. A healthy,
|
|
15069
|
+
// fresh graph answering `count: 0` for a misspelled symbol was
|
|
15070
|
+
// byte-identical to one answering for real dead code, and the
|
|
15071
|
+
// freshness stamp made the typo read as an authoritative negative
|
|
15072
|
+
// (KNODIN-28). Say which of the two happened.
|
|
15073
|
+
//
|
|
15074
|
+
// `resolveSymbolRows` only sees the primary database, so a symbol
|
|
15075
|
+
// defined in a federated repo must be checked before it is called
|
|
15076
|
+
// missing — otherwise cross-repo queries would report every target
|
|
15077
|
+
// as a typo.
|
|
15078
|
+
targetResolution = resolved.selected
|
|
15079
|
+
? "resolved"
|
|
15080
|
+
: allRepos.some((r) => {
|
|
15081
|
+
const stmt = r.db.query("SELECT filePath FROM symbols WHERE name = ? LIMIT 1");
|
|
15082
|
+
const row = stmt.get(target);
|
|
15083
|
+
stmt.finalize();
|
|
15084
|
+
return row != null;
|
|
15085
|
+
})
|
|
15086
|
+
? "resolved"
|
|
15087
|
+
: "not-found";
|
|
14807
15088
|
}
|
|
14808
15089
|
const finish = (rows, extra = {}) => {
|
|
14809
15090
|
for (const row of rows) {
|
|
@@ -14822,6 +15103,7 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
14822
15103
|
count: rows.length,
|
|
14823
15104
|
results: rows.slice(0, cap),
|
|
14824
15105
|
...(rows.length > cap ? { hasMore: true } : {}),
|
|
15106
|
+
...(targetResolution ? { targetResolution } : {}),
|
|
14825
15107
|
...extra,
|
|
14826
15108
|
};
|
|
14827
15109
|
};
|
|
@@ -15004,26 +15286,41 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
|
|
|
15004
15286
|
const refs = pattern === "callers_of"
|
|
15005
15287
|
? findCallersFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null)
|
|
15006
15288
|
: findCalleesFederated(allRepos, defRepo, targetName, 1, new Set(), defFile, selectedTarget?.identity ?? null);
|
|
15007
|
-
|
|
15289
|
+
// One row per caller symbol, but carrying every call site it has.
|
|
15290
|
+
// Collapsing to the first line lost the rest and made `count`
|
|
15291
|
+
// read as a call-site count (KNODIN-29).
|
|
15292
|
+
const byCaller = new Map();
|
|
15008
15293
|
const rows = [];
|
|
15294
|
+
let callSiteCount = 0;
|
|
15009
15295
|
for (const r of refs) {
|
|
15010
15296
|
const file = formatPath(r.repoPath || defRepo, r.filePath);
|
|
15011
15297
|
const key = `${r.symbol}|${file}`;
|
|
15012
|
-
|
|
15298
|
+
callSiteCount++;
|
|
15299
|
+
const existing = byCaller.get(key);
|
|
15300
|
+
if (existing) {
|
|
15301
|
+
if (r.lineNumber !== undefined && !existing.callSiteLines?.includes(r.lineNumber))
|
|
15302
|
+
existing.callSiteLines?.push(r.lineNumber);
|
|
15013
15303
|
continue;
|
|
15014
|
-
|
|
15304
|
+
}
|
|
15015
15305
|
const rr = (r.repoPath ? allRepos.find((x) => x.path === r.repoPath) : primary)?.db
|
|
15016
15306
|
.query("SELECT * FROM symbols WHERE name = ? AND filePath = ? LIMIT 1")
|
|
15017
15307
|
.get(r.symbol, r.filePath);
|
|
15018
|
-
|
|
15308
|
+
const row = {
|
|
15019
15309
|
symbol: r.symbol,
|
|
15020
15310
|
file,
|
|
15021
15311
|
line: r.lineNumber,
|
|
15022
15312
|
kind: r.kind ?? "call",
|
|
15313
|
+
callSiteLines: r.lineNumber !== undefined ? [r.lineNumber] : [],
|
|
15023
15314
|
...(rr ? { identity: symbolIdentity(r.repoPath || defRepo, rr) } : {}),
|
|
15024
|
-
}
|
|
15315
|
+
};
|
|
15316
|
+
byCaller.set(key, row);
|
|
15317
|
+
rows.push(row);
|
|
15025
15318
|
}
|
|
15026
|
-
|
|
15319
|
+
for (const row of rows) {
|
|
15320
|
+
row.callSiteLines?.sort((a, b) => a - b);
|
|
15321
|
+
row.line = row.callSiteLines?.[0] ?? row.line;
|
|
15322
|
+
}
|
|
15323
|
+
return finish(rows, { callSiteCount });
|
|
15027
15324
|
}
|
|
15028
15325
|
case "imports_of": {
|
|
15029
15326
|
const stmt = db.query("SELECT DISTINCT toFile, kind FROM dependencies WHERE fromFile = ?");
|
|
@@ -101,15 +101,21 @@ export function decorateGraphQueryResult(value, graphState = "healthy", freshnes
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
const root = value;
|
|
104
|
-
|
|
104
|
+
// An unresolved target is not a no-match: nothing was searched for. Calling
|
|
105
|
+
// it `no-match` is what let a misspelled symbol read as proof that nothing
|
|
106
|
+
// calls it (KNODIN-28). Ambiguity is exempted for the same reason.
|
|
107
|
+
const notFound = root.targetResolution === "not-found";
|
|
108
|
+
const noMatch = hasNoMatches(root) && root.ambiguity === undefined && !notFound;
|
|
109
|
+
const state = notFound ? "target-not-found" : noMatch ? "no-match" : graphState;
|
|
105
110
|
return {
|
|
106
111
|
...root,
|
|
107
112
|
...(freshness ? { freshness } : {}),
|
|
113
|
+
...(notFound && root.status === undefined ? { status: "target-not-found" } : {}),
|
|
108
114
|
...(noMatch && root.status === undefined ? { status: "no-match" } : {}),
|
|
109
115
|
availability: {
|
|
110
|
-
state
|
|
116
|
+
state,
|
|
111
117
|
graphState,
|
|
112
|
-
outcome: noMatch ? "no-match" : "matches",
|
|
118
|
+
outcome: notFound ? "target-not-found" : noMatch ? "no-match" : "matches",
|
|
113
119
|
},
|
|
114
120
|
};
|
|
115
121
|
}
|
package/dist/src/init.js
CHANGED
|
@@ -1458,6 +1458,87 @@ export async function initializeRepository(repo, options) {
|
|
|
1458
1458
|
lifecycleLease.release();
|
|
1459
1459
|
}
|
|
1460
1460
|
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Rewrite lifecycle routing — the managed Git hooks and the background refresh
|
|
1463
|
+
* script — without reading or writing a single line of agent configuration.
|
|
1464
|
+
*
|
|
1465
|
+
* This exists because a team-integrated repository whose hooks had died had no
|
|
1466
|
+
* safe exit (KNODIN-31). `repair` fixes graph rows and leaves the dead indexer
|
|
1467
|
+
* dead, so the schema drifts again; `init` rewrites tracked `AGENTS.md` /
|
|
1468
|
+
* `GEMINI.md` / `.mcp.json`, which is churn every teammate diffs on a branch
|
|
1469
|
+
* that has nothing to do with it; and `configure --scope personal` strips the
|
|
1470
|
+
* shared block for everyone else on that branch. The one clean option,
|
|
1471
|
+
* `init --scope personal`, is precisely the one refused in that state.
|
|
1472
|
+
*
|
|
1473
|
+
* So fault and remedy are matched in scope: the fault is in the hooks'
|
|
1474
|
+
* contents, and this rewrites the hooks' contents. It deliberately does not
|
|
1475
|
+
* index, does not touch graph rows, and never enters the `configureIntegration`
|
|
1476
|
+
* block — a broken hook should not be able to change a scope preference. Run
|
|
1477
|
+
* plain `knodin repair` alongside it when graph content is also damaged.
|
|
1478
|
+
*/
|
|
1479
|
+
export async function repairLifecycleRouting(repoPath, options) {
|
|
1480
|
+
const resolvedRepo = path.resolve(repoPath);
|
|
1481
|
+
if (runGit(resolvedRepo, ["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
|
|
1482
|
+
throw new Error(`knodin repair --lifecycle: not a Git worktree: ${resolvedRepo}`);
|
|
1483
|
+
}
|
|
1484
|
+
const mirror = lookupMirror(resolvedRepo);
|
|
1485
|
+
if (mirror) {
|
|
1486
|
+
throw new Error(`knodin repair --lifecycle: ${resolvedRepo} is a read-only mirror of ${mirror.url}. Mirrors carry no lifecycle hooks by design, so there is no routing here to repair.`);
|
|
1487
|
+
}
|
|
1488
|
+
if (!path.isAbsolute(options.command[0])) {
|
|
1489
|
+
throw new Error("knodin repair --lifecycle: packaged runtime command must be an absolute path");
|
|
1490
|
+
}
|
|
1491
|
+
const lifecycleLease = acquireRepairLease(resolvedRepo, "init");
|
|
1492
|
+
try {
|
|
1493
|
+
const knodinHooksDir = path.join(resolvedRepo, ".knodin", "hooks");
|
|
1494
|
+
const hookInstallation = await prepareHookInstallation(resolvedRepo);
|
|
1495
|
+
const hooksDir = hookInstallation.hooksDir;
|
|
1496
|
+
await Promise.all([
|
|
1497
|
+
fs.promises.mkdir(knodinHooksDir, { recursive: true }),
|
|
1498
|
+
fs.promises.mkdir(hooksDir, { recursive: true }),
|
|
1499
|
+
]);
|
|
1500
|
+
const backgroundPath = path.join(knodinHooksDir, "background-index.sh");
|
|
1501
|
+
await fs.promises.writeFile(backgroundPath, backgroundScript(options.command), {
|
|
1502
|
+
encoding: "utf-8",
|
|
1503
|
+
mode: 0o755,
|
|
1504
|
+
});
|
|
1505
|
+
await fs.promises.chmod(backgroundPath, 0o755);
|
|
1506
|
+
const hookManagerIntegration = installHookManagerIntegration(resolvedRepo);
|
|
1507
|
+
for (const hookName of HOOK_NAMES) {
|
|
1508
|
+
let preservedOriginal;
|
|
1509
|
+
if (hookInstallation.trackedHooksDir) {
|
|
1510
|
+
const relativeHook = `${hookInstallation.trackedHooksDir}/${hookName}`;
|
|
1511
|
+
const sourceHook = path.join(resolvedRepo, relativeHook);
|
|
1512
|
+
try {
|
|
1513
|
+
const stat = await fs.promises.stat(sourceHook);
|
|
1514
|
+
preservedOriginal =
|
|
1515
|
+
stat.isFile() && (stat.mode & 0o111) !== 0 ? `repo:${relativeHook}` : null;
|
|
1516
|
+
}
|
|
1517
|
+
catch {
|
|
1518
|
+
preservedOriginal = null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
await installManagedHook(hooksDir, hookName, preservedOriginal);
|
|
1522
|
+
}
|
|
1523
|
+
for (const entry of await fs.promises.readdir(hooksDir, { withFileTypes: true })) {
|
|
1524
|
+
if (entry.isFile())
|
|
1525
|
+
await preventLefthookAutoInstall(path.join(hooksDir, entry.name));
|
|
1526
|
+
}
|
|
1527
|
+
const lifecycleRefresh = drainQueuedLifecycleEvents(resolvedRepo, backgroundPath, lifecycleLease.token);
|
|
1528
|
+
await fs.promises.rm(path.join(knodinHooksDir, HOOK_FAILURE_FILE), { force: true });
|
|
1529
|
+
return {
|
|
1530
|
+
backgroundIndexer: ".knodin/hooks/background-index.sh",
|
|
1531
|
+
hooksDirectory: path.relative(resolvedRepo, hooksDir) || ".",
|
|
1532
|
+
gitHooks: [...HOOK_NAMES],
|
|
1533
|
+
hookManagerIntegration,
|
|
1534
|
+
lifecycleRefresh,
|
|
1535
|
+
configurationChanges: [],
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
finally {
|
|
1539
|
+
lifecycleLease.release();
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1461
1542
|
async function matchesManagedFile(filePath, expected, executable) {
|
|
1462
1543
|
try {
|
|
1463
1544
|
const [content, stat] = await Promise.all([
|
|
@@ -169,6 +169,41 @@ function recordedInterpreterIfMissing(backgroundScriptPath) {
|
|
|
169
169
|
// wrong and the next environment without that fallback breaks silently.
|
|
170
170
|
return recorded;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The interpreter a pre-fallback generated script pins, when it pins one.
|
|
174
|
+
*
|
|
175
|
+
* Scripts written before the `KNODIN_NODE` fallback (EASFDC-8497) invoke an
|
|
176
|
+
* absolute interpreter path with nothing to fall back to. Homebrew deletes its
|
|
177
|
+
* Cellar directory on upgrade, so `.../Cellar/node/<version>/bin/node`
|
|
178
|
+
* evaporates and every background refresh afterwards dies with exit 127 — in
|
|
179
|
+
* the background, where the only trace is a log file nobody reads. The graph
|
|
180
|
+
* then drifts until someone happens to run `status`. Observed across three
|
|
181
|
+
* repositories on one machine from a single `brew upgrade` (KNODIN-32), so the
|
|
182
|
+
* blast radius is every checkout initialized before the fix, not one.
|
|
183
|
+
*
|
|
184
|
+
* `recordedInterpreterIfMissing` cannot see this: it keys off a `KNODIN_NODE=`
|
|
185
|
+
* line, and these scripts have none, so the most dangerous shape reads as
|
|
186
|
+
* healthy. Detection is the absence of that line in a script that is otherwise
|
|
187
|
+
* recognisably ours.
|
|
188
|
+
*
|
|
189
|
+
* Returns null when the script is current, unreadable, or shaped unfamiliarly.
|
|
190
|
+
* A script we cannot parse is not evidence of a pinned interpreter, and
|
|
191
|
+
* guessing would invent a failure rather than report one.
|
|
192
|
+
*/
|
|
193
|
+
function pinnedInterpreterWithoutFallback(backgroundScriptPath) {
|
|
194
|
+
let contents;
|
|
195
|
+
try {
|
|
196
|
+
contents = fs.readFileSync(backgroundScriptPath, "utf8");
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (/^KNODIN_NODE=/m.test(contents))
|
|
202
|
+
return null;
|
|
203
|
+
if (!contents.includes("hook-refresh"))
|
|
204
|
+
return null;
|
|
205
|
+
return /'(\/[^']*\/bin\/node)'/.exec(contents)?.[1] ?? null;
|
|
206
|
+
}
|
|
172
207
|
function readRefreshFailure(repo) {
|
|
173
208
|
const failurePath = path.join(repo, ".knodin", "hooks", HOOK_FAILURE_FILE);
|
|
174
209
|
if (!fs.existsSync(failurePath))
|
|
@@ -253,7 +288,15 @@ export function inspectLifecycleHealth(repoPath) {
|
|
|
253
288
|
issues.push("background indexer is missing or not executable");
|
|
254
289
|
const staleInterpreter = backgroundReady ? recordedInterpreterIfMissing(background) : null;
|
|
255
290
|
if (staleInterpreter)
|
|
256
|
-
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin
|
|
291
|
+
issues.push(`background indexer records a Node interpreter that no longer exists (${staleInterpreter}); run \`knodin repair --lifecycle\` to rewrite the hook`);
|
|
292
|
+
// Reported even while that interpreter still exists. The failure is latent
|
|
293
|
+
// by construction — the script works right up until the next `brew upgrade`
|
|
294
|
+
// deletes the path out from under it, and then fails silently in the
|
|
295
|
+
// background. Waiting for the breakage would mean reporting it only after
|
|
296
|
+
// the graph had already drifted (KNODIN-32).
|
|
297
|
+
const pinnedInterpreter = backgroundReady && !staleInterpreter ? pinnedInterpreterWithoutFallback(background) : null;
|
|
298
|
+
if (pinnedInterpreter)
|
|
299
|
+
issues.push(`background indexer predates the interpreter fallback and pins ${pinnedInterpreter} with no alternative; a Node upgrade will break refresh silently — run \`knodin repair --lifecycle\` to rewrite the hook`);
|
|
257
300
|
const lastError = readRefreshFailure(repo);
|
|
258
301
|
if (lastError)
|
|
259
302
|
issues.push(`${lastError} See .knodin/indexer.log for details`);
|
|
@@ -299,7 +342,7 @@ export function attachLifecycleHealth(repo, graph) {
|
|
|
299
342
|
},
|
|
300
343
|
repairSteps: [
|
|
301
344
|
...graph.repairSteps,
|
|
302
|
-
"Run `knodin
|
|
345
|
+
"Run `knodin repair --lifecycle` to restore managed lifecycle hooks without touching agent configuration.",
|
|
303
346
|
"Run `knodin status` again to verify graph and refresh-path health.",
|
|
304
347
|
],
|
|
305
348
|
lifecycle,
|
|
@@ -312,7 +355,7 @@ export function attachRepairLifecycle(repo, result) {
|
|
|
312
355
|
...result,
|
|
313
356
|
lifecycle,
|
|
314
357
|
nextSteps: lifecycle.status === "degraded"
|
|
315
|
-
? ["Run `knodin
|
|
358
|
+
? ["Run `knodin repair --lifecycle` to restore lifecycle routing, then `knodin status`."]
|
|
316
359
|
: [],
|
|
317
360
|
};
|
|
318
361
|
}
|
|
@@ -23,6 +23,7 @@ export function parseRepairCliArgs(args) {
|
|
|
23
23
|
let json = false;
|
|
24
24
|
let jsonl = false;
|
|
25
25
|
let plan = false;
|
|
26
|
+
let lifecycle = false;
|
|
26
27
|
const seen = new Set();
|
|
27
28
|
const markSeen = (flag) => {
|
|
28
29
|
if (seen.has(flag))
|
|
@@ -42,6 +43,11 @@ export function parseRepairCliArgs(args) {
|
|
|
42
43
|
plan = true;
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
46
|
+
if (argument === "--lifecycle") {
|
|
47
|
+
markSeen(argument);
|
|
48
|
+
lifecycle = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
45
51
|
if (argument === "--json" || argument === "--jsonl") {
|
|
46
52
|
markSeen(argument);
|
|
47
53
|
if (argument === "--json")
|
|
@@ -86,12 +92,18 @@ export function parseRepairCliArgs(args) {
|
|
|
86
92
|
}
|
|
87
93
|
if (json && jsonl)
|
|
88
94
|
throw new Error("knodin repair: --json and --jsonl are mutually exclusive");
|
|
95
|
+
// `--plan` explains what a graph repair would do; a lifecycle repair does not
|
|
96
|
+
// touch the graph, so there is nothing for it to explain. Refusing beats
|
|
97
|
+
// printing a graph plan for a run that would not carry it out.
|
|
98
|
+
if (plan && lifecycle)
|
|
99
|
+
throw new Error("knodin repair: --plan and --lifecycle are mutually exclusive");
|
|
89
100
|
return {
|
|
90
101
|
progress,
|
|
91
102
|
progressExplicit,
|
|
92
103
|
progressIntervalMs,
|
|
93
104
|
output: jsonl ? "jsonl" : json ? "json" : "human",
|
|
94
105
|
plan,
|
|
106
|
+
lifecycle,
|
|
95
107
|
};
|
|
96
108
|
}
|
|
97
109
|
export function createRepairPlan(health) {
|
|
@@ -1052,6 +1052,22 @@ function validateKnodinArgs(args) {
|
|
|
1052
1052
|
if (args.client !== undefined &&
|
|
1053
1053
|
!["claude", "codex", "gemini", "copilot", "antigravity"].includes(args.client))
|
|
1054
1054
|
throw new Error("knodin: invalid client");
|
|
1055
|
+
// `auditRange`/`auditBase`/`auditHead` select a range of pull requests for
|
|
1056
|
+
// `prs`, and nothing else consumes them. `review` used to accept them and
|
|
1057
|
+
// silently review HEAD~1..HEAD instead, returning `base: "HEAD~1"` under a
|
|
1058
|
+
// full `staleness: fresh` envelope — so it did not merely fail to answer the
|
|
1059
|
+
// question asked, it confidently answered a different one, and a caller who
|
|
1060
|
+
// did not inspect `base` would read the result as authoritative for their
|
|
1061
|
+
// range (KNODIN-27). An unused-parameter error is always better than that.
|
|
1062
|
+
//
|
|
1063
|
+
// The review equivalent is `diffScope: "compare"` with `from`/`toRevision`,
|
|
1064
|
+
// which is what the CLI's `--scope compare --from X --to Y` maps to.
|
|
1065
|
+
const auditOnly = ["auditRange", "auditBase", "auditHead"].filter((name) => args[name] !== undefined);
|
|
1066
|
+
if (auditOnly.length > 0 && args.operation !== "prs")
|
|
1067
|
+
throw new Error(`knodin ${args.operation}: ${auditOnly.join(", ")} ${auditOnly.length === 1 ? "is" : "are"} accepted only by \`prs\`` +
|
|
1068
|
+
(args.operation === "review"
|
|
1069
|
+
? '; to review a revision range pass diffScope: "compare" with `from` and `toRevision`'
|
|
1070
|
+
: ""));
|
|
1055
1071
|
}
|
|
1056
1072
|
function compactExplainSource(result) {
|
|
1057
1073
|
const source = result.source
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# knodin 0.12.0
|
|
2
|
+
|
|
3
|
+
Ten open defects worked as one batch, plus three more found while investigating
|
|
4
|
+
them. Nearly all are the same failure in different clothing: a result that is
|
|
5
|
+
*complete* and a result that is *empty* arriving in the same shape, so the reader
|
|
6
|
+
cannot tell which one they got. One is worse than that, and it goes first.
|
|
7
|
+
|
|
8
|
+
No schema change and no re-index. An earlier draft of this release carried both,
|
|
9
|
+
for an embedding change that measured well on the test embedder and turned out to
|
|
10
|
+
do nothing on the real model. That story is told below, because the way it was
|
|
11
|
+
nearly shipped is more instructive than the fix — and because the same mistake,
|
|
12
|
+
caught a second time, is what led to the retrieval benchmark this release adds.
|
|
13
|
+
|
|
14
|
+
## `review` answered a different question and said nothing about it
|
|
15
|
+
|
|
16
|
+
The MCP gateway accepted `auditRange`, `auditBase` and `auditHead` on `review`,
|
|
17
|
+
discarded them, and reviewed `HEAD~1..HEAD` instead. The response reported
|
|
18
|
+
`base: HEAD~1` truthfully — but carried `staleness: fresh`, `availability:
|
|
19
|
+
healthy` and a full freshness envelope, so unless the caller happened to inspect
|
|
20
|
+
`base`, it read as authoritative for the range they had asked about.
|
|
21
|
+
|
|
22
|
+
Measured against a real branch, the difference was one changed file versus
|
|
23
|
+
twenty. That is not a near-miss; it is a review of the wrong thing, presented
|
|
24
|
+
with the same confidence as a review of the right thing. It was found while
|
|
25
|
+
running a product validation, where the conclusion would otherwise have been
|
|
26
|
+
"review does not catch this defect" — a false negative manufactured entirely out
|
|
27
|
+
of discarded input, and reported as a finding.
|
|
28
|
+
|
|
29
|
+
Those three parameters belong to `prs`, and only to `prs`; the schema had always
|
|
30
|
+
said so and nothing enforced it. They are now refused on every other operation,
|
|
31
|
+
and the refusal on `review` names the parameter that would have worked
|
|
32
|
+
(`diffScope: "compare"` with `from`/`toRevision`). An unused-parameter error is
|
|
33
|
+
always better than a confident answer to an unasked question.
|
|
34
|
+
|
|
35
|
+
## `count: 0` could not tell a typo from dead code
|
|
36
|
+
|
|
37
|
+
On a healthy, fresh graph, `knodin query callers_of someMisspelling` returned
|
|
38
|
+
`count: 0`, `staleness: fresh`, exit 0 — byte-identical to the answer for a real
|
|
39
|
+
symbol that genuinely has no callers.
|
|
40
|
+
|
|
41
|
+
The uninitialized-graph case was fixed earlier and now fails loudly, which made
|
|
42
|
+
this one worse rather than better: before that fix an empty result at least felt
|
|
43
|
+
untrustworthy, and afterwards it arrived with a certificate. The freshness stamp
|
|
44
|
+
certifies the **graph**; it says nothing about whether the **target** resolved.
|
|
45
|
+
Those are separate axes and are now reported separately. A query for a symbol
|
|
46
|
+
that exists in no open repository answers `targetResolution: "not-found"`,
|
|
47
|
+
renders as `target-not-found` rather than `no-match`, and exits non-zero, since
|
|
48
|
+
a misspelling is operator error rather than a negative result. A real symbol
|
|
49
|
+
with no callers still answers `resolved` and exits 0 — that is the honest
|
|
50
|
+
negative, and it is now distinguishable from the typo.
|
|
51
|
+
|
|
52
|
+
This matters most for the symbol it is easiest to lose: a seam is *by
|
|
53
|
+
definition* a well-documented symbol with no callers yet, so `count: 0` was
|
|
54
|
+
destroying exactly the signal that would have said "use this one". One session
|
|
55
|
+
nearly hand-rolled a duplicate of a blessed seam for that reason.
|
|
56
|
+
|
|
57
|
+
## `callers_of` counted callers and read as call sites
|
|
58
|
+
|
|
59
|
+
`callers_of` answers "which symbols call X", and its output — `count: N` above
|
|
60
|
+
one line per row — reads as "N call sites, here they are". When a function calls
|
|
61
|
+
the target twice, only the first line appeared and the count was lower than the
|
|
62
|
+
number of call sites. Ground truth of four calls reported as three.
|
|
63
|
+
|
|
64
|
+
Anyone sizing an edit from that count under-sizes it. Worse, the reporting
|
|
65
|
+
session initially scored a correct answer as a bug, because cross-checking with
|
|
66
|
+
grep found a call the tool had "missed" — it had not missed it, it had merged
|
|
67
|
+
it. Each row now carries `callSiteLines` with every line for that caller, and
|
|
68
|
+
the result carries `callSiteCount` alongside `count`, which is documented as
|
|
69
|
+
counting rows. Renaming the field alone would not have been enough: it would
|
|
70
|
+
have left the reader doing arithmetic they had no data for.
|
|
71
|
+
|
|
72
|
+
## A team repo with dead hooks had no safe way out
|
|
73
|
+
|
|
74
|
+
Every exit mutated something. Plain `repair` fixed graph rows and left the
|
|
75
|
+
background indexer dead, so the schema drifted again. `init` rewrote tracked
|
|
76
|
+
`AGENTS.md`, `GEMINI.md` and `.mcp.json` — churn every teammate diffs, on a
|
|
77
|
+
branch that has nothing to do with it. `configure --scope personal` stripped the
|
|
78
|
+
shared block for everyone else. The one clean option, `init --scope personal`,
|
|
79
|
+
is precisely the one refused while tracked team integration is active.
|
|
80
|
+
|
|
81
|
+
`knodin repair --lifecycle` rewrites the managed Git hooks and the background
|
|
82
|
+
refresh script and touches nothing else — no indexing, no graph rows, and it
|
|
83
|
+
never enters the agent-configuration path at any scope. Fault and remedy now
|
|
84
|
+
match in scope: the fault is in the hooks' contents, so the hooks' contents are
|
|
85
|
+
what changes. Every remediation message that used to say "run `knodin init`" for
|
|
86
|
+
a lifecycle fault now points here instead.
|
|
87
|
+
|
|
88
|
+
This does not settle the separate question of whether a per-developer scope
|
|
89
|
+
preference should mutate team-shared tracked files. It removes the trap.
|
|
90
|
+
|
|
91
|
+
## Hooks that predate the interpreter fallback are now named before they break
|
|
92
|
+
|
|
93
|
+
Scripts generated before the `KNODIN_NODE` fallback pin an absolute Homebrew
|
|
94
|
+
Cellar interpreter with nothing to fall back to. Homebrew deletes that directory
|
|
95
|
+
on upgrade, so every background refresh afterwards dies with exit 127 — in the
|
|
96
|
+
background, where the only trace is a log file — and the graph drifts until
|
|
97
|
+
someone happens to run `status`. One `brew upgrade` did this to three
|
|
98
|
+
repositories on one machine in a day.
|
|
99
|
+
|
|
100
|
+
The existing check could not see these at all: it keys off a `KNODIN_NODE=`
|
|
101
|
+
line, and these scripts have none, so the most dangerous shape read as healthy.
|
|
102
|
+
Health now reports a script that predates the fallback **while its interpreter
|
|
103
|
+
still exists**, because the whole point is that it is a time bomb rather than an
|
|
104
|
+
already-detonated one. A script it cannot recognise is still left alone; an
|
|
105
|
+
unparseable hook is not evidence of a pinned interpreter, and guessing would
|
|
106
|
+
invent a failure rather than report one.
|
|
107
|
+
|
|
108
|
+
## Search ranks a depended-upon implementation above its own fresh duplicate
|
|
109
|
+
|
|
110
|
+
Semantic similarity alone ranked a minutes-old duplicate above the
|
|
111
|
+
implementation it duplicated. The duplicate is short and made almost entirely of
|
|
112
|
+
the query's own words; the canonical version is diluted by an actual body. The
|
|
113
|
+
searcher read their own new leaf as evidence that nothing existed.
|
|
114
|
+
|
|
115
|
+
Degree is now weighted into rank fusion, capped at about three rank positions.
|
|
116
|
+
It is deliberately a small weight, because the opposite failure is equally real
|
|
117
|
+
and lands on the *same symbols*: a seam is a documented symbol with no callers,
|
|
118
|
+
and a large centrality weight would bury seams to fix duplicates — trading one
|
|
119
|
+
silent miss for another. A connected implementation climbs past neighbours it
|
|
120
|
+
was scoring near; it does not win from anywhere. Symbols with no inbound
|
|
121
|
+
references are excluded from the centrality ranking rather than ranked last, so
|
|
122
|
+
an uncalled symbol is never penalised, it only forgoes a boost it has no
|
|
123
|
+
evidence for.
|
|
124
|
+
|
|
125
|
+
An accompanying change — bounding the source body inside the embedding budget —
|
|
126
|
+
was written, measured, justified in a comment, given a schema bump, and then
|
|
127
|
+
**reverted before release**. On the deterministic bag-of-words embedder the suite
|
|
128
|
+
runs, it raised the diluted implementation's similarity from 0.163 to 0.212. On
|
|
129
|
+
the real MiniLM model it does nothing: across two body shapes, three query
|
|
130
|
+
phrasings and both budgets, the duplicate wins all twelve times, and the cap
|
|
131
|
+
never moves the winner. Its apparent benefit came from the test fixture, which
|
|
132
|
+
emitted forty copies of one line — truncating identical lines removes a
|
|
133
|
+
redundancy bag-of-words punishes and a transformer largely ignores.
|
|
134
|
+
|
|
135
|
+
The general lesson is worth more than the change would have been: a short
|
|
136
|
+
document made almost entirely of query tokens beats a long one under cosine
|
|
137
|
+
similarity, and no reweighting of a single vector's inputs changes that. Graph
|
|
138
|
+
degree is the right remedy here precisely because it is not a similarity signal.
|
|
139
|
+
|
|
140
|
+
## Prose queries got no lexical signal at all, and now do
|
|
141
|
+
|
|
142
|
+
A query phrased as prose did not surface the symbol whose docstring restates it;
|
|
143
|
+
symbols sharing a word or two in their *identifier* won instead. The ticket
|
|
144
|
+
framed this as docstring prose being under-weighted against code in the
|
|
145
|
+
embedding. That premise turned out to be false — for the reported symbol the
|
|
146
|
+
embedded text is already about 95% docstring, and it still lost.
|
|
147
|
+
|
|
148
|
+
The actual cause was in rank fusion. The lexical channel selected the AND-matched
|
|
149
|
+
set unless every semantic score was bit-identical, which never happens under a
|
|
150
|
+
real model, so the OR set was computed on every query and discarded on every
|
|
151
|
+
query. And for a query phrased as prose the AND clause is empty by construction:
|
|
152
|
+
it demands one symbol containing every surviving query word, function words
|
|
153
|
+
included. Nothing matches, so the lexical contribution was zero for every
|
|
154
|
+
candidate and "hybrid search" was pure vector search. The docstring's BM25
|
|
155
|
+
evidence existed the whole time, in the set being thrown away.
|
|
156
|
+
|
|
157
|
+
Both operators are now fused as separate channels, the partial one at 0.75 weight
|
|
158
|
+
and truncated to its top 30. Because the AND set is a subset of the OR set by
|
|
159
|
+
construction, an exact match collects both terms and a partial match collects
|
|
160
|
+
one — so the precision bonus falls out of that containment rather than needing
|
|
161
|
+
machinery of its own. The weight buys a property worth stating: lexical evidence
|
|
162
|
+
can rescue a symbol the embedder under-ranked, and cannot resurrect one the
|
|
163
|
+
embedder judged irrelevant.
|
|
164
|
+
|
|
165
|
+
A partial match must also clear a quorum: it has to echo at least two of the
|
|
166
|
+
query's words. A single incidental token is not evidence, and this index cannot
|
|
167
|
+
tell the difference unaided — FTS5 here has no stemming and does not split
|
|
168
|
+
identifiers, so for "session authentication and token validations" the class
|
|
169
|
+
that does the work matches *nothing* (its docstring says "sessions" and
|
|
170
|
+
"validation") while a handler containing "session" matches once. Without the
|
|
171
|
+
quorum that handler outranked the authenticator, and no weight fixed it: every
|
|
172
|
+
value from 0.75 down to 0.15 flipped the same way, because the problem was which
|
|
173
|
+
symbol earned credit rather than how much.
|
|
174
|
+
|
|
175
|
+
Measured on the new benchmark below: **MRR 0.5178 → 0.754, recall@10 0.80 →
|
|
176
|
+
0.925**, with 19 of 40 queries improving, 4 regressing and 17 unchanged — a
|
|
177
|
+
two-sided exact sign test at p = 0.0026. Dev and test halves moved together,
|
|
178
|
+
which is what you want when nothing was tuned against the labels. The largest
|
|
179
|
+
gains were symbols that had been effectively unreachable: rank 24 → 1, 28 → 3,
|
|
180
|
+
14 → 2.
|
|
181
|
+
|
|
182
|
+
## Doc comments were being lost, and misattributed
|
|
183
|
+
|
|
184
|
+
Two defects in how a doc comment is matched to the thing it documents. Both fed
|
|
185
|
+
the embedding input and the FTS `summary` column, so both corrupted retrieval
|
|
186
|
+
directly — and they point in opposite directions, which is why neither showed up
|
|
187
|
+
as an obvious symptom.
|
|
188
|
+
|
|
189
|
+
**Multi-line `//` comments kept only their last line.** tree-sitter gives each
|
|
190
|
+
`//` line its own node and the extractor read a single previous sibling, so the
|
|
191
|
+
loop meant to rejoin lines could never fire for this style. The first line of a
|
|
192
|
+
doc comment is usually the sentence saying what the thing is for — exactly what a
|
|
193
|
+
capability question matches on — so this silently removed the most useful text
|
|
194
|
+
and left a grammatical fragment that still looked like a well-formed summary.
|
|
195
|
+
Block comments are one node and were never affected, which is why the damage
|
|
196
|
+
stayed invisible in codebases that prefer them.
|
|
197
|
+
|
|
198
|
+
**And a symbol with no doc comment adopted its neighbour's.** The sibling walk
|
|
199
|
+
stepped over as many as five non-comment nodes hunting for a comment, so prose
|
|
200
|
+
written about one symbol was embedded against another. Measured on this
|
|
201
|
+
repository before the fix: **857 of 1,329 documented symbols under `src/` — 64% —
|
|
202
|
+
shared their summary text with another symbol**, and spot checks showed plain
|
|
203
|
+
misattribution rather than genuine repetition, including an interface carrying a
|
|
204
|
+
neighbouring function's docstring. After the fix, 718 symbols are documented and
|
|
205
|
+
**none** share text.
|
|
206
|
+
|
|
207
|
+
That second one is the worse of the pair. Absent prose merely loses a hit;
|
|
208
|
+
misattributed prose manufactures a confident wrong one.
|
|
209
|
+
|
|
210
|
+
A related gap is recorded but not fixed: a decorated declaration still never
|
|
211
|
+
receives its doc comment, because the decorator is a sibling of the declaration
|
|
212
|
+
but a child of the exporting statement. It was verified to predate both fixes and
|
|
213
|
+
is pinned by a characterization test.
|
|
214
|
+
|
|
215
|
+
## A benchmark for retrieval quality, and why it could not be a test
|
|
216
|
+
|
|
217
|
+
`npm run bench:c103` measures whether a capability question phrased as prose
|
|
218
|
+
finds the symbol implementing it, through the real ranker and the real embedding
|
|
219
|
+
model, over knodin's own source at a pinned commit. Forty hand-written queries,
|
|
220
|
+
15 dev / 25 test, MRR primary.
|
|
221
|
+
|
|
222
|
+
It exists as an opt-in benchmark rather than a spec because the fast suite
|
|
223
|
+
installs a deterministic bag-of-words embedder, and that embedder produces the
|
|
224
|
+
**opposite** answer on exactly this case: an identifier match beats a docstring
|
|
225
|
+
match on it, while the docstring wins by 2.75x on the real model. Two ranking
|
|
226
|
+
assertions written for this release were deleted for pinning that inversion. A
|
|
227
|
+
test that asserts the reverse of production is worse than no test — it fails
|
|
228
|
+
changes that help users and passes changes that hurt them.
|
|
229
|
+
|
|
230
|
+
So the fast suite now asserts only ranking properties no choice of embedder can
|
|
231
|
+
change, which in practice means rank arithmetic over graph structure, and
|
|
232
|
+
retrieval quality is measured where it can be measured honestly. The existing
|
|
233
|
+
`c86` evaluation could not host this: it ranks raw text by exact cosine, never
|
|
234
|
+
touching fusion or the ranking weights, and is saturated at recall@5 = 1.0.
|
|
235
|
+
|
|
236
|
+
## What is not fixed
|
|
237
|
+
|
|
238
|
+
**Mean-pooling dilution inside long docstrings.** Only one sentence of a
|
|
239
|
+
multi-sentence docstring may be on topic, and averaged over the whole comment it
|
|
240
|
+
becomes a minority of the vector while a short, entirely-on-topic competitor does
|
|
241
|
+
not. Splitting docstrings into sentence chunks and max-pooling was prototyped
|
|
242
|
+
against the real model and moves the reported symbol from rank 4 of 5 to rank 2 —
|
|
243
|
+
but it needs roughly 1.5x the vectors, a composite primary key, and a re-embed
|
|
244
|
+
migration.
|
|
245
|
+
|
|
246
|
+
It is deliberately **not** in this release. The lexical fix above addressed the
|
|
247
|
+
same failure for no migration at all, and the benchmark now says prose retrieval
|
|
248
|
+
sits at MRR 0.754 with recall@10 of 0.95. Chunking should be re-measured against
|
|
249
|
+
that new baseline before anyone pays for it — which is the whole reason the
|
|
250
|
+
benchmark was built first.
|
|
251
|
+
|
|
252
|
+
Worth stating plainly: the benchmark corpus is knodin's own source, and the
|
|
253
|
+
defect was reported against a different codebase. The improvement is real and
|
|
254
|
+
significant here; it has not been demonstrated there.
|
|
255
|
+
|
|
256
|
+
## Smaller things
|
|
257
|
+
|
|
258
|
+
`repair` now seeds from an indexed sibling worktree when there is **no** database
|
|
259
|
+
at all, reflink-copying a baseline and reconciling only the differing paths
|
|
260
|
+
instead of rebuilding the corpus from scratch. A database that exists but is
|
|
261
|
+
damaged is deliberately excluded — that is the case `repair` exists for, and
|
|
262
|
+
seeding it would replace a verifiable local rebuild with another checkout's
|
|
263
|
+
state.
|
|
264
|
+
|
|
265
|
+
`readGitHeadFast` is deleted. Its caller invoked it only when `.git` was absent,
|
|
266
|
+
which was its own first bail-out, so the two conditions were exactly
|
|
267
|
+
anti-correlated and the function could only ever return `null` — making the
|
|
268
|
+
`&& headUnchanged` term it fed inert. Coverage confirmed zero hits across the
|
|
269
|
+
whole suite. Git history settled which side was the bug: the fast HEAD read
|
|
270
|
+
landed first, and the later `lease = 0` for git-backed repositories superseded
|
|
271
|
+
it.
|
|
272
|
+
|
|
273
|
+
Two timing-sensitive specs no longer produce false pre-push failures. Both had
|
|
274
|
+
been widened only for instrumented coverage runs, on the theory that
|
|
275
|
+
instrumentation caused the contention. It does not — the failures reproduce on
|
|
276
|
+
ordinary uninstrumented runs under host load — and the uninstrumented run is
|
|
277
|
+
exactly the one developers gate on. The budgets are now unconditional, matching
|
|
278
|
+
the correction already recorded in `SUBPROCESS_BUDGET_MS`. A timeout that fires
|
|
279
|
+
on a loaded machine reports a failure that did not happen, and a gate whose
|
|
280
|
+
failures have to be re-run in isolation before they can be believed is not
|
|
281
|
+
doing its job.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "knodin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"knodin": {
|
|
5
5
|
"compatibility": "breaking"
|
|
6
6
|
},
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"docs/releases/0.10.7.md",
|
|
65
65
|
"docs/releases/0.10.8.md",
|
|
66
66
|
"docs/releases/0.11.0.md",
|
|
67
|
+
"docs/releases/0.12.0.md",
|
|
67
68
|
"docs/releases/0.3.0.md",
|
|
68
69
|
"docs/releases/0.4.0.md",
|
|
69
70
|
"docs/releases/0.4.1.md",
|
|
@@ -122,6 +123,7 @@
|
|
|
122
123
|
"check:hygiene": "node scripts/check-repository-hygiene.mjs",
|
|
123
124
|
"check:roadmap": "tsx scripts/check-roadmap.ts",
|
|
124
125
|
"verify:c79": "tsx scripts/verify-c79.ts",
|
|
126
|
+
"bench:c103": "tsx benchmarks/evaluations/c103-prose-retrieval/runner.ts",
|
|
125
127
|
"bench:c80": "tsx benchmarks/evaluations/c80-structural-routing/runner.ts",
|
|
126
128
|
"verify:c80": "tsx scripts/verify-c80.ts",
|
|
127
129
|
"bench:c81": "tsx benchmarks/evaluations/c81-powered-benchmark/runner.ts",
|