llmnav 0.9.0 → 0.9.2

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
@@ -6,6 +6,32 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.2] — 2026-09-09
10
+
11
+ ### Fixed
12
+
13
+ * 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.
14
+ * Invalidate older parsed-file caches so unchanged consumer files receive the corrected parsing behavior.
15
+
16
+ ## [0.9.1] — 2026-09-07
17
+
18
+ ### Fixed
19
+
20
+ * Explain unsupported or disallowed hard-link lock publication, preserve its filesystem error, and verify installed host show, bounded context, and refresh operations.
21
+ * Prevent inherited npm `allow-scripts` environment policy from breaking isolated package installation smoke checks; installation lifecycle scripts remain disabled.
22
+ * Synchronize the public package-version constant with 0.9.1 and check both npm manifests in the normal test suite.
23
+ * Publish complete generation lock records atomically, clean up failed preparation, and explain repair of unreadable legacy locks.
24
+ * Hold the generation lock throughout search and registry snapshot reads.
25
+ * Apply context edge limits when falling back to semantic relations without a usable graph.
26
+
27
+ ### Performance
28
+
29
+ * Distribute search postings to card-range shards in one pass while preserving byte-identical manifests and artifacts.
30
+ * Prepare card lookups, normalized IDs and aliases, and graph validation once per session snapshot; detach query and show results to protect cached state from caller mutation.
31
+ * Measure fresh-process session startup, graph-aware query and context latency, and refresh separately, with result parity checks against direct navigation APIs.
32
+ * Reuse graph adjacency and traversal ordering within project sessions; refresh rebuilds that state while direct query APIs continue to accept mutable graph inputs.
33
+ * Count removed graph partitions with keyed membership instead of scanning all current partitions for each previous key.
34
+
9
35
  ## [0.9.0] — 2026-08-15
10
36
 
11
37
  ### Added
package/docs/api.md CHANGED
@@ -302,6 +302,10 @@ await session.refresh(); // after generation or checkout changes
302
302
 
303
303
  `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
304
 
305
+ 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
+
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.
308
+
305
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.
306
310
 
307
311
  `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`.
@@ -197,6 +197,12 @@ Generation never mutates the live cache file by file. It builds a complete repla
197
197
 
198
198
  The complete source scan and cache commit are serialized by `.llmnav/generation.lock`. The lock records a process and opaque owner ID. A reader waits for a live owner to finish and only then evaluates recovery, while an abandoned lock from a dead process can be removed without granting another process authority over an active journal.
199
199
 
200
+ Readers hold that lock until all snapshot artifacts, including the ID registry for sessions, show, and context, have been read. Lock ownership is published with an exclusive hard link to a fully written, synced, and closed candidate file in the same directory; the filesystem must support hard links. Failed preparation cannot leave an empty authoritative lock. Unused candidate files confer no ownership. An unreadable lock left by an older version is not automatically stolen: stop all LLMNav processes, remove `.llmnav/generation.lock`, then retry. A valid abandoned transaction is still recovered normally.
201
+
202
+ If hard-link publication fails with `ENOSYS`, `ENOTSUP`, `EOPNOTSUPP`, `EPERM`, or `EXDEV`, the error explains the filesystem and permission requirements and retains the original cause. The unpublished candidate is cleaned up; the lock is not downgraded to a non-atomic write. `EPERM` can indicate a permission restriction, not only a filesystem capability gap.
203
+
204
+ When no compatible graph is available, context falls back to semantic relations and still limits traversal with `maxEdges`, including zero. The token budget separately bounds rendered output.
205
+
200
206
  ```text
201
207
  write every staged artifact
202
208
  verify exact staged bytes
@@ -63,6 +63,55 @@ LLMNAV_BENCH_FILES=500 LLMNAV_BENCH_QUERY_RUNS=5 npm run benchmark:v0.2
63
63
 
64
64
  Published repository results use the defaults. The report states that each query starts a fresh Node.js process and that the operating-system filesystem cache is not flushed. That distinction prevents a warm page cache from being mislabeled as cold disk I/O.
65
65
 
66
+ ## Graph-aware session lifecycle
67
+
68
+ `npm run benchmark:navigation` generates a temporary 100-file repository with 1,000 cards and three outgoing semantic relations per card. A fresh worker process measures initial session loading, the first query, 50 repeated queries, 50 bounded graph-context requests, and refresh with unchanged source. Three direct `queryProject` calls provide a filesystem-loading comparison. The report includes medians, p95, memory, operation counters, fixture sizes, and result digests.
69
+
70
+ The operating-system filesystem cache is not flushed. Module import and process launch costs are excluded from `initialSessionMs` but included in the total `workerWallMs`, which also includes reference checks. Fixture generation is outside both measurements. Query scores and reasons must match the direct graph-aware index path; context must include the requested root and graph evidence within its edge and token limits. CI uses a 100-card fixture to validate these contracts without imposing a machine-specific timing threshold.
71
+
72
+
73
+ ### Local baseline before session metadata preparation
74
+
75
+ Measured on 2026-09-07 with runtime source based on `5b0d837`: Windows x64, Node v24.18.0, AMD Ryzen 5 7430U with Radeon Graphics. This is one local run with an unflushed filesystem cache, not a production latency guarantee.
76
+
77
+ | Phase | Median or single sample (ms) | p95 (ms) |
78
+ | --- | ---: | ---: |
79
+ | Initial session load | 141.335 | — |
80
+ | First query | 11.113 | — |
81
+ | Repeated session query (50) | 3.859 | 7.833 |
82
+ | Repeated session context (50) | 2.870 | 5.484 |
83
+ | Refresh unchanged source | 70.303 | — |
84
+ | Direct query with disk loading (3) | 90.443 | 120.859 |
85
+
86
+ All 50 query rankings, scores and reasons matched the direct index path. All 50 contexts met the graph and token bounds. Query work still included 50,000 ID scans and 50,000 phrase-document scans; caching adjacency does not eliminate those passes.
87
+
88
+ Result digests for comparison with subsequent optimization:
89
+
90
+ ```text
91
+ query d7545f3781f705eb2fdb5c7deb14004c7d74f1b9fc81fb21314e00f3f276e272
92
+ context 051db2dc2b802eca7a5b3a04a08c38b447ba95659ebc2a8eef2e9b6fb0e1207b
93
+ ```
94
+
95
+ ### Local session metadata result
96
+
97
+ On the same fixture and environment, one local run after session metadata preparation measured query median/p95 at 2.883/5.796 ms, with identical query and context digests above. Across 50 queries, ID normalizations, lookup-table builds, and graph validations were all zero; ID and phrase scans remain 50,000 each. The optimization does not narrow substring candidates or change ranking.
98
+
99
+ Initial loading was 159.667 ms, first query 21.485 ms, refresh 114.610 ms, context median/p95 3.617/8.979 ms, and direct-query median 102.542 ms. Preparation shifts work to loading/refresh and result detachment adds copying. These single-run timings include local load variation; they establish neither a startup improvement nor a production speed guarantee.
100
+
101
+ ## Card-range shard generation
102
+
103
+ `npm run benchmark:shards` compares one-pass distribution with the pre-optimization per-shard filtering implementation on 2,000 cards and 44,286 postings. It checks exact manifest and artifact bytes. A separate regression instruments posting reads and requires one visit per posting, independent of shard count; the reference visits every posting once per shard.
104
+
105
+ One Windows x64 / Node v24.18.0 run on 2026-09-07, including serialization but excluding index construction:
106
+
107
+ | Shard count | Reference (ms) | One pass (ms) |
108
+ | --- | ---: | ---: |
109
+ | 100 | 503.116 | 169.912 |
110
+ | 20 | 247.831 | 170.843 |
111
+ | 4 | 157.325 | 191.673 |
112
+
113
+ These are single in-process samples, with the reference first, not production guarantees. Few shards can be slower because distribution adds bookkeeping. Posting distribution changes from shard-count multiplied scans to one pass, but serialization still costs the output size. Partitioned posting pairs temporarily occupy memory alongside serialized shards; completed partitions are released as serialization progresses. This benchmark does not measure peak memory. Sharding remains disabled by default.
114
+
66
115
  ## Measurement integrity
67
116
 
68
117
  A benchmark result is accepted only after these checks pass:
@@ -79,6 +128,8 @@ Wall-clock speed is reported exactly as measured. It is not used to invent a spe
79
128
 
80
129
  ## Repository search evaluation
81
130
 
131
+ The [historical navigation replay pilot](navigation-replay.md) compares six fixed task paraphrases with regex file discovery. It reports candidate ranks and explicit unmeasured agent metrics; it is not a substitute for end-to-end trials.
132
+
82
133
  Add real task descriptions to `.llmnav/eval/queries.jsonl`.
83
134
 
84
135
  ```jsonl
@@ -0,0 +1,44 @@
1
+ # Historical navigation replay pilot
2
+
3
+ ## Result
4
+
5
+ On 2026-09-08, six curated historical changes were replayed against runtime commit `d30d80015ea10ea43a0875bc329669fa4019a457`. Both methods included the expected source file in their first five candidates for all six cases. LLMNav ranked four expected files first; the regex baseline placed one first in alphabetical file order. These are different ordering policies, not an agent success-rate comparison.
6
+
7
+ | Historical task | Source change | Expected source | Regex rank / candidates | LLMNav rank / candidates |
8
+ | --- | --- | --- | ---: | ---: |
9
+ | Filesystem capability required by generation locking | `5b0d837` | `src/transaction.js` | 2 / 2 | 2 / 20 |
10
+ | Context still follows edges at a zero edge limit | `34f56a6` | `src/search.js` | 4 / 4 | 2 / 23 |
11
+ | Explain suppressed annotation-audit candidates | `592849f` | `src/audit.js` | 3 / 6 | 1 / 17 |
12
+ | Resolve a Go module import to its package | `fcd40cb` | `src/module-resolution.js` | 3 / 3 | 1 / 23 |
13
+ | Upgrade generated formats without partial cache state | `839ab1c` | `src/migration.js` | 5 / 6 | 1 / 25 |
14
+ | Remove repeated posting scans during sharding | `d30d800` | `src/search-shards.js` | 1 / 1 | 1 / 23 |
15
+
16
+ Candidate counts include the complete positive-result list, not just the first five displayed paths. LLMNav returns more broad matches, while known technical keywords can narrow regex discovery much further. The two non-first LLMNav results put `src/audit.js` ahead of the locking implementation and `src/graph.js` ahead of the context-limit implementation. No query, expected target, annotation, or rank weight was tuned after this run.
17
+
18
+ ## Reproduce
19
+
20
+ From the Git repository, run:
21
+
22
+ ```sh
23
+ node benchmarks/navigation-replay.js
24
+ ```
25
+
26
+ The script performs no model calls, network requests, source edits, or cache generation. It requires an existing generated LLMNav index and the referenced Git history. Expected targets must both exist in current source and occur in the cited historical change. The output includes source, harness, and case hashes so a rerun can distinguish changed inputs from changed ranking. Refresh the index through the normal generation workflow before comparing a later source revision.
27
+
28
+ Both output scopes contain 37 tracked JavaScript files under `src/` and `bin/`. The baseline performs one fixed case-insensitive line-regex search per case and sorts matching paths alphabetically. LLMNav performs one query, preserves score order, deduplicates paths, and restricts output to that same source scope. Its scoring still uses the complete repository index before filtering. Source annotations remain visible to the baseline. The script refuses a truncated 100-card result instead of treating hidden hits as misses.
29
+
30
+ Input identities for the recorded run:
31
+
32
+ ```text
33
+ harness 706834dc4a0faf0b5111f11dc854d929f5c84a4b6757542bd3a98041d0b47f33
34
+ cases c23e467540d03544a68a4ffabc9cb4c91c2ecea87f655f6e3b55257adf404364
35
+ source 2a8e5f8e2bff1f6236bd2117998d134a44cacb728d1667a5f3fb747a7fff8c7b
36
+ ```
37
+
38
+ ## Evidence boundary and decision
39
+
40
+ The cases are author-curated English paraphrases of known changes, not verbatim user requests, representative traffic, Korean-language coverage, or a sealed holdout. They run on already corrected current source, not the pre-fix repository. The author knew the expected paths while constructing both symptom queries and regexes. This is a diagnostic retrieval pilot, not a claim that LLMNav outperforms a developer adaptively using `rg`.
41
+
42
+ Each method performs one search per case, but actual agent tool calls, files opened, uncached tokens, elapsed task time, edit correctness, and task completion are **unmeasured** and reported as `null`, not inferred from candidate rank. No full agent trace was captured. The pilot therefore does not complete the end-to-end evaluation described in [benchmarking](benchmarking.md).
43
+
44
+ The existing changes were delivered and their Windows/Linux CI passed before this pilot. No runtime optimization is added on the strength of these results: they do not establish that `show` or `context` latency blocks development work. Further session lookup caching and shard-memory work remain deferred. The existing ranking and substring-search behavior are unchanged.
@@ -1,18 +1,41 @@
1
1
  # Publishing `llmnav` to npm
2
2
 
3
- The repository is release-ready except for owner-specific metadata and npm account configuration.
3
+ Maintainers publish this repository through the existing tag-triggered GitHub Actions workflow. Initial repository and npm account setup belongs to the separate bootstrap section below; it is not part of every release.
4
4
 
5
- ## 1. Claim the names
5
+ ## Routine release
6
6
 
7
- Create the GitHub repository named `llmnav`, then check the npm registry immediately before the first release:
7
+ 1. Confirm the intended version agrees across `package.json`, `package-lock.json`, `src/spec.js`, and the changelog. Review the exact diff and keep unrelated changes out of the release.
8
+ 2. Check the remote tag and npm version before creating anything. Authentication or network errors mean unknown state, not an unpublished version. Never move an existing release tag or overwrite a published version.
9
+ 3. Validate the exact payload using the commands below. Reuse passing checks only while their relevant inputs remain unchanged.
10
+ 4. Commit and push the release changes to `main`. Require successful Windows/Linux and package CI for that exact commit, not a previous commit.
11
+ 5. Create an annotated version tag on the verified commit and push only that tag. The workflow handles npm publication and GitHub Release creation.
12
+ 6. Verify the tag commit, release workflow, npm version and integrity, provenance, GitHub Release, and an installation of the published package. Publication success and branch CI are separate checks.
8
13
 
9
14
  ```sh
10
- npm view llmnav
15
+ npm run check
16
+ npm run release:check
17
+ npm run smoke:pack
18
+ npm pack --dry-run
19
+ ```
20
+
21
+ If dependencies are missing or the lockfile changed, install them through the approved dependency workflow before these checks. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmarks, and development scripts remain in GitHub rather than the installed package.
22
+
23
+ After choosing the version, replace the placeholder in both tag commands:
24
+
25
+ ```sh
26
+ git tag -a vX.Y.Z -m "llmnav vX.Y.Z"
27
+ git push origin refs/tags/vX.Y.Z
11
28
  ```
12
29
 
13
- An `E404` means no public package is visible through the registry you queried at that moment. It is not a reservation. The name remains claimable by someone else until publication succeeds.
30
+ The workflow rejects a tag that does not match `package.json` or the protected `origin/main` lineage. If the version already exists in npm, it succeeds only when the registry integrity matches the tagged payload. A mismatch fails closed and requires a new version, not a forced tag. GitHub Release creation runs only after publication or matching-integrity verification succeeds.
31
+
32
+ If publication is interrupted, inspect the existing tag, registry artifact, and workflow result before retrying. Preserve the version and tag when they already identify the intended artifact; do not assume a failed workflow means nothing was published.
33
+
34
+ ## One-time bootstrap for a new repository or publisher
35
+
36
+ ### Claim names and set owner metadata
14
37
 
15
- ## 2. Replace release metadata
38
+ Before a first publication, create the GitHub repository and check npm name availability. An `E404` means no public package is visible at that moment, not that the name is reserved. Do not repeat owner replacement for routine releases of this configured repository.
16
39
 
17
40
  Update only the owner-specific URLs in `package.json` and `.github/ISSUE_TEMPLATE/config.yml`.
18
41
  Do not run a repository-wide replacement: the release checker and doctor intentionally keep the literal `OWNER` sentinel in source code.
@@ -29,27 +52,15 @@ Then replace `OWNER` only in `.github/ISSUE_TEMPLATE/config.yml` and run:
29
52
  npm run release:check
30
53
  ```
31
54
 
32
- ## 3. Configure npm authentication
55
+ ### Configure npm authentication
33
56
 
34
57
  The supplied release workflow uses GitHub's OIDC token through npm Trusted Publishers. The public GitHub source repository enables provenance in both `package.json` and the workflow, and the release check fails if either surface disables it. The same tag workflow creates an idempotent GitHub Release only after the npm registry check or publication succeeds.
35
58
 
36
- The workflow references a GitHub environment named `npm`. Create that environment for release protection, or remove the `environment` line when no environment gate is desired.
59
+ The workflow references a GitHub environment named `npm`. Configure that environment and its protection rules as part of bootstrap. Do not remove an existing approval gate merely to unblock a release.
37
60
 
38
61
  A classic or granular access token can be used for a manual first publication. Do not commit `.npmrc` credentials or an npm token. npm may still require browser-backed two-factor authentication.
39
62
 
40
- ## 4. Validate the exact package payload
41
-
42
- ```sh
43
- npm ci
44
- npm run check
45
- npm run release:check
46
- npm run smoke:pack
47
- npm pack --dry-run
48
- ```
49
-
50
- Inspect the tarball list. The package intentionally includes the CLI, source API, type declarations, schema, templates, documentation, the typed provider-neutral host example, README, changelog, roadmap, and license. Tests, benchmark harnesses, and development scripts remain in GitHub but are not installed into consumer projects. `npm run smoke:pack` verifies the exact tarball and example export in a clean temporary project.
51
-
52
- ## 5. Publish the first release
63
+ ### Manual first publication, only when required
53
64
 
54
65
  Manual publication:
55
66
 
@@ -58,22 +69,17 @@ npm login
58
69
  npm publish --provenance --access public
59
70
  ```
60
71
 
61
- Automated publication:
62
-
63
- ```sh
64
- git tag vX.Y.Z
65
- git push origin vX.Y.Z
66
- ```
72
+ ## Verify a published version from a clean directory
67
73
 
68
- The release workflow rejects a tag that does not match `package.json`. If the exact version is already present in npm, the workflow succeeds only when the registry tarball integrity matches the tagged package; a mismatched package fails closed. It then reuses an existing GitHub Release for the tag or creates one with generated release notes. Registry, GitHub API, and Release creation errors other than an expected missing Release fail the workflow.
74
+ Use an explicit published version instead of the moving default tag. The temporary project must be outside the source repository. Do not commit installed files or credentials.
69
75
 
70
- ## 6. Verify from a clean directory
76
+ From a source checkout, `npm run smoke:pack -- --published=X.Y.Z` runs the existing CLI/API/host assertions against that exact npm version in a temporary project with lifecycle scripts disabled. Without the option, it tests a locally packed tarball instead.
71
77
 
72
78
  ```sh
73
79
  mkdir llmnav-smoke
74
80
  cd llmnav-smoke
75
81
  npm init -y
76
- npm install --save-dev llmnav
82
+ npm install --save-dev --ignore-scripts llmnav@X.Y.Z
77
83
  npx llmnav --version
78
84
  npx llmnav init --agents all --package-scripts
79
85
  npx llmnav doctor
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,8 @@
48
48
  "release:tag": "node ./scripts/verify-tag.js",
49
49
  "test:performance": "node --test tests/performance.test.js",
50
50
  "benchmark:v0.2": "node ./benchmarks/run-v0.2.js",
51
+ "benchmark:navigation": "node ./benchmarks/navigation.js",
52
+ "benchmark:shards": "node ./benchmarks/search-shards.js",
51
53
  "smoke:pack": "node ./scripts/pack-smoke.js"
52
54
  },
53
55
  "keywords": [
package/src/graph.js CHANGED
@@ -79,6 +79,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
79
79
  }
80
80
 
81
81
  partitions.sort((left, right) => compareText(left.key, right.key));
82
+ const partitionKeys = new Set(partitions.map((partition) => partition.key));
82
83
  const nodes = new Map();
83
84
  const edges = new Map();
84
85
  for (const partition of partitions) {
@@ -115,7 +116,7 @@ export function buildRepositoryGraphIncremental(project, index, previousState =
115
116
  totalPartitions: partitions.length,
116
117
  reusedPartitions,
117
118
  rebuiltPartitions,
118
- removedPartitions: [...previousPartitions.keys()].filter((key) => !partitions.some((item) => item.key === key)).length,
119
+ removedPartitions: [...previousPartitions.keys()].filter((key) => !partitionKeys.has(key)).length,
119
120
  },
120
121
  };
121
122
  }
@@ -27,7 +27,7 @@ import {
27
27
  } from "./util.js";
28
28
 
29
29
  export const FILE_STATE_SCHEMA_VERSION = 1;
30
- export const SOURCE_INDEXER_VERSION = 6;
30
+ export const SOURCE_INDEXER_VERSION = 7;
31
31
  const STAT_HINTS_SCHEMA_VERSION = 1;
32
32
 
33
33
  export async function scanProjectIncremental(root, options = {}) {
package/src/index.d.ts CHANGED
@@ -231,6 +231,9 @@ export interface SearchMetrics {
231
231
  phraseDocumentsScanned?: number;
232
232
  idDocumentsScanned?: number;
233
233
  graphEdgesVisited?: number;
234
+ idNormalizations?: number;
235
+ lookupBuilds?: number;
236
+ graphValidations?: number;
234
237
  }
235
238
 
236
239
  export interface SearchResult {
@@ -244,7 +247,7 @@ export interface SearchResult {
244
247
 
245
248
  export interface ProjectSession {
246
249
  root: string;
247
- query(query: string, options?: { top?: number }): SearchResult[];
250
+ query(query: string, options?: { top?: number; metrics?: SearchMetrics }): SearchResult[];
248
251
  show(id: string): { card: IndexedCard | null; node: GraphNode | null; resolvedFrom: unknown };
249
252
  context(id: string, options?: { depth?: number; budget?: number; maxEdges?: number }): { id: string; depth: number; budget: number; maxEdges: number; included: string[]; includedEdges: string[]; text: string };
250
253
  refresh(): Promise<ProjectSession>;
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,6 +222,12 @@ 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;
222
233
  const mask = new Uint8Array(source.length);
@@ -260,6 +271,21 @@ function buildLiteralMask(source, filePath) {
260
271
  }
261
272
  continue;
262
273
  }
274
+ if (state === "regex") {
275
+ if (character === "\n" || character === "\r") {
276
+ state = "normal";
277
+ escaped = false;
278
+ } else if (escaped) escaped = false;
279
+ else if (character === "\\") escaped = true;
280
+ else if (character === "[") regexCharacterClass = true;
281
+ else if (character === "]") regexCharacterClass = false;
282
+ else if (character === "/" && !regexCharacterClass) {
283
+ state = "normal";
284
+ regexAllowed = false;
285
+ previousToken = "value";
286
+ }
287
+ continue;
288
+ }
263
289
  if (state === "single" || state === "double" || state === "backtick") {
264
290
  if (escaped) {
265
291
  escaped = false;
@@ -270,7 +296,11 @@ function buildLiteralMask(source, filePath) {
270
296
  continue;
271
297
  }
272
298
  const terminator = state === "single" ? "'" : state === "double" ? '"' : "`";
273
- if (character === terminator) state = "normal";
299
+ if (character === terminator) {
300
+ state = "normal";
301
+ regexAllowed = false;
302
+ previousToken = "value";
303
+ }
274
304
  continue;
275
305
  }
276
306
 
@@ -283,6 +313,10 @@ function buildLiteralMask(source, filePath) {
283
313
  } else if (character === "/" && next === "/") {
284
314
  state = "line-comment";
285
315
  index += 1;
316
+ } else if (javascript && character === "/" && regexAllowed) {
317
+ state = "regex";
318
+ regexCharacterClass = false;
319
+ escaped = false;
286
320
  } else if (hashComments && character === "#") {
287
321
  state = "line-comment";
288
322
  } else if (dashComments && character === "-" && next === "-") {
@@ -303,6 +337,40 @@ function buildLiteralMask(source, filePath) {
303
337
  } else if (character === "`") {
304
338
  state = "backtick";
305
339
  escaped = false;
340
+ } else if (javascript && !/\s/u.test(character)) {
341
+ // Track lexical expression position without rescanning prefixes. Comments do
342
+ // not change it; a control-condition ')' permits a following regex statement.
343
+ if (/[A-Za-z_$]/u.test(character)) {
344
+ const start = index;
345
+ const memberName = previousToken === ".";
346
+ while (index + 1 < source.length && /[\w$]/u.test(source[index + 1])) index += 1;
347
+ previousToken = source.slice(start, index + 1);
348
+ regexAllowed = !memberName && REGEX_PREFIX_KEYWORDS.has(previousToken);
349
+ } else if (character === "(") {
350
+ controlParentheses.push(CONTROL_PAREN_KEYWORDS.has(previousToken));
351
+ regexAllowed = true;
352
+ previousToken = character;
353
+ } else if (character === ")") {
354
+ regexAllowed = controlParentheses.pop() === true;
355
+ previousToken = character;
356
+ } else if (character === "{") {
357
+ expressionBraces.push(regexAllowed && !["", ";", "else", "do", "try", "finally", ")"].includes(previousToken));
358
+ regexAllowed = true;
359
+ previousToken = character;
360
+ } else if (character === "}") {
361
+ regexAllowed = expressionBraces.pop() !== true;
362
+ previousToken = character;
363
+ } else if ((character === "+" || character === "-") && next === character) {
364
+ previousToken = character + next;
365
+ index += 1;
366
+ } else if (character === "=" && next === ">") {
367
+ regexAllowed = true;
368
+ previousToken = "=>";
369
+ index += 1;
370
+ } else {
371
+ regexAllowed = "=(:,!&|?;[+-*%~^>/".includes(character);
372
+ previousToken = character;
373
+ }
306
374
  }
307
375
  }
308
376
 
@@ -24,20 +24,26 @@ export function buildSearchShards(index, searchIndex, shardSize) {
24
24
  const cardsById = new Map(index.cards.map((card) => [card.id, card]));
25
25
  const shards = new Map();
26
26
  const records = [];
27
+ const partitions = Array.from({ length: Math.ceil(searchIndex.cardIds.length / shardSize) }, () => ({ tokens: [], postings: [] }));
28
+ for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
29
+ const selectedByShard = new Map();
30
+ for (const [cardIndex, vector] of searchIndex.postings[tokenIndex] ?? []) {
31
+ if (!(cardIndex >= 0 && cardIndex < searchIndex.cardIds.length)) continue;
32
+ const ordinal = Math.floor(cardIndex / shardSize);
33
+ const selected = selectedByShard.get(ordinal) ?? [];
34
+ selected.push([cardIndex - ordinal * shardSize, vector]);
35
+ selectedByShard.set(ordinal, selected);
36
+ }
37
+ for (const [ordinal, selected] of selectedByShard) {
38
+ partitions[ordinal].tokens.push(token);
39
+ partitions[ordinal].postings.push(selected);
40
+ }
41
+ }
27
42
  for (let start = 0, ordinal = 0; start < searchIndex.cardIds.length; start += shardSize, ordinal += 1) {
28
43
  const end = Math.min(start + shardSize, searchIndex.cardIds.length);
29
44
  const cardIds = searchIndex.cardIds.slice(start, end);
30
45
  const cards = cardIds.map((id) => cardsById.get(id)).filter(Boolean);
31
- const tokens = [];
32
- const postings = [];
33
- for (const [tokenIndex, token] of searchIndex.tokens.entries()) {
34
- const selected = (searchIndex.postings[tokenIndex] ?? [])
35
- .filter(([cardIndex]) => cardIndex >= start && cardIndex < end)
36
- .map(([cardIndex, vector]) => [cardIndex - start, vector]);
37
- if (selected.length === 0) continue;
38
- tokens.push(token);
39
- postings.push(selected);
40
- }
46
+ const { tokens, postings } = partitions[ordinal];
41
47
  const shard = {
42
48
  ...searchIndex,
43
49
  cardSetHash: searchCardSetHash(cards),
@@ -49,6 +55,7 @@ export function buildSearchShards(index, searchIndex, shardSize) {
49
55
  };
50
56
  const file = `search-shards/${String(ordinal).padStart(4, "0")}.json`;
51
57
  const content = stableStringify(shard);
58
+ partitions[ordinal] = null;
52
59
  shards.set(file, content);
53
60
  records.push({
54
61
  file,
package/src/search.js CHANGED
@@ -23,15 +23,21 @@ import {
23
23
  verifySearchIndex,
24
24
  } from "./inverted-index.js";
25
25
  import { normalizeSearchText, tokenize } from "./tokenizer.js";
26
- import { recoverGenerationTransaction } from "./transaction.js";
26
+ import { recoverGenerationTransaction, withGenerationLock } from "./transaction.js";
27
27
  import { isCompatibleRepositoryGraph, renderGraphNode, resolveGraphNode } from "./graph.js";
28
28
 
29
29
  const preparedIndexCache = new WeakMap();
30
30
  const preparedSearchIndexCache = new WeakMap();
31
+ const sessionGraphAdjacencyCache = new WeakMap();
32
+ const sessionSearchMetadataCache = new WeakMap();
31
33
 
32
34
  export async function loadSearchData(root) {
35
+ return withGenerationLock(root, (lock) => loadSearchDataLocked(root, lock));
36
+ }
37
+
38
+ async function loadSearchDataLocked(root, lock) {
33
39
  const { config } = await loadConfig(root);
34
- await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory });
40
+ await recoverGenerationTransaction(root, { cacheDirectory: config.generation.cacheDirectory, lockOwnerId: lock.ownerId });
35
41
  const cacheRoot = path.join(root, config.generation.cacheDirectory);
36
42
  await assertNoSymlinkTraversal(root, cacheRoot, config.generation.cacheDirectory);
37
43
  const indexPath = path.join(cacheRoot, "index.json");
@@ -91,34 +97,54 @@ export async function queryProject(root, query, options = {}) {
91
97
  }
92
98
 
93
99
  export async function createProjectSession(root) {
94
- let snapshot = await loadProjectSnapshot(root);
100
+ let snapshot = await loadSessionSnapshot(root);
95
101
  const session = {
96
102
  root,
97
103
  query(query, options = {}) {
98
- return queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
104
+ return structuredClone(queryPreparedIndex(snapshot.index, snapshot.searchIndex, query, {
99
105
  ...options,
100
106
  lexicon: snapshot.lexicon,
101
107
  graph: snapshot.graph,
102
- });
108
+ }));
103
109
  },
104
110
  show(id) {
105
- return showSnapshotCard(snapshot, id);
111
+ return structuredClone(showSnapshotCard(snapshot, id));
106
112
  },
107
113
  context(id, options = {}) {
108
114
  return buildSnapshotContext(snapshot, id, options);
109
115
  },
110
116
  async refresh() {
111
- snapshot = await loadProjectSnapshot(root);
117
+ snapshot = await loadSessionSnapshot(root);
112
118
  return session;
113
119
  },
114
120
  };
115
121
  return session;
116
122
  }
117
123
 
124
+ async function loadSessionSnapshot(root) {
125
+ const snapshot = await loadProjectSnapshot(root);
126
+ // Only session-owned graphs are cached: public query inputs may be mutable.
127
+ if (snapshot.graph) sessionGraphAdjacencyCache.set(snapshot.graph, buildGraphAdjacency(snapshot.graph));
128
+ sessionSearchMetadataCache.set(snapshot.index, prepareSearchMetadata(snapshot.index, snapshot.lexicon, snapshot.graph));
129
+ return snapshot;
130
+ }
131
+
132
+ function prepareSearchMetadata(index, lexicon, graph) {
133
+ return {
134
+ byId: new Map(index.cards.map((card) => [card.id, card])),
135
+ cardOrder: new Map(index.cards.map((card, position) => [card.id, position])),
136
+ normalizedIds: index.cards.map((card) => normalizeSearchText(card.id)),
137
+ aliases: Object.entries(lexicon.aliases ?? {}).map(([alias, targets]) => [alias, targets, normalizeSearchText(alias)]),
138
+ graphCompatible: isCompatibleRepositoryGraph(graph, index.repositoryId),
139
+ };
140
+ }
141
+
118
142
  async function loadProjectSnapshot(root) {
119
- const { index, lexicon, searchIndex, graph } = await loadSearchData(root);
120
- const registry = await loadRegistry(root);
121
- return { index, lexicon, searchIndex, graph, registry };
143
+ return withGenerationLock(root, async (lock) => {
144
+ const { index, lexicon, searchIndex, graph } = await loadSearchDataLocked(root, lock);
145
+ const registry = await loadRegistry(root);
146
+ return { index, lexicon, searchIndex, graph, registry };
147
+ });
122
148
  }
123
149
 
124
150
  export function queryIndex(index, query, options = {}) {
@@ -139,11 +165,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
139
165
  const metrics = options.metrics ?? null;
140
166
  const normalizedQuery = normalizeSearchText(query);
141
167
  const queryTokens = tokenize(query);
142
- const aliases = Object.entries(lexicon.aliases ?? {});
168
+ const cached = sessionSearchMetadataCache.get(index);
169
+ const prepared = cached ?? prepareSearchMetadata(index, lexicon, options.graph);
170
+ const aliases = prepared.aliases;
143
171
  const aliasTargets = new Set();
144
172
  const aliasReasons = new Map();
145
- const byId = new Map(index.cards.map((card) => [card.id, card]));
146
- const cardOrder = new Map(index.cards.map((card, cardIndex) => [card.id, cardIndex]));
173
+ const { byId, cardOrder } = prepared;
147
174
  const resultsById = new Map();
148
175
 
149
176
  if (metrics) {
@@ -153,10 +180,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
153
180
  metrics.phraseDocumentsScanned = 0;
154
181
  metrics.idDocumentsScanned = 0;
155
182
  metrics.graphEdgesVisited = 0;
183
+ metrics.idNormalizations = cached ? 0 : index.cards.length;
184
+ metrics.lookupBuilds = cached ? 0 : 2;
185
+ metrics.graphValidations = cached ? 0 : 1;
156
186
  }
157
187
 
158
- for (const [alias, targetValue] of aliases) {
159
- const normalizedAlias = normalizeSearchText(alias);
188
+ for (const [alias, targetValue, normalizedAlias] of aliases) {
160
189
  if (!normalizedAlias || !normalizedQuery.includes(normalizedAlias)) continue;
161
190
  const targets = Array.isArray(targetValue) ? targetValue : [targetValue];
162
191
  for (const target of targets) {
@@ -167,11 +196,12 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
167
196
  }
168
197
  }
169
198
 
170
- for (const card of index.cards) {
199
+ for (const [position, card] of index.cards.entries()) {
171
200
  if (metrics) metrics.idDocumentsScanned += 1;
172
- if (normalizeSearchText(card.id) === normalizedQuery) {
201
+ const normalizedId = prepared.normalizedIds[position];
202
+ if (normalizedId === normalizedQuery) {
173
203
  addScore(resultsById, card, 1000, "exact semantic ID");
174
- } else if (normalizeSearchText(card.id).includes(normalizedQuery) && normalizedQuery.length > 2) {
204
+ } else if (normalizedId.includes(normalizedQuery) && normalizedQuery.length > 2) {
175
205
  addScore(resultsById, card, 100, "semantic ID phrase");
176
206
  }
177
207
  if (aliasTargets.has(card.id)) {
@@ -215,7 +245,7 @@ export function queryPreparedIndex(index, searchIndex, query, options = {}) {
215
245
  const seeds = [...results]
216
246
  .sort((left, right) => right.score - left.score || (cardOrder.get(left.card.id) ?? 0) - (cardOrder.get(right.card.id) ?? 0))
217
247
  .slice(0, 3);
218
- if (isCompatibleRepositoryGraph(options.graph, index.repositoryId)) {
248
+ if (prepared.graphCompatible) {
219
249
  applyGraphBonuses(index, options.graph, seeds, byId, resultsById, metrics);
220
250
  } else {
221
251
  applyLegacyRelationBonuses(seeds, byId, resultsById);
@@ -491,8 +521,9 @@ function buildLegacyContext(index, rootId, options) {
491
521
  const queue = [{ id: rootId, depth: 0 }];
492
522
  const visited = new Set();
493
523
  const selected = [];
494
- while (queue.length > 0) {
495
- const current = queue.shift();
524
+ let traversedEdges = 0;
525
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
526
+ const current = queue[cursor];
496
527
  if (!current || visited.has(current.id)) continue;
497
528
  visited.add(current.id);
498
529
  const card = byId.get(current.id);
@@ -500,10 +531,16 @@ function buildLegacyContext(index, rootId, options) {
500
531
  selected.push(card);
501
532
  if (current.depth >= depth) continue;
502
533
  for (const relation of card.rel ?? []) {
534
+ if (traversedEdges >= maxEdges) break;
503
535
  const target = relation.slice(relation.indexOf(">") + 1);
504
536
  queue.push({ id: target, depth: current.depth + 1 });
537
+ traversedEdges += 1;
538
+ }
539
+ for (const source of reverse.get(card.id) ?? []) {
540
+ if (traversedEdges >= maxEdges) break;
541
+ queue.push({ id: source, depth: current.depth + 1 });
542
+ traversedEdges += 1;
505
543
  }
506
- for (const source of reverse.get(card.id) ?? []) queue.push({ id: source, depth: current.depth + 1 });
507
544
  }
508
545
 
509
546
  const header = `llmnav-context/1 root=${rootId} depth=${depth}\n`;
@@ -575,6 +612,8 @@ function applyLegacyRelationBonuses(seeds, byId, resultsById) {
575
612
  }
576
613
 
577
614
  function buildGraphAdjacency(graph) {
615
+ const cached = sessionGraphAdjacencyCache.get(graph);
616
+ if (cached) return cached;
578
617
  const adjacency = new Map();
579
618
  for (const edge of [...graph.edges].sort(compareGraphEdgesForTraversal)) {
580
619
  appendGraphNeighbor(adjacency, edge.from, { neighbor: edge.to, direction: "out", edge });
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.0";
13
+ export const PACKAGE_VERSION = "0.9.2";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);
@@ -9,7 +9,7 @@ stability=architecture
9
9
  */
10
10
 
11
11
  import { randomBytes } from "node:crypto";
12
- import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
12
+ import { link, lstat, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { setTimeout as sleep } from "node:timers/promises";
15
15
  import {
@@ -32,6 +32,7 @@ const DEFAULT_RETRY_DELAYS = Object.freeze([0, 8, 16, 32, 64, 128, 256, 512]);
32
32
  const JOURNAL_PHASES = new Set(["prepared", "old-moved", "new-installed", "committed"]);
33
33
  const DEFAULT_LOCK_TIMEOUT_MS = 30_000;
34
34
  const DEFAULT_LOCK_POLL_MS = 50;
35
+ const LOCK_LINK_CAPABILITY_ERRORS = new Set(["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM", "EXDEV"]);
35
36
  const CONTROL_ARTIFACT_PATHS = new Set([".llmnav/ids.jsonl", ".llmnav/order.lock"]);
36
37
 
37
38
  export async function withGenerationLock(root, callback, options = {}) {
@@ -54,28 +55,50 @@ export async function acquireGenerationLock(root, options = {}) {
54
55
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
55
56
  const delays = options.delays ?? [0, ...Array.from({ length: Math.ceil(timeoutMs / pollMs) }, () => pollMs)];
56
57
  const openImpl = options.openImpl ?? open;
58
+ const linkImpl = options.linkImpl ?? link;
57
59
  const sleepImpl = options.sleepImpl ?? sleep;
58
-
59
- for (const delay of delays) {
60
- if (delay > 0) await sleepImpl(delay);
60
+ const candidatePath = `${lockPath}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`;
61
+ // Publish only a closed, complete owner record. A crash before publication
62
+ // leaves an unused candidate, never an unreadable authoritative lock.
63
+ let candidateCreated = false;
64
+ try {
65
+ const handle = await openImpl(candidatePath, "wx");
66
+ candidateCreated = true;
61
67
  try {
62
- const handle = await openImpl(lockPath, "wx");
68
+ await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
69
+ await handle.sync();
70
+ } finally {
71
+ await handle.close();
72
+ }
73
+
74
+ for (const delay of delays) {
75
+ if (delay > 0) await sleepImpl(delay);
63
76
  try {
64
- await handle.writeFile(stableStringify({ schemaVersion: 1, ownerId, pid: process.pid }));
65
- await handle.sync();
66
- } finally {
67
- await handle.close();
68
- }
69
- return { root, lockPath, ownerId };
70
- } catch (error) {
71
- if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
72
- const existing = await readJsonSafe(lockPath, null);
73
- if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
74
- await removeOwnedLock(lockPath, existing.ownerId);
77
+ await linkImpl(candidatePath, lockPath);
78
+ return { root, lockPath, ownerId };
79
+ } catch (error) {
80
+ if (error && typeof error === "object" && LOCK_LINK_CAPABILITY_ERRORS.has(error.code)) {
81
+ throw new Error(
82
+ `Cannot publish the LLMNav generation lock (${error.code}). The repository filesystem must support hard links and permit their creation. Check filesystem permissions or move the repository to a filesystem with hard-link support.`,
83
+ { cause: error },
84
+ );
85
+ }
86
+ if (!error || typeof error !== "object" || error.code !== "EEXIST") throw error;
87
+ const existing = await readJsonSafe(lockPath, null);
88
+ if (existing && Number.isInteger(existing.pid) && !isProcessAlive(existing.pid)) {
89
+ await removeOwnedLock(lockPath, existing.ownerId);
90
+ }
75
91
  }
76
92
  }
93
+ const existing = await readJsonSafe(lockPath, null);
94
+ if (!existing || !Number.isInteger(existing.pid) || !existing.ownerId) {
95
+ throw new Error("The LLMNav generation lock has no valid owner record. After stopping all LLMNav processes, remove .llmnav/generation.lock and retry.");
96
+ }
97
+ throw new Error("Timed out waiting for the LLMNav generation lock.");
98
+ } finally {
99
+ // Candidate cleanup must not turn successful acquisition into a leaked lock.
100
+ if (candidateCreated) await removeWithRetry(candidatePath).catch(() => {});
77
101
  }
78
- throw new Error("Timed out waiting for the LLMNav generation lock.");
79
102
  }
80
103
 
81
104
  export async function releaseGenerationLock(lock) {