llmnav 0.9.1 → 0.9.7

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/CHANGELOG.md CHANGED
@@ -4,7 +4,29 @@ All notable changes to this project are documented here.
4
4
 
5
5
  The npm package follows Semantic Versioning. The `llmnav/N` source protocol is versioned independently.
6
6
 
7
- ## [Unreleased]
7
+ ## [0.9.7] — 2026-09-28
8
+
9
+ ### Fixed
10
+
11
+ * Recognize Go and Rust raw string boundaries before scanning LLMNav cards, preserving real cards and leaving card-shaped literal text untouched.
12
+ * Rank evaluation queries with the same repository graph used by project queries and sessions.
13
+ * Read prompt bundles under the generation lock and let hosts refresh prompt partitions and search from one generation snapshot.
14
+ * Validate the primary index against its manifest before trusting cached search and graph data, while retaining manifestless schema v1 reads.
15
+ * Reuse persisted parsed file state only when its manifest hash, record shape, and local stat-hint generation agree; otherwise read source and rebuild safely. Explicit caller-provided state remains reusable.
16
+ * Classify missing and ambiguous semantic IDs consistently across show and context agent operations.
17
+ * Report whether retrieval evaluation passed, failed, was invalid, or lacks enough cases; allow API callers to require a minimum case count without changing the default CLI exit behavior.
18
+ * Evaluate explicit no-result queries separately from positive recall, and require every no-result case to return no cards before evaluation passes.
19
+
20
+ ### Performance
21
+
22
+ * Narrow session ID substring checks to precomputed candidates and resolve alias targets directly, preserving direct-query results and ranking.
23
+
24
+ ## [0.9.2] — 2026-09-09
25
+
26
+ ### Fixed
27
+
28
+ * Recognize JavaScript/TypeScript regular-expression literals while masking source strings, so quotes and comment-like text inside regexes do not hide later cards or create false cards. Preserve division expressions and ordinary JSX closing tags.
29
+ * Invalidate older parsed-file caches so unchanged consumer files receive the corrected parsing behavior.
8
30
 
9
31
  ## [0.9.1] — 2026-09-07
10
32
 
package/README.md CHANGED
@@ -6,7 +6,7 @@ It adds compact, stable metadata to a small number of architectural and behavior
6
6
 
7
7
  LLMNav is not a documentation generator, an embedding database, or a reason to annotate every function. It is a zero-runtime-dependency Node.js CLI and ESM library for reducing broad repository scans, irrelevant context, stale hand-written links, repeated card tokenization, and avoidable cache invalidation.
8
8
 
9
- ## What v0.7 provides
9
+ ## Current capabilities
10
10
 
11
11
  * The backward-compatible `llmnav/1` source comment specification
12
12
  * A parser and data-loss-resistant canonical formatter
@@ -295,7 +295,7 @@ The project uses the Node.js standard library and built-in test runner. There is
295
295
 
296
296
  ## Status
297
297
 
298
- LLMNav is an experimental protocol and a usable v0.8 CLI. The source format remains `llmnav/1`; npm package changes and source-grammar changes are versioned independently.
298
+ LLMNav is a pre-1.0 CLI and ESM library. The source format remains `llmnav/1`, and the primary index uses schemaVersion 1. Package, source-protocol, and generated-format versions change independently; see the [compatibility policy](docs/compatibility.md) and [roadmap](ROADMAP.md) for their respective guarantees and remaining 1.0 criteria.
299
299
 
300
300
  ## License
301
301
 
package/ROADMAP.md CHANGED
@@ -85,7 +85,7 @@ Implemented:
85
85
 
86
86
  ## 1.0 criteria
87
87
 
88
- The source grammar and generated formats will be declared stable only after use across multiple TypeScript, Go, Rust, Python, and mixed-language repositories. A 1.0 release requires migration tooling, documented compatibility guarantees, benchmark fixtures with published methodology, sustained Windows and Linux verification, and no unresolved high-severity parser or transaction ambiguity.
88
+ The `llmnav/1` source protocol and schemaVersion 1 primary index already have compatibility guarantees. A 1.0 release requires evidence from multiple TypeScript, Go, Rust, Python, and mixed-language repositories, migration tooling, benchmark fixtures with published methodology, sustained Windows and Linux verification, and no unresolved high-severity parser or transaction ambiguity.
89
89
 
90
90
  In progress:
91
91
 
package/docs/api.md CHANGED
@@ -268,9 +268,15 @@ The normative source vocabulary remains available from `llmnav/spec`.
268
268
  import { KEY_ORDER, EFFECT_KINDS, RISK_KINDS } from "llmnav/spec";
269
269
  ```
270
270
 
271
+ ## Retrieval evaluation
272
+
273
+ `evaluateProject(root, { minimumCases: 1 })` requires at least one reviewed query before returning `ok: true`. The result's `status` is `passed`, `failed`, `insufficient`, `unmeasured`, or `invalid`. An empty dataset keeps the existing default `ok: true` for callers that use `eval` during setup, but always reports `status: "unmeasured"`; pass/fail gates should set `minimumCases` explicitly. The default CLI does not set this option.
274
+
275
+ Each query record requires a non-empty `query`. A positive case supplies at least one non-empty ID in `expected`; multiple IDs mean **any one** is an acceptable answer. A no-result case supplies `"expectNoResults": true` and omits `expected`. Recall@1, Recall@5, and MRR use only positive cases, while every no-result case must return zero cards for `status: "passed"`. At least one positive case is needed to pass. The current format does not require every listed ID to be found.
276
+
271
277
  ## Agent operation protocol
272
278
 
273
- `getAgentToolDefinitions()` returns defensive copies of four schemaVersion 1 tool definitions in fixed order. Their JSON Schema inputs reject unknown fields and deliberately omit the repository root.
279
+ `getAgentToolDefinitions()` returns defensive copies of five schemaVersion 1 tool definitions in fixed order. Their JSON Schema inputs reject unknown fields and deliberately omit the repository root.
274
280
 
275
281
  ```js
276
282
  import { executeAgentOperation, getAgentToolDefinitions } from "llmnav";
@@ -283,7 +289,7 @@ const result = await executeAgentOperation(
283
289
  );
284
290
  ```
285
291
 
286
- The trusted wrapper binds `root`; the model supplies only the validated operation input. Results use one schemaVersion 1 envelope containing `operation`, `ok`, `data`, and `error`. Input errors use `LNVAP002`, missing IDs use `LNVAP404`, and unexpected operation failures use `LNVAP500`.
292
+ The trusted wrapper binds `root`; the model supplies only the validated operation input. Results use one schemaVersion 1 envelope containing `operation`, `ok`, `data`, and `error`. Input errors use `LNVAP002`, missing IDs use `LNVAP404`, ambiguous IDs use `LNVAP409`, and unexpected operation failures use `LNVAP500`.
287
293
 
288
294
  Long-lived hosts can load one explicit snapshot for repeated navigation calls:
289
295
 
@@ -302,11 +308,13 @@ await session.refresh(); // after generation or checkout changes
302
308
 
303
309
  `query`, `show`, and `context` reuse the loaded index, postings, graph, lexicon, and registry. `refresh()` replaces the complete snapshot; it never mutates one layer in place. The `check` operation still scans current source and does not use session data.
304
310
 
311
+ Pass `{ withPromptBundle: true }` to `createProjectSession` when a host needs prompt partitions and navigation from the same generation. The returned session exposes `promptBundle` and a content-derived `generationHash`; `refresh()` replaces both alongside the search snapshot. The hash identifies generated cache content, not whether ungenerated source edits exist in the working tree.
312
+
305
313
  Sessions prepare graph adjacency and traversal order once per snapshot for reuse by `query` and `context`. Refreshing prepares a new graph, and obsolete prepared state can be garbage-collected with the previous snapshot. Direct `queryIndex` and `queryPreparedIndex` calls do not cache caller-owned graphs, so in-place changes to supplied edges remain visible on the next call.
306
314
 
307
- Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. ID substring and phrase matching still scan all documents to preserve ranking. Session `query` and `show` return detached results: caller edits cannot invalidate the private prepared snapshot. These tables are rebuilt on `refresh()` and retained only with that snapshot.
315
+ Session queries also reuse card lookup tables, normalized IDs and aliases, and graph validation. Session ID substring checks use precomputed three-code-unit candidates before exact verification; direct queries retain their full ID scan. Phrase matching still scans all documents to preserve ranking. Session `query` and `show` return detached results: caller edits cannot invalidate the private prepared snapshot. These tables are rebuilt on `refresh()` and retained only with that snapshot.
308
316
 
309
- The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs into a trusted-root closure. It exposes tool definitions, base and module-selected prompt partitions, one snapshot-backed operation executor, and an explicit refresh method without importing a model SDK.
317
+ The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs into a trusted-root closure. It exposes tool definitions, base and module-selected prompt partitions, the generation hash, one snapshot-backed operation executor, and an explicit refresh method without importing a model SDK. Refresh prepares a complete replacement before switching the host's prompt and search references.
310
318
 
311
319
  `buildPromptPrefixBundle(input)` constructs ordered package, repository, and module partitions with normalized newlines, SHA-256 content hashes, estimated token counts, and explicit cache-boundary hints. `renderPromptPrefixBundle` serializes it deterministically. `loadPromptPrefixBundle(root)` accepts only a schema-compatible artifact whose exact bytes match `manifest.json`.
312
320
 
@@ -8,6 +8,7 @@ import type {
8
8
  export interface LlmnavHost {
9
9
  toolDefinitions: AgentToolDefinition[];
10
10
  basePromptPartitions: PromptPrefixPartition[];
11
+ generationHash: string;
11
12
  selectPromptPartitions(moduleIds?: string[]): PromptPrefixPartition[];
12
13
  execute(call: { name: string; input?: Record<string, unknown> }): Promise<AgentOperationResult>;
13
14
  refresh(): Promise<LlmnavHost>;
@@ -13,15 +13,15 @@ import {
13
13
  executeAgentOperation,
14
14
  createProjectSession,
15
15
  getAgentToolDefinitions,
16
- loadPromptPrefixBundle,
17
16
  } from "llmnav";
18
17
 
19
18
  export async function createLlmnavHost(root) {
20
- let bundle = await loadPromptPrefixBundle(root);
21
- let session = await createProjectSession(root);
19
+ let session = await createProjectSession(root, { withPromptBundle: true });
20
+ let bundle = session.promptBundle;
22
21
  const host = {
23
22
  toolDefinitions: getAgentToolDefinitions(),
24
23
  basePromptPartitions: selectPromptPartitions(bundle),
24
+ generationHash: session.generationHash,
25
25
  selectPromptPartitions(moduleIds = []) {
26
26
  return selectPromptPartitions(bundle, moduleIds);
27
27
  },
@@ -29,11 +29,13 @@ export async function createLlmnavHost(root) {
29
29
  return executeAgentOperation(root, call.name, call.input ?? {}, { session });
30
30
  },
31
31
  async refresh() {
32
- [bundle, session] = await Promise.all([
33
- loadPromptPrefixBundle(root),
34
- createProjectSession(root),
35
- ]);
36
- host.basePromptPartitions = selectPromptPartitions(bundle);
32
+ const nextSession = await createProjectSession(root, { withPromptBundle: true });
33
+ const nextBundle = nextSession.promptBundle;
34
+ const nextBase = selectPromptPartitions(nextBundle);
35
+ session = nextSession;
36
+ bundle = nextBundle;
37
+ host.basePromptPartitions = nextBase;
38
+ host.generationHash = nextSession.generationHash;
37
39
  return host;
38
40
  },
39
41
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.9.1",
3
+ "version": "0.9.7",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@ stability=contract
12
12
 
13
13
  import { loadGraphInputs } from "./graph-input.js";
14
14
  import { scanProject } from "./project.js";
15
- import { buildContext, queryProject, showProjectCard } from "./search.js";
15
+ import { buildContext, queryProject, SemanticIdLookupError, showProjectCard } from "./search.js";
16
16
  import { countDiagnostics, validateProject } from "./validator.js";
17
17
  import { getAgentToolDefinitions } from "./agent-tools.js";
18
18
  import { explainProjectFile } from "./audit.js";
@@ -48,7 +48,12 @@ export async function executeAgentOperation(root, name, input = {}, options = {}
48
48
  const result = options.session
49
49
  ? options.session.show(input.id.trim())
50
50
  : await showProjectCard(root, input.id.trim());
51
- if (!result.card && !result.node) return failure(operation, "LNVAP404", `Unknown or inactive semantic ID ${input.id}.`, result);
51
+ if (!result.card && !result.node) {
52
+ const state = result.resolvedFrom?.state;
53
+ if (state === "ambiguous") return failure(operation, "LNVAP409", `Ambiguous semantic ID ${input.id}; qualify or replace it with one candidate.`, result);
54
+ if (state === "cycle") return failure(operation, "LNVAP500", `Registry cycle prevents resolving semantic ID ${input.id}.`, result);
55
+ return failure(operation, "LNVAP404", `Unknown or inactive semantic ID ${input.id}.`, result);
56
+ }
52
57
  return success(operation, result);
53
58
  }
54
59
  if (operation === "context") {
@@ -76,6 +81,10 @@ export async function executeAgentOperation(root, name, input = {}, options = {}
76
81
  error: null,
77
82
  };
78
83
  } catch (error) {
84
+ if (error instanceof SemanticIdLookupError) {
85
+ return failure(operation, error.reason === "not_found" ? "LNVAP404" :
86
+ error.reason === "ambiguous" ? "LNVAP409" : "LNVAP500", error.message);
87
+ }
79
88
  return failure(operation, "LNVAP500", error instanceof Error ? error.message : String(error));
80
89
  }
81
90
  }
package/src/evaluation.js CHANGED
@@ -14,54 +14,78 @@ import { loadSearchData, queryPreparedIndex } from "./search.js";
14
14
  import { assertNoSymlinkTraversal, parseJsonLines, readText } from "./util.js";
15
15
 
16
16
  export async function evaluateProject(root, options = {}) {
17
+ const minimumCases = options.minimumCases ?? 0;
18
+ if (!Number.isSafeInteger(minimumCases) || minimumCases < 0) {
19
+ throw new RangeError("minimumCases must be a non-negative safe integer.");
20
+ }
17
21
  const { config } = await loadConfig(root);
18
- const { index, lexicon, searchIndex } = await loadSearchData(root);
22
+ const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
19
23
  const queryPath = path.resolve(root, options.file ?? config.evaluation.queryFile);
20
24
  await assertNoSymlinkTraversal(root, queryPath, "evaluation query file");
21
25
  const parsed = parseJsonLines(await readText(queryPath, ""), queryPath);
22
26
  if (parsed.errors.length > 0) {
23
- return { ok: false, errors: parsed.errors, cases: [], metrics: emptyMetrics() };
27
+ return { ok: false, status: "invalid", minimumCases, errors: parsed.errors, cases: [], metrics: emptyMetrics() };
24
28
  }
25
29
 
26
30
  const cases = [];
27
31
  for (const record of parsed.records) {
28
- if (!record || typeof record.query !== "string" || !Array.isArray(record.expected) || record.expected.length === 0) {
32
+ const expectNoResults = record?.expectNoResults === true;
33
+ const expected = expectNoResults ? [] : record?.expected;
34
+ if (!record || typeof record.query !== "string" || !record.query.trim() ||
35
+ (Object.hasOwn(record, "expectNoResults") && !expectNoResults) ||
36
+ (expectNoResults ? record.expected !== undefined :
37
+ !Array.isArray(expected) || expected.length === 0 ||
38
+ !expected.every((id) => typeof id === "string" && id.trim()))) {
29
39
  return {
30
40
  ok: false,
31
- errors: [`${queryPath}: each record requires query:string and expected:string[]`],
41
+ status: "invalid",
42
+ minimumCases,
43
+ errors: [`${queryPath}: each record requires a non-empty query and either non-empty expected IDs or expectNoResults:true`],
32
44
  cases,
33
45
  metrics: emptyMetrics(),
34
46
  };
35
47
  }
36
- const results = queryPreparedIndex(index, searchIndex, record.query, { top: Math.max(options.top ?? 5, 5), lexicon });
48
+ const results = queryPreparedIndex(index, searchIndex, record.query, { top: Math.max(options.top ?? 5, 5), lexicon, graph });
37
49
  const ids = results.map((result) => result.id);
38
- const firstRank = ids.findIndex((id) => record.expected.includes(id));
50
+ const firstRank = expectNoResults ? -1 : ids.findIndex((id) => expected.includes(id));
51
+ const noResultPass = expectNoResults && ids.length === 0;
39
52
  cases.push({
40
53
  query: record.query,
41
- expected: record.expected,
54
+ expected,
55
+ expectNoResults,
42
56
  actual: ids,
43
57
  rank: firstRank < 0 ? null : firstRank + 1,
44
- passAt1: firstRank === 0,
45
- passAt5: firstRank >= 0 && firstRank < 5,
58
+ passAt1: noResultPass || firstRank === 0,
59
+ passAt5: noResultPass || (firstRank >= 0 && firstRank < 5),
46
60
  });
47
61
  }
48
62
 
49
63
  const total = cases.length;
64
+ const positiveCases = cases.filter((item) => !item.expectNoResults);
65
+ const positiveTotal = positiveCases.length;
66
+ const negativeTotal = total - positiveTotal;
67
+ const negativePassed = cases.filter((item) => item.expectNoResults && item.passAt5).length;
50
68
  const metrics = total === 0
51
69
  ? emptyMetrics()
52
70
  : {
53
71
  total,
54
- recallAt1: cases.filter((item) => item.passAt1).length / total,
55
- recallAt5: cases.filter((item) => item.passAt5).length / total,
56
- meanReciprocalRank: cases.reduce((sum, item) => sum + (item.rank ? 1 / item.rank : 0), 0) / total,
72
+ positiveTotal,
73
+ negativeTotal,
74
+ negativePassed,
75
+ recallAt1: positiveTotal === 0 ? 0 : positiveCases.filter((item) => item.passAt1).length / positiveTotal,
76
+ recallAt5: positiveTotal === 0 ? 0 : positiveCases.filter((item) => item.passAt5).length / positiveTotal,
77
+ meanReciprocalRank: positiveTotal === 0 ? 0 :
78
+ positiveCases.reduce((sum, item) => sum + (item.rank ? 1 / item.rank : 0), 0) / positiveTotal,
57
79
  };
58
- const ok =
59
- total === 0 ||
60
- (metrics.recallAt1 >= config.evaluation.minimumRecallAt1 &&
61
- metrics.recallAt5 >= config.evaluation.minimumRecallAt5);
62
- return { ok, errors: [], cases, metrics, thresholds: config.evaluation };
80
+ const thresholdsPassed = positiveTotal > 0 && negativePassed === negativeTotal &&
81
+ metrics.recallAt1 >= config.evaluation.minimumRecallAt1 &&
82
+ metrics.recallAt5 >= config.evaluation.minimumRecallAt5;
83
+ const status = total === 0 ? "unmeasured" :
84
+ total < minimumCases ? "insufficient" : thresholdsPassed ? "passed" : "failed";
85
+ const ok = status === "passed" || (status === "unmeasured" && minimumCases === 0);
86
+ return { ok, status, minimumCases, errors: [], cases, metrics, thresholds: config.evaluation };
63
87
  }
64
88
 
65
89
  function emptyMetrics() {
66
- return { total: 0, recallAt1: 0, recallAt5: 0, meanReciprocalRank: 0 };
90
+ return { total: 0, positiveTotal: 0, negativeTotal: 0, negativePassed: 0, recallAt1: 0, recallAt5: 0, meanReciprocalRank: 0 };
67
91
  }
@@ -21,14 +21,15 @@ import {
21
21
  atomicWrite,
22
22
  compareText,
23
23
  readJsonSafe,
24
+ readText,
24
25
  relativePosix,
25
26
  sha256,
26
27
  stableStringify,
27
28
  } from "./util.js";
28
29
 
29
30
  export const FILE_STATE_SCHEMA_VERSION = 1;
30
- export const SOURCE_INDEXER_VERSION = 6;
31
- const STAT_HINTS_SCHEMA_VERSION = 1;
31
+ export const SOURCE_INDEXER_VERSION = 8;
32
+ const STAT_HINTS_SCHEMA_VERSION = 2;
32
33
 
33
34
  export async function scanProjectIncremental(root, options = {}) {
34
35
  const { config, configPath } = await loadConfig(root);
@@ -37,14 +38,30 @@ export async function scanProjectIncremental(root, options = {}) {
37
38
  await assertNoSymlinkTraversal(root, cacheDirectory, config.generation.cacheDirectory);
38
39
  const fileStatePath = path.join(cacheDirectory, "file-state.json");
39
40
  await assertNoSymlinkTraversal(root, fileStatePath, relativePosix(root, fileStatePath));
40
- const previousState = options.previousState ?? await readJsonSafe(fileStatePath, null);
41
+ const fileStateSource = options.previousState
42
+ ? renderFileState(options.previousState)
43
+ : await readText(fileStatePath, null);
44
+ let previousState = null;
45
+ try {
46
+ previousState = fileStateSource === null ? null : JSON.parse(fileStateSource);
47
+ } catch (error) {
48
+ if (!(error instanceof SyntaxError)) throw error;
49
+ }
50
+ const manifest = await readJsonSafe(path.join(cacheDirectory, "manifest.json"), null);
51
+ const previousStateHash = fileStateSource === null ? null : sha256(fileStateSource);
52
+ const fileStateKey = `${relativePosix(root, cacheDirectory)}/file-state.json`;
53
+ const trustedPreviousState = usableFileState(previousState) &&
54
+ (options.previousState != null ||
55
+ (manifest?.schemaVersion === 1 && manifest.repositoryId === config.repositoryId &&
56
+ manifest.files?.[fileStateKey] === previousStateHash));
41
57
  const hintsPath = path.join(root, ".llmnav", "state", "stat-hints.json");
42
58
  await assertNoSymlinkTraversal(root, hintsPath, relativePosix(root, hintsPath));
43
59
  const previousHints = options.useStatHints === false
44
60
  ? null
45
61
  : await readJsonSafe(hintsPath, null);
46
- const previousFiles = usableFileState(previousState) ? mapStateFiles(previousState.files) : new Map();
47
- const hintFiles = usableStatHints(previousHints) ? previousHints.files ?? {} : {};
62
+ const previousFiles = trustedPreviousState ? mapStateFiles(previousState.files) : new Map();
63
+ const hintFiles = trustedPreviousState && usableStatHints(previousHints) &&
64
+ previousHints.fileStateHash === previousStateHash ? previousHints.files : {};
48
65
 
49
66
  const fileRecords = [];
50
67
  const records = [];
@@ -69,13 +86,13 @@ export async function scanProjectIncremental(root, options = {}) {
69
86
  const relativePath = relativePosix(root, absolutePath);
70
87
  const details = await stat(absolutePath, { bigint: true });
71
88
  const fingerprint = statFingerprint(details);
72
- nextHintFiles[relativePath] = fingerprint;
73
89
  seenPaths.add(relativePath);
74
90
  const previousFile = previousFiles.get(relativePath);
75
91
  const previousHint = hintFiles[relativePath];
76
92
  let stateFile;
77
93
 
78
- if (previousFile && fingerprintsEqual(previousHint, fingerprint)) {
94
+ if (previousFile && previousHint?.contentHash === previousFile.contentHash &&
95
+ fingerprintsEqual(previousHint, fingerprint)) {
79
96
  stateFile = previousFile;
80
97
  stats.reusedFiles += 1;
81
98
  stats.reusedFilesByStat += 1;
@@ -97,6 +114,7 @@ export async function scanProjectIncremental(root, options = {}) {
97
114
  }
98
115
 
99
116
  const normalizedStateFile = normalizeStateFile(stateFile, relativePath);
117
+ nextHintFiles[relativePath] = { ...fingerprint, contentHash: normalizedStateFile.contentHash };
100
118
  nextStateFiles.push(normalizedStateFile);
101
119
  sourceBytes += normalizedStateFile.sourceBytes;
102
120
  semanticBytes += normalizedStateFile.semanticBytes;
@@ -117,6 +135,7 @@ export async function scanProjectIncremental(root, options = {}) {
117
135
  };
118
136
  const statHints = {
119
137
  schemaVersion: STAT_HINTS_SCHEMA_VERSION,
138
+ fileStateHash: sha256(renderFileState(fileState)),
120
139
  files: Object.fromEntries(Object.entries(nextHintFiles).sort(([left], [right]) => compareText(left, right))),
121
140
  };
122
141
  const registry = await loadRegistry(root);
@@ -176,12 +195,22 @@ export function renderFileState(fileState) {
176
195
  }
177
196
 
178
197
  export function usableFileState(value) {
179
- return Boolean(
180
- value &&
181
- value.schemaVersion === FILE_STATE_SCHEMA_VERSION &&
182
- value.indexerVersion === SOURCE_INDEXER_VERSION &&
183
- Array.isArray(value.files),
184
- );
198
+ if (!value || value.schemaVersion !== FILE_STATE_SCHEMA_VERSION ||
199
+ value.indexerVersion !== SOURCE_INDEXER_VERSION || !Array.isArray(value.files)) return false;
200
+ const paths = new Set();
201
+ for (const file of value.files) {
202
+ if (!file || typeof file.path !== "string" || !file.path || file.path.startsWith("/") ||
203
+ file.path.includes("\\") || file.path.split("/").includes("..") || paths.has(file.path) ||
204
+ !/^[0-9a-f]{64}$/u.test(file.contentHash) ||
205
+ !Number.isSafeInteger(file.sourceBytes) || file.sourceBytes < 0 ||
206
+ !Number.isSafeInteger(file.semanticBytes) || file.semanticBytes < 0 ||
207
+ !Array.isArray(file.imports) || !file.imports.every((item) => typeof item === "string") ||
208
+ !Array.isArray(file.blocks) || !file.blocks.every((block) => block &&
209
+ typeof block.raw === "string" && block.card && typeof block.card.id === "string") ||
210
+ !Array.isArray(file.declarations) || file.declarations.length !== file.blocks.length) return false;
211
+ paths.add(file.path);
212
+ }
213
+ return true;
185
214
  }
186
215
 
187
216
  function analyzeFile(relativePath, source) {
@@ -269,5 +298,7 @@ function fingerprintsEqual(left, right) {
269
298
  }
270
299
 
271
300
  function usableStatHints(value) {
272
- return Boolean(value && value.schemaVersion === STAT_HINTS_SCHEMA_VERSION && value.files && typeof value.files === "object");
301
+ return Boolean(value && value.schemaVersion === STAT_HINTS_SCHEMA_VERSION &&
302
+ /^[0-9a-f]{64}$/u.test(value.fileStateHash) && value.files &&
303
+ typeof value.files === "object" && !Array.isArray(value.files));
273
304
  }
package/src/index.d.ts CHANGED
@@ -253,6 +253,12 @@ export interface ProjectSession {
253
253
  refresh(): Promise<ProjectSession>;
254
254
  }
255
255
 
256
+ export interface ProjectSessionWithPromptBundle extends ProjectSession {
257
+ promptBundle: PromptPrefixBundle;
258
+ generationHash: string;
259
+ refresh(): Promise<ProjectSessionWithPromptBundle>;
260
+ }
261
+
256
262
  export interface RegistryRecord {
257
263
  id: string;
258
264
  state: "active" | "redirect" | "replaced" | "retired" | string;
@@ -621,10 +627,13 @@ export interface MigrationResult {
621
627
 
622
628
  export interface EvaluationResult {
623
629
  ok: boolean;
630
+ status: "invalid" | "unmeasured" | "insufficient" | "passed" | "failed";
631
+ minimumCases: number;
624
632
  errors: string[];
625
633
  cases: Array<{
626
634
  query: string;
627
635
  expected: string[];
636
+ expectNoResults: boolean;
628
637
  actual: string[];
629
638
  rank: number | null;
630
639
  passAt1: boolean;
@@ -632,6 +641,9 @@ export interface EvaluationResult {
632
641
  }>;
633
642
  metrics: {
634
643
  total: number;
644
+ positiveTotal: number;
645
+ negativeTotal: number;
646
+ negativePassed: number;
635
647
  recallAt1: number;
636
648
  recallAt5: number;
637
649
  meanReciprocalRank: number;
@@ -725,7 +737,7 @@ export function findAttachedDeclaration(source: string, block: LlmnavBlock, file
725
737
  export function extractImports(source: string, filePath: string): string[];
726
738
  export function doctorProject(root: string): Promise<{ ok: boolean; checks: Array<{ name: string; ok: boolean; message: string }> }>;
727
739
  export function migrateProject(root: string, options?: { write?: boolean; failpoint?: string; renameOptions?: Record<string, unknown>; lockOptions?: Record<string, unknown>; onTransactionPhase?: (phase: string) => void | Promise<void> }): Promise<MigrationResult>;
728
- export function evaluateProject(root: string, options?: { top?: number; file?: string }): Promise<EvaluationResult>;
740
+ export function evaluateProject(root: string, options?: { top?: number; file?: string; minimumCases?: number }): Promise<EvaluationResult>;
729
741
  export function collectSourceFiles(root: string, config: LlmnavConfig, requestedPaths?: string[]): Promise<string[]>;
730
742
  export function findProjectRoot(start?: string): Promise<string>;
731
743
  export function formatProject(root: string, options?: { check?: boolean; paths?: string[] }): Promise<{ ok: boolean; changedFiles: string[]; errors: Array<{ file: string; line: number; message: string }> }>;
@@ -757,7 +769,8 @@ export function renderRegistryRecords(records: RegistryRecord[]): string;
757
769
  export function loadRegistry(root: string): Promise<Registry>;
758
770
  export function resolveRegistryId(registry: Registry, id: string): { id: string; state: string; [key: string]: unknown };
759
771
  export function buildContext(root: string, id: string, options?: { depth?: number; budget?: number; maxEdges?: number }): Promise<{ id: string; depth: number; budget: number; maxEdges: number; included: string[]; includedEdges: string[]; text: string }>;
760
- export function createProjectSession(root: string): Promise<ProjectSession>;
772
+ export function createProjectSession(root: string, options: { withPromptBundle: true }): Promise<ProjectSessionWithPromptBundle>;
773
+ export function createProjectSession(root: string, options?: { withPromptBundle?: false }): Promise<ProjectSession>;
761
774
  export function loadSearchData(root: string): Promise<{ index: LlmnavIndex; searchIndex: LlmnavSearchIndex; lexicon: { version?: number; aliases: Record<string, string | string[]> }; graph: RepositoryGraph | null }>;
762
775
  export function queryIndex(index: LlmnavIndex, query: string, options?: { top?: number; lexicon?: { aliases: Record<string, string | string[]> }; invertedIndex?: LlmnavSearchIndex; metrics?: SearchMetrics; graph?: RepositoryGraph | null }): SearchResult[];
763
776
  export function queryPreparedIndex(index: LlmnavIndex, searchIndex: LlmnavSearchIndex, query: string, options?: { top?: number; lexicon?: { aliases: Record<string, string | string[]> }; metrics?: SearchMetrics; graph?: RepositoryGraph | null }): SearchResult[];
package/src/parser.js CHANGED
@@ -39,6 +39,11 @@ const BLOCK_PATTERNS = [
39
39
  ];
40
40
  const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
41
41
  const MAX_BLOCKS_PER_FILE = 10_000;
42
+ const JAVASCRIPT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".jsx", ".ts", ".tsx"]);
43
+ const REGEX_PREFIX_KEYWORDS = new Set([
44
+ "await", "case", "delete", "do", "else", "in", "instanceof", "new", "return", "throw", "typeof", "void", "yield",
45
+ ]);
46
+ const CONTROL_PAREN_KEYWORDS = new Set(["if", "while", "for", "with", "switch", "catch"]);
42
47
 
43
48
  export function parseLlmnavBlocks(source, filePath = "<memory>") {
44
49
  if (Buffer.byteLength(source) > MAX_SOURCE_BYTES) {
@@ -217,8 +222,15 @@ function buildLiteralMask(source, filePath) {
217
222
  const hashComments = [".py", ".rb", ".sh", ".bash", ".zsh"].includes(extension);
218
223
  const dashComments = extension === ".sql";
219
224
  const tripleQuotes = extension === ".py";
225
+ const javascript = JAVASCRIPT_EXTENSIONS.has(extension);
226
+ let regexAllowed = true;
227
+ let regexCharacterClass = false;
228
+ let previousToken = "";
229
+ const controlParentheses = [];
230
+ const expressionBraces = [];
220
231
  let state = "normal";
221
232
  let escaped = false;
233
+ let rustRawTerminator = "";
222
234
  const mask = new Uint8Array(source.length);
223
235
 
224
236
  for (let index = 0; index < source.length; index += 1) {
@@ -260,21 +272,51 @@ function buildLiteralMask(source, filePath) {
260
272
  }
261
273
  continue;
262
274
  }
275
+ if (state === "regex") {
276
+ if (character === "\n" || character === "\r") {
277
+ state = "normal";
278
+ escaped = false;
279
+ } else if (escaped) escaped = false;
280
+ else if (character === "\\") escaped = true;
281
+ else if (character === "[") regexCharacterClass = true;
282
+ else if (character === "]") regexCharacterClass = false;
283
+ else if (character === "/" && !regexCharacterClass) {
284
+ state = "normal";
285
+ regexAllowed = false;
286
+ previousToken = "value";
287
+ }
288
+ continue;
289
+ }
290
+ if (state === "rust-raw") {
291
+ if (source.startsWith(rustRawTerminator, index)) {
292
+ state = "normal";
293
+ index += rustRawTerminator.length - 1;
294
+ }
295
+ continue;
296
+ }
263
297
  if (state === "single" || state === "double" || state === "backtick") {
264
298
  if (escaped) {
265
299
  escaped = false;
266
300
  continue;
267
301
  }
268
- if (character === "\\") {
302
+ if (character === "\\" && (state !== "backtick" || extension !== ".go")) {
269
303
  escaped = true;
270
304
  continue;
271
305
  }
272
306
  const terminator = state === "single" ? "'" : state === "double" ? '"' : "`";
273
- if (character === terminator) state = "normal";
307
+ if (character === terminator) {
308
+ state = "normal";
309
+ regexAllowed = false;
310
+ previousToken = "value";
311
+ }
274
312
  continue;
275
313
  }
276
314
 
277
- if (nextFour === "<!--") {
315
+ const rawTerminator = extension === ".rs" ? rustRawStringTerminator(source, index) : null;
316
+ if (rawTerminator !== null) {
317
+ state = "rust-raw";
318
+ rustRawTerminator = rawTerminator;
319
+ } else if (nextFour === "<!--") {
278
320
  state = "html-comment";
279
321
  index += 3;
280
322
  } else if (character === "/" && next === "*") {
@@ -283,6 +325,10 @@ function buildLiteralMask(source, filePath) {
283
325
  } else if (character === "/" && next === "/") {
284
326
  state = "line-comment";
285
327
  index += 1;
328
+ } else if (javascript && character === "/" && regexAllowed) {
329
+ state = "regex";
330
+ regexCharacterClass = false;
331
+ escaped = false;
286
332
  } else if (hashComments && character === "#") {
287
333
  state = "line-comment";
288
334
  } else if (dashComments && character === "-" && next === "-") {
@@ -303,12 +349,61 @@ function buildLiteralMask(source, filePath) {
303
349
  } else if (character === "`") {
304
350
  state = "backtick";
305
351
  escaped = false;
352
+ } else if (javascript && !/\s/u.test(character)) {
353
+ // Track lexical expression position without rescanning prefixes. Comments do
354
+ // not change it; a control-condition ')' permits a following regex statement.
355
+ if (/[A-Za-z_$]/u.test(character)) {
356
+ const start = index;
357
+ const memberName = previousToken === ".";
358
+ while (index + 1 < source.length && /[\w$]/u.test(source[index + 1])) index += 1;
359
+ previousToken = source.slice(start, index + 1);
360
+ regexAllowed = !memberName && REGEX_PREFIX_KEYWORDS.has(previousToken);
361
+ } else if (character === "(") {
362
+ controlParentheses.push(CONTROL_PAREN_KEYWORDS.has(previousToken));
363
+ regexAllowed = true;
364
+ previousToken = character;
365
+ } else if (character === ")") {
366
+ regexAllowed = controlParentheses.pop() === true;
367
+ previousToken = character;
368
+ } else if (character === "{") {
369
+ expressionBraces.push(regexAllowed && !["", ";", "else", "do", "try", "finally", ")"].includes(previousToken));
370
+ regexAllowed = true;
371
+ previousToken = character;
372
+ } else if (character === "}") {
373
+ regexAllowed = expressionBraces.pop() !== true;
374
+ previousToken = character;
375
+ } else if ((character === "+" || character === "-") && next === character) {
376
+ previousToken = character + next;
377
+ index += 1;
378
+ } else if (character === "=" && next === ">") {
379
+ regexAllowed = true;
380
+ previousToken = "=>";
381
+ index += 1;
382
+ } else {
383
+ regexAllowed = "=(:,!&|?;[+-*%~^>/".includes(character);
384
+ previousToken = character;
385
+ }
306
386
  }
307
387
  }
308
388
 
309
389
  return mask;
310
390
  }
311
391
 
392
+ function rustRawStringTerminator(source, index) {
393
+ let cursor;
394
+ if (source[index] === "r") cursor = index + 1;
395
+ else if (source[index] === "b" && source[index + 1] === "r") cursor = index + 2;
396
+ else return null;
397
+ if (index > 0 && /[\p{ID_Continue}]/u.test(source[index - 1])) return null;
398
+ let hashes = 0;
399
+ while (source[cursor] === "#" && hashes <= 255) {
400
+ hashes += 1;
401
+ cursor += 1;
402
+ }
403
+ if (hashes > 255 || source[cursor] !== '"') return null;
404
+ return `"${"#".repeat(hashes)}`;
405
+ }
406
+
312
407
  function looksLikeRustCharacterLiteral(source, offset) {
313
408
  return /^'(?:\\.|[^'\\\r\n])'/u.test(source.slice(offset));
314
409
  }
@@ -12,6 +12,7 @@ stability=architecture
12
12
 
13
13
  import path from "node:path";
14
14
  import { loadConfig } from "./config.js";
15
+ import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
15
16
  import { approximateTokens, assertNoSymlinkTraversal, compareText, readText, sha256, stableJson, stableStringify, toPosix } from "./util.js";
16
17
 
17
18
  export const PROMPT_BUNDLE_SCHEMA_VERSION = 1;
@@ -60,7 +61,12 @@ export function isCompatiblePromptPrefixBundle(bundle, repositoryId = undefined)
60
61
  }
61
62
 
62
63
  export async function loadPromptPrefixBundle(root) {
64
+ return withGenerationLock(root, async (lock) => (await loadPromptPrefixBundleLocked(root, lock)).bundle);
65
+ }
66
+
67
+ export async function loadPromptPrefixBundleLocked(root, lock) {
63
68
  const { config } = await loadConfig(root);
69
+ await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory, lockOwnerId: lock.ownerId });
64
70
  const relativePath = `${toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "")}/prompt-prefix.json`;
65
71
  const bundlePath = path.join(root, relativePath);
66
72
  const manifestPath = path.join(root, config.generation.cacheDirectory, "manifest.json");
@@ -80,7 +86,7 @@ export async function loadPromptPrefixBundle(root) {
80
86
  const manifestContent = await readText(manifestPath, "");
81
87
  const manifest = manifestContent ? JSON.parse(manifestContent) : null;
82
88
  if (manifest?.files?.[relativePath] !== sha256(content)) throw new Error(`Prompt bundle hash does not match manifest.json.`);
83
- return bundle;
89
+ return { bundle, generationHash: sha256(manifestContent) };
84
90
  }
85
91
 
86
92
  function partition(id, cacheScope, contentType, content) {
package/src/search.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  verifySearchIndex,
24
24
  } from "./inverted-index.js";
25
25
  import { normalizeSearchText, tokenize } from "./tokenizer.js";
26
+ import { loadPromptPrefixBundleLocked } from "./prompt-bundle.js";
26
27
  import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
27
28
  import { isCompatibleRepositoryGraph, renderGraphNode, resolveGraphNode } from "./graph.js";
28
29
 
@@ -31,6 +32,14 @@ const preparedSearchIndexCache = new WeakMap();
31
32
  const sessionGraphAdjacencyCache = new WeakMap();
32
33
  const sessionSearchMetadataCache = new WeakMap();
33
34
 
35
+ export class SemanticIdLookupError extends Error {
36
+ constructor(reason, message) {
37
+ super(message);
38
+ this.name = "SemanticIdLookupError";
39
+ this.reason = reason;
40
+ }
41
+ }
42
+
34
43
  export async function loadSearchData(root) {
35
44
  return withGenerationLock(root, (lock) => loadSearchDataLocked(root, lock));
36
45
  }
@@ -48,11 +57,33 @@ async function loadSearchDataLocked(root, lock) {
48
57
  for (const managedPath of [indexPath, manifestPath, graphPath, searchPath, lexiconPath]) {
49
58
  await assertNoSymlinkTraversal(root, managedPath, toPosix(path.relative(root, managedPath)));
50
59
  }
51
- const index = await readJsonSafe(indexPath, null);
52
- if (!index) throw new Error("No generated index found. Run `llmnav generate` first.");
60
+ const indexText = await readText(indexPath, null);
61
+ if (indexText === null) throw new Error("No generated index found. Run `llmnav generate` first.");
62
+ let index;
63
+ try {
64
+ index = JSON.parse(indexText);
65
+ } catch {
66
+ throw new Error("Malformed primary index.json. Run `llmnav generate` to rebuild it.");
67
+ }
68
+ if (!isUsablePrimaryIndex(index, config.repositoryId)) {
69
+ throw new Error("Incompatible primary index.json. Run `llmnav generate` to rebuild it.");
70
+ }
53
71
  const lexicon = await readJsonSafe(lexiconPath, { version: 1, aliases: {} });
54
- const manifest = await readJsonSafe(manifestPath, null);
55
- const graphRelative = `${toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "")}/graph.json`;
72
+ const cacheRelative = toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "");
73
+ const manifestText = await readText(manifestPath, null);
74
+ let manifest = null;
75
+ if (manifestText !== null) {
76
+ try {
77
+ manifest = JSON.parse(manifestText);
78
+ } catch {
79
+ throw new Error("Malformed generated manifest.json. Run `llmnav generate` to rebuild it.");
80
+ }
81
+ if (manifest?.repositoryId !== config.repositoryId ||
82
+ manifest.files?.[`${cacheRelative}/index.json`] !== sha256(indexText)) {
83
+ throw new Error("Primary index.json hash does not match manifest.json. Run `llmnav generate` to rebuild it.");
84
+ }
85
+ }
86
+ const graphRelative = `${cacheRelative}/graph.json`;
56
87
  const graphText = await readText(graphPath, null);
57
88
  let graph = null;
58
89
  if (graphText !== null) {
@@ -68,7 +99,7 @@ async function loadSearchDataLocked(root, lock) {
68
99
  isCompatibleRepositoryGraph(graph, index.repositoryId),
69
100
  );
70
101
  if (!graphMatches) graph = null;
71
- const searchRelative = `${toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "")}/search-index.json`;
102
+ const searchRelative = `${cacheRelative}/search-index.json`;
72
103
  const searchText = await readText(searchPath, null);
73
104
  let searchIndex = null;
74
105
  if (searchText !== null) {
@@ -91,15 +122,30 @@ async function loadSearchDataLocked(root, lock) {
91
122
  return { index, lexicon, searchIndex, graph };
92
123
  }
93
124
 
125
+ function isUsablePrimaryIndex(index, repositoryId) {
126
+ if (!index || index.schemaVersion !== 1 || index.repositoryId !== repositoryId || !Array.isArray(index.cards)) return false;
127
+ const ids = new Set();
128
+ for (const card of index.cards) {
129
+ if (!card || typeof card.id !== "string" || !card.id || typeof card.role !== "string" || ids.has(card.id)) return false;
130
+ ids.add(card.id);
131
+ for (const field of ["search", "owns", "excludes", "invariant", "effect", "risk", "rel"]) {
132
+ if (card[field] !== undefined && (!Array.isArray(card[field]) || card[field].some((value) => typeof value !== "string"))) return false;
133
+ }
134
+ }
135
+ return true;
136
+ }
137
+
94
138
  export async function queryProject(root, query, options = {}) {
95
139
  const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
96
140
  return queryPreparedIndex(index, searchIndex, query, { ...options, lexicon, graph });
97
141
  }
98
142
 
99
- export async function createProjectSession(root) {
100
- let snapshot = await loadSessionSnapshot(root);
143
+ export async function createProjectSession(root, options = {}) {
144
+ const withPromptBundle = options.withPromptBundle === true;
145
+ let snapshot = await loadSessionSnapshot(root, { withPromptBundle });
101
146
  const session = {
102
147
  root,
148
+ ...(withPromptBundle ? { promptBundle: snapshot.promptBundle, generationHash: snapshot.generationHash } : {}),
103
149
  query(query, options = {}) {
104
150
  return structuredClone(queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
105
151
  ...options,
@@ -114,36 +160,74 @@ export async function createProjectSession(root) {
114
160
  return buildSnapshotContext(snapshot, id, options);
115
161
  },
116
162
  async refresh() {
117
- snapshot = await loadSessionSnapshot(root);
163
+ const next = await loadSessionSnapshot(root, { withPromptBundle });
164
+ snapshot = next;
165
+ if (withPromptBundle) {
166
+ session.promptBundle = next.promptBundle;
167
+ session.generationHash = next.generationHash;
168
+ }
118
169
  return session;
119
170
  },
120
171
  };
121
172
  return session;
122
173
  }
123
174
 
124
- async function loadSessionSnapshot(root) {
125
- const snapshot = await loadProjectSnapshot(root);
175
+ async function loadSessionSnapshot(root, options = {}) {
176
+ const snapshot = await loadProjectSnapshot(root, options);
126
177
  // Only session-owned graphs are cached: public query inputs may be mutable.
127
178
  if (snapshot.graph) sessionGraphAdjacencyCache.set(snapshot.graph, buildGraphAdjacency(snapshot.graph));
128
- sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph));
179
+ sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph, true));
129
180
  return snapshot;
130
181
  }
131
182
 
132
- function prepareSearchMetadata(index, lexicon, graph) {
183
+ function prepareSearchMetadata(index, lexicon, graph, forSession = false) {
184
+ const normalizedIds = index.cards.map((card) => normalizeSearchText(card.id));
133
185
  return {
134
186
  byId: new Map(index.cards.map((card) => [card.id, card])),
135
187
  cardOrder: new Map(index.cards.map((card, position) => [card.id, position])),
136
- normalizedIds: index.cards.map((card) => normalizeSearchText(card.id)),
188
+ normalizedIds,
189
+ idCandidates: forSession ? buildIdCandidates(normalizedIds) : null,
137
190
  aliases: Object.entries(lexicon.aliases ?? {}).map(([alias, targets]) => [alias, targets, normalizeSearchText(alias)]),
138
191
  graphCompatible: isCompatibleRepositoryGraph(graph, index.repositoryId),
139
192
  };
140
193
  }
141
194
 
142
- async function loadProjectSnapshot(root) {
195
+ function buildIdCandidates(normalizedIds) {
196
+ const exact = new Map();
197
+ const grams = new Map();
198
+ for (const [position, id] of normalizedIds.entries()) {
199
+ if (!exact.has(id)) exact.set(id, []);
200
+ exact.get(id).push(position);
201
+ const seen = new Set();
202
+ for (let offset = 0; offset <= id.length - 3; offset += 1) {
203
+ const gram = id.slice(offset, offset + 3);
204
+ if (seen.has(gram)) continue;
205
+ seen.add(gram);
206
+ if (!grams.has(gram)) grams.set(gram, []);
207
+ grams.get(gram).push(position);
208
+ }
209
+ }
210
+ return { exact, grams };
211
+ }
212
+
213
+ function idCandidatePositions(prepared, normalizedQuery) {
214
+ if (!prepared.idCandidates) return prepared.normalizedIds.keys();
215
+ if (normalizedQuery.length < 3) return prepared.idCandidates.exact.get(normalizedQuery) ?? [];
216
+ let smallest = null;
217
+ for (let offset = 0; offset <= normalizedQuery.length - 3; offset += 1) {
218
+ const positions = prepared.idCandidates.grams.get(normalizedQuery.slice(offset, offset + 3));
219
+ if (!positions) return [];
220
+ if (!smallest || positions.length < smallest.length) smallest = positions;
221
+ }
222
+ return smallest ?? [];
223
+ }
224
+
225
+ async function loadProjectSnapshot(root, options = {}) {
143
226
  return withGenerationLock(root, async (lock) => {
144
227
  const { index, lexicon, searchIndex, graph } = await loadSearchDataLocked(root, lock);
145
228
  const registry = await loadRegistry(root);
146
- return { index, lexicon, searchIndex, graph, registry };
229
+ const prompt = options.withPromptBundle ? await loadPromptPrefixBundleLocked(root, lock) : null;
230
+ return { index, lexicon, searchIndex, graph, registry, promptBundle: prompt?.bundle, generationHash: prompt?.generationHash };
147
231
  });
148
232
  }
149
233
 
@@ -196,17 +280,19 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
196
280
  }
197
281
  }
198
282
 
199
- for (const [position, card] of index.cards.entries()) {
283
+ for (const position of idCandidatePositions(prepared, normalizedQuery)) {
200
284
  if (metrics) metrics.idDocumentsScanned += 1;
285
+ const card = index.cards[position];
201
286
  const normalizedId = prepared.normalizedIds[position];
202
287
  if (normalizedId === normalizedQuery) {
203
288
  addScore(resultsById, card, 1000, "exact semantic ID");
204
289
  } else if (normalizedId.includes(normalizedQuery) && normalizedQuery.length > 2) {
205
290
  addScore(resultsById, card, 100, "semantic ID phrase");
206
291
  }
207
- if (aliasTargets.has(card.id)) {
208
- addScore(resultsById, card, 500, aliasReasons.get(card.id) ?? []);
209
- }
292
+ }
293
+ for (const target of aliasTargets) {
294
+ const card = byId.get(target);
295
+ if (card) addScore(resultsById, card, 500, aliasReasons.get(target) ?? []);
210
296
  }
211
297
 
212
298
  const preparedSearchIndex = prepareCompactSearchIndex(searchIndex);
@@ -423,21 +509,21 @@ function buildSnapshotContext(snapshot, id, options = {}) {
423
509
  const resolved = resolveRegistryId(registry, id);
424
510
  if (resolved.state === "active" && index.cards.some((card) => card.id === resolved.id)) rootId = resolved.id;
425
511
  if (resolved.state === "ambiguous") {
426
- throw new Error(`Ambiguous replaced semantic ID ${id}; choose one of ${resolved.candidates.join(", ")}.`);
512
+ throw new SemanticIdLookupError("ambiguous", `Ambiguous replaced semantic ID ${id}; choose one of ${resolved.candidates.join(", ")}.`);
427
513
  }
428
- if (resolved.state === "cycle") throw new Error(`Registry cycle prevents resolving semantic ID ${id}.`);
514
+ if (resolved.state === "cycle") throw new SemanticIdLookupError("registry_cycle", `Registry cycle prevents resolving semantic ID ${id}.`);
429
515
  }
430
516
  if (isCompatibleRepositoryGraph(graph, index.repositoryId)) {
431
517
  const resolution = rootId
432
518
  ? resolveGraphNode(graph, `${index.repositoryId}/${rootId}`, index.repositoryId)
433
519
  : resolveGraphNode(graph, id, index.repositoryId);
434
520
  if (resolution.state === "ambiguous") {
435
- throw new Error(`Ambiguous semantic ID ${id}; qualify one of ${resolution.candidates.join(", ")}.`);
521
+ throw new SemanticIdLookupError("ambiguous", `Ambiguous semantic ID ${id}; qualify one of ${resolution.candidates.join(", ")}.`);
436
522
  }
437
- if (resolution.state !== "resolved") throw new Error(`Unknown or inactive semantic ID ${id}.`);
523
+ if (resolution.state !== "resolved") throw new SemanticIdLookupError("not_found", `Unknown or inactive semantic ID ${id}.`);
438
524
  return buildGraphContext(index, graph, resolution.node, { depth, budget, maxEdges });
439
525
  }
440
- if (!rootId) throw new Error(`Unknown or inactive semantic ID ${id}.`);
526
+ if (!rootId) throw new SemanticIdLookupError("not_found", `Unknown or inactive semantic ID ${id}.`);
441
527
  return buildLegacyContext(index, rootId, { depth, budget, maxEdges });
442
528
  }
443
529
 
package/src/spec.js CHANGED
@@ -10,7 +10,7 @@ rel=workflow>llmnav.rules.validate
10
10
  stability=contract
11
11
  */
12
12
 
13
- export const PACKAGE_VERSION = "0.9.1";
13
+ export const PACKAGE_VERSION = "0.9.7";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);