knodin 0.12.0 → 0.12.1

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.
@@ -1728,6 +1728,34 @@ function createLanguageMemo() {
1728
1728
  * `isLineComment` below, which also requires the `//` prefix.
1729
1729
  */
1730
1730
  const LINE_COMMENT_NODE_TYPES = new Set(["comment", "line_comment", "hash_comment"]);
1731
+ /**
1732
+ * Repo-relative files that reference `symbol` and are test files, read from raw
1733
+ * reference rows rather than from resolved callers.
1734
+ *
1735
+ * Resolved callers cannot answer this. A call inside an anonymous callback --
1736
+ * `it("...", () => { target() })`, which is how essentially all test code is
1737
+ * written -- has no enclosing NAMED symbol, so it is stored with a null
1738
+ * `callerSymbol` and every caller-resolution path drops it. Measured on this
1739
+ * repository: 29,158 of 31,467 references from test files (92.7%) have a null
1740
+ * caller, against 390 of 18,399 (2.1%) from production files. So a coverage
1741
+ * question answered from callers is roughly 93% blind in exactly the files it is
1742
+ * asking about, and reported `untested: true` for 642 symbols that have tests
1743
+ * (KNODIN-36).
1744
+ *
1745
+ * The reference row itself carries `callerFile`, which is all this question
1746
+ * needs. Attributing the call to the file instead would be the other repair, and
1747
+ * `call-graph.spec.ts` deliberately forbids it: a file path must never appear as
1748
+ * a caller. So the fix belongs here, at the question, not in the graph.
1749
+ *
1750
+ * Shared by `explain`'s `untested`, `tests_for`, and `detectKnowledgeGaps` so the
1751
+ * definition of "covered by a test" cannot drift between them.
1752
+ */
1753
+ function testCallerFilesFor(db, symbol, relativeFile) {
1754
+ const statement = db.query('SELECT DISTINCT callerFile FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL)');
1755
+ const rows = statement.all(symbol, relativeFile);
1756
+ statement.finalize();
1757
+ return rows.map((row) => row.callerFile).filter((file) => isTestFilePath(file));
1758
+ }
1731
1759
  /** Clean up and format comment/docstring blocks in JavaScript/TypeScript. */
1732
1760
  function getPrecedingComment(node) {
1733
1761
  let target = node;
@@ -12449,7 +12477,20 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
12449
12477
  })
12450
12478
  : [];
12451
12479
  const allBlastFiles = Array.from(new Set(allCallers.map((c) => c.filePath)));
12452
- const untested = !allCallers.some((c) => isTestFilePath(c.filePath));
12480
+ // Asked of raw reference rows, not of resolved callers: a test that calls
12481
+ // this symbol from inside an anonymous `it(...)` callback has no caller
12482
+ // symbol and is invisible to `allCallers` (KNODIN-36). `untested: true`
12483
+ // is a positive assertion with no zero-count to invite doubt, so it has
12484
+ // to be answered from the evidence that actually exists.
12485
+ // Three values, not two. Without the defining repository's database this
12486
+ // question cannot be answered, and the previous fallback -- guessing from
12487
+ // resolved callers -- is precisely the collapse that made this field
12488
+ // wrong in the first place: it turned "cannot tell" into a confident
12489
+ // `true`. `untested` is now simply absent when the evidence is absent,
12490
+ // so a caller reading it gets a fact or nothing, never a guess.
12491
+ const untested = targetDb
12492
+ ? testCallerFilesFor(targetDb, primaryDef.name, primaryDef.filePath).length === 0
12493
+ : undefined;
12453
12494
  const minimal = detailLevel === "minimal";
12454
12495
  const cap = minimal ? 25 : 200;
12455
12496
  const truncated = allCallers.length > cap ||
@@ -15459,6 +15500,60 @@ export function createEngine(openPolicy = DEFAULT_ENGINE_OPEN_POLICY) {
15459
15500
  seen.add(key);
15460
15501
  rows.push({ symbol: r.symbol, file, line: r.lineNumber });
15461
15502
  }
15503
+ // Resolved callers miss almost every real test. A call inside an
15504
+ // anonymous `it(...)` callback has no enclosing named symbol, so it
15505
+ // carries a null `callerSymbol` and `findCallersFederated` drops it
15506
+ // (92.7% of this repository's test-file references, against 2.1% of
15507
+ // production ones). Reading the raw rows recovers them (KNODIN-36).
15508
+ //
15509
+ // These rows carry no caller name because there genuinely is none,
15510
+ // and inventing one -- the file path, or the enclosing `describe`
15511
+ // title -- would put a non-symbol in a `symbol` field that
15512
+ // `call-graph.spec.ts` explicitly forbids. `symbol` is therefore
15513
+ // omitted and the file and line carry the answer, which is what the
15514
+ // question asked for anyway: which test covers this.
15515
+ for (const repo of allRepos) {
15516
+ if (defFile && repo.path !== defRepo)
15517
+ continue;
15518
+ // MIN(line) grouped by file, not DISTINCT(file, line). The rows are
15519
+ // deduped by file below, so a plain DISTINCT would leave SQLite free
15520
+ // to hand back whichever line its query plan reached first when a
15521
+ // test file references the symbol more than once -- a value that can
15522
+ // change between runs and plans, which is a flaky test waiting to be
15523
+ // written. The first reference is both stable and the more useful
15524
+ // one to report.
15525
+ // With no defining file the file filter is dropped rather than bound
15526
+ // to "", which would degrade the clause to `calleeFile IS NULL` and
15527
+ // silently discard every reference that does carry a file -- turning
15528
+ // "I do not know where this is defined" into "it is defined nowhere",
15529
+ // and under-reporting coverage for exactly the symbols we know least
15530
+ // about (ADR 008). findCallersFederated omits the filter in the same
15531
+ // situation; these two must not disagree.
15532
+ const anonymous = defFile
15533
+ ? (() => {
15534
+ const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND (calleeFile = ? OR calleeFile IS NULL) AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
15535
+ const out = stmt.all(target, defFile);
15536
+ stmt.finalize();
15537
+ return out;
15538
+ })()
15539
+ : (() => {
15540
+ const stmt = repo.db.query('SELECT callerFile, MIN(line) AS line FROM "references" WHERE calleeSymbol = ? AND callerSymbol IS NULL GROUP BY callerFile ORDER BY callerFile');
15541
+ const out = stmt.all(target);
15542
+ stmt.finalize();
15543
+ return out;
15544
+ })();
15545
+ for (const row of anonymous) {
15546
+ const file = formatPath(repo.path, row.callerFile);
15547
+ if (!isTestFilePath(file))
15548
+ continue;
15549
+ const key = `|${file}`;
15550
+ if (seen.has(key))
15551
+ continue;
15552
+ seen.add(key);
15553
+ rows.push({ file, line: row.line });
15554
+ }
15555
+ }
15556
+ rows.sort((a, b) => (a.file ?? "").localeCompare(b.file ?? "") || (a.line ?? 0) - (b.line ?? 0));
15462
15557
  return finish(rows);
15463
15558
  }
15464
15559
  case "rename_preview": {
@@ -7,6 +7,17 @@ import { resolveDbPath } from "./engine/state-paths.js";
7
7
  import { summarizeAdoption } from "./session-telemetry.js";
8
8
  export const TELEMETRY_TOKENIZER = "gpt-tokenizer@3.4.0:o200k_base";
9
9
  export const countOutputTokens = (value) => encode(value).length;
10
+ /** Retrieval stages the engine can report; anything else is dropped. */
11
+ const RETRIEVAL_ROUTE_STEPS = new Set([
12
+ "stable-identity",
13
+ "exact-name",
14
+ "exact-path",
15
+ "lexical",
16
+ "bounded-graph",
17
+ "embeddings",
18
+ ]);
19
+ /** Embedding-coverage states the engine can report; anything else is dropped. */
20
+ const SEMANTIC_READINESS_STATES = new Set(["ready", "partial", "absent"]);
10
21
  export const DEFAULT_TELEMETRY_RETENTION_DAYS = 30;
11
22
  const DEFAULT_INPUT = ".knodin-telemetry.jsonl";
12
23
  function lineCount(value) {
@@ -105,6 +116,28 @@ export function measureOutput(options) {
105
116
  const status = typeof options.output.status === "string" ? options.output.status : undefined;
106
117
  const availability = options.output.availability;
107
118
  const freshness = options.output.freshness;
119
+ // Retrieval-quality fields, lifted so a repository where knodin underperforms
120
+ // is diagnosable from a shared bundle instead of from a hand-written field
121
+ // report (KNODIN-37). All four already travel in the response and all four are
122
+ // source-free metadata, so this persists nothing the privacy contract forbids.
123
+ //
124
+ // `semanticReadiness` is the most diagnostic of them: a partially embedded
125
+ // index returns confident SHORT answers, which is indistinguishable from the
126
+ // query being wrong unless the readiness is recorded beside the result.
127
+ const retrieval = options.output.retrieval;
128
+ const retrievalRoute = Array.isArray(retrieval?.route)
129
+ ? retrieval.route.filter((step) => typeof step === "string")
130
+ : undefined;
131
+ const embeddingsUsed = typeof retrieval?.embeddingsUsed === "boolean" ? retrieval.embeddingsUsed : undefined;
132
+ const semanticReadiness = typeof options.output.semanticReadiness === "string"
133
+ ? options.output.semanticReadiness
134
+ : undefined;
135
+ const results = options.output.results;
136
+ const resultCount = Array.isArray(results)
137
+ ? results.length
138
+ : typeof options.output.count === "number"
139
+ ? options.output.count
140
+ : undefined;
108
141
  const fidelity = options.output.fidelity;
109
142
  const database = resolveDbPath(fs.realpathSync(options.repo));
110
143
  const latencyMs = Math.round((performance.now() - options.startedAt) * 100) / 100;
@@ -137,6 +170,15 @@ export function measureOutput(options) {
137
170
  compressionFidelity,
138
171
  confidence: baseline ? "measured" : "unavailable",
139
172
  truncated: options.truncated,
173
+ // Non-empty or absent, never `[]`. An empty route asserts that no retrieval
174
+ // stage ran, which is a claim about the query; a route that is unknown or
175
+ // wholly unreadable is the absence of one. The sanitizer downstream makes
176
+ // the same distinction, and a record that collapsed it here would hand the
177
+ // sanitizer a falsehood to faithfully preserve (ADR 008).
178
+ ...(retrievalRoute && retrievalRoute.length > 0 ? { retrievalRoute } : {}),
179
+ ...(embeddingsUsed !== undefined ? { embeddingsUsed } : {}),
180
+ ...(semanticReadiness ? { semanticReadiness } : {}),
181
+ ...(resultCount !== undefined ? { resultCount } : {}),
140
182
  detailMode: options.detailMode,
141
183
  tokenizer: TELEMETRY_TOKENIZER,
142
184
  schemaTokens: options.schemaTokens,
@@ -232,7 +274,12 @@ export function writeTelemetryReport(repoPath, records, outputPath = ".knodin/te
232
274
  sessionEvents: sessionEvents.length,
233
275
  };
234
276
  }
235
- function sanitizeTelemetryRecord(record) {
277
+ /**
278
+ * Exported for the retrieval-quality spec: the allowlist is the guarantee that
279
+ * an unexpected field never reaches a shareable file, and that guarantee is
280
+ * only worth anything if it is tested directly rather than through a caller.
281
+ */
282
+ export function sanitizeTelemetryRecord(record) {
236
283
  return {
237
284
  schemaVersion: record.schemaVersion,
238
285
  at: record.at,
@@ -252,6 +299,38 @@ function sanitizeTelemetryRecord(record) {
252
299
  compressionFidelity: record.compressionFidelity,
253
300
  confidence: record.confidence,
254
301
  truncated: record.truncated,
302
+ // Retrieval-quality fields (KNODIN-37). Validated rather than copied: the
303
+ // allowlist is the reason an unexpected field can never reach the file, and
304
+ // a new entry that trusts its input would quietly defeat that. Route steps
305
+ // are constrained to a known vocabulary and the array is bounded, so a
306
+ // malformed response cannot smuggle arbitrary strings into a shareable
307
+ // record.
308
+ // Omitted when nothing survives the allowlist, not emitted as `[]`. An
309
+ // empty array reads as "no retrieval stage ran", which is a fact; a route
310
+ // whose every step was unrecognised is the absence of a readable fact, and
311
+ // the two must not share a representation (ADR 008). Caught in review of
312
+ // this very change, which is the telemetry meant to diagnose exactly this
313
+ // class of confusion.
314
+ ...(() => {
315
+ if (!Array.isArray(record.retrievalRoute))
316
+ return {};
317
+ const route = record.retrievalRoute
318
+ .filter((step) => RETRIEVAL_ROUTE_STEPS.has(step))
319
+ .slice(0, 8);
320
+ return route.length > 0 ? { retrievalRoute: route } : {};
321
+ })(),
322
+ ...(typeof record.embeddingsUsed === "boolean"
323
+ ? { embeddingsUsed: record.embeddingsUsed }
324
+ : {}),
325
+ ...(typeof record.semanticReadiness === "string" &&
326
+ SEMANTIC_READINESS_STATES.has(record.semanticReadiness)
327
+ ? { semanticReadiness: record.semanticReadiness }
328
+ : {}),
329
+ ...(typeof record.resultCount === "number" &&
330
+ Number.isInteger(record.resultCount) &&
331
+ record.resultCount >= 0
332
+ ? { resultCount: record.resultCount }
333
+ : {}),
255
334
  detailMode: record.detailMode,
256
335
  tokenizer: record.tokenizer,
257
336
  schemaTokens: record.schemaTokens,
@@ -0,0 +1,98 @@
1
+ # knodin 0.12.1
2
+
3
+ A P1 correctness fix, a field made honest, and telemetry that lets a badly
4
+ performing repository explain itself. No schema change and no re-index.
5
+
6
+ ## `explain` said "untested" about symbols that have tests
7
+
8
+ `explain` reported `untested: true`, and `tests_for` returned `count: 0`, for
9
+ symbols with dedicated unit tests — on a healthy, fresh graph.
10
+
11
+ Test code lives almost entirely inside anonymous callbacks passed to `it(...)`
12
+ and `describe(...)`. Such a call has no enclosing *named* symbol, so it is
13
+ recorded with a null caller and every caller-resolution path drops it. Measured
14
+ on this repository: **29,158 of 31,467 references from test files (92.7%) had no
15
+ caller symbol, against 390 of 18,399 (2.1%) from production files.** A coverage
16
+ question answered from callers was therefore about 93% blind in exactly the files
17
+ it was asking about, and 642 symbols with real tests reported as untested.
18
+
19
+ This is the worst shape a wrong answer can take. An empty result invites a second
20
+ look; a confident positive does not. The field was written into a security
21
+ proposal as a precondition blocking a change and reached a pull request before a
22
+ reviewer caught it.
23
+
24
+ Both questions now read the raw reference rows, which carry the calling file.
25
+
26
+ Two alternatives were rejected on evidence rather than taste. Attributing the
27
+ call to the *file* would put a non-symbol into a `symbol` field, which a
28
+ checked-in contract forbids outright. Indexing `describe`/`it` titles as symbols
29
+ would add roughly ten thousand rows and perturb statistics, dead-code detection,
30
+ communities, embeddings, and the centrality term added in 0.12.0. Reading the
31
+ rows changes no caller, so search ranking is bit-identical — verified rather than
32
+ assumed.
33
+
34
+ Rows for anonymous callers carry a file and a line and no symbol, because there
35
+ genuinely is none.
36
+
37
+ ## `untested` is now three-valued
38
+
39
+ The first version of that fix still collapsed "cannot tell" into a guess: without
40
+ the defining repository's database it inferred from resolved callers, which is
41
+ the same blindness that made the field wrong. `untested` is now simply **absent**
42
+ when the evidence is absent. A caller gets a fact or nothing, never a guess.
43
+
44
+ The control matters as much as the fix: a genuinely uncovered symbol still
45
+ reports `untested: true`. A field that is never true would be as broken as one
46
+ that is always true, and only that second test tells them apart.
47
+
48
+ ## Telemetry can now explain a repository where knodin underperforms
49
+
50
+ knodin has been reported as performing poorly in other people's repositories,
51
+ and there was no way to find out why without someone writing a field report by
52
+ hand. The signals that explain it already travelled in every response and were
53
+ being discarded.
54
+
55
+ Four are now persisted with the existing opt-in local telemetry:
56
+ `retrievalRoute`, `embeddingsUsed`, `semanticReadiness`, and `resultCount`.
57
+
58
+ `semanticReadiness` is the most diagnostic of them. A partially embedded index
59
+ returns confident *short* answers, which is indistinguishable from a poorly
60
+ phrased query unless the readiness is recorded beside the result.
61
+
62
+ All four are source-free metadata, so the privacy contract is unchanged: no
63
+ source, no query text, no paths, no identities, still opt-in, still local-only,
64
+ still no transport. Query *shape* — the remaining diagnostic gap — is
65
+ deliberately excluded, because persisting query text would falsify the
66
+ no-source-egress property the product is built on.
67
+
68
+ The sanitizer's allowlist gained these fields with validation rather than
69
+ pass-through. That allowlist is the only reason an unexpected field cannot reach
70
+ a shareable file, and an entry that trusted its input would quietly defeat it.
71
+
72
+ ## A design principle, written down
73
+
74
+ ADR 008 records what eleven defects across 0.12.0 and this release turned out to
75
+ have in common:
76
+
77
+ > A check that collapses "no" and "don't know" into a single answer will
78
+ > eventually return the wrong one, and it will do so confidently.
79
+
80
+ `untested: true`, `count: 0` for a misspelling, a review answering a different
81
+ range, staleness defaulting to `fresh` when it was missing. Two instances in the
82
+ ADR are deliberately not knodin bugs — another product's CI gate reporting a
83
+ network timeout as a security advisory, and an agent truncating its own terminal
84
+ output and concluding a remote did not exist. The shape belongs to any predicate
85
+ whose failure path and negative path are the same path.
86
+
87
+ ## Measured and not shipped
88
+
89
+ Three attempts at the lexical retrieval channel were implemented, measured, and
90
+ reverted: asymmetric token floors, Porter stemming, and grading a partial match
91
+ by how many query words it echoed. On a 44-query real-model benchmark they
92
+ scored 0.7112, 0.7389, and "no measurable difference" against a 0.7635 baseline,
93
+ and **none bought a single point of recall** — including on four queries added
94
+ specifically to favour morphological matching.
95
+
96
+ The reproductions behind them were sound. The inference was not: a demonstrated
97
+ defect does not imply that removing it is an improvement. The vector channel was
98
+ already carrying what the lexical channel could not see.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "knodin",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "knodin": {
5
- "compatibility": "breaking"
5
+ "compatibility": "compatible"
6
6
  },
7
7
  "description": "knodin — source-evidenced local code intelligence with known bounds. Stable identity, fresh evidence, truthful budgets, and recoverable bounded views.",
8
8
  "license": "MIT",
@@ -65,6 +65,7 @@
65
65
  "docs/releases/0.10.8.md",
66
66
  "docs/releases/0.11.0.md",
67
67
  "docs/releases/0.12.0.md",
68
+ "docs/releases/0.12.1.md",
68
69
  "docs/releases/0.3.0.md",
69
70
  "docs/releases/0.4.0.md",
70
71
  "docs/releases/0.4.1.md",