llmnav 0.7.0 → 0.7.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,18 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.2] — 2026-08-13
10
+
11
+ ### Added
12
+
13
+ * Documented pre-1.0 stability classes, a one-minor and 90-day public API deprecation window, generated-format migration guarantees, and the limited security exception.
14
+
15
+ ## [0.7.1] — 2026-08-13
16
+
17
+ ### Fixed
18
+
19
+ * Revalidated managed-write parent identity and symbolic-link traversal immediately before atomic replacement, preventing a concurrent directory swap from redirecting source formatting or control-file writes outside the repository.
20
+
9
21
  ## [0.7.0] — 2026-08-13
10
22
 
11
23
  ### Added
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.6 provides
9
+ ## What v0.7 provides
10
10
 
11
11
  * The backward-compatible `llmnav/1` source comment specification
12
12
  * A parser and data-loss-resistant canonical formatter
@@ -36,6 +36,8 @@ LLMNav is not a documentation generator, an embedding database, or a reason to a
36
36
 
37
37
  The package supports Node.js 22 or newer, uses ESM, performs no network requests, and has no runtime dependencies.
38
38
 
39
+ Public compatibility rules are documented in [Compatibility and deprecation policy](docs/compatibility.md). In short, `llmnav/1` source cards and the schemaVersion 1 primary index are stable contracts; disposable generated accelerators may be rebuilt, and documented CLI or library removals receive a replacement and a minimum one-minor/90-day deprecation window.
40
+
39
41
  ## Install
40
42
 
41
43
  ```sh
package/ROADMAP.md CHANGED
@@ -89,7 +89,7 @@ The source grammar and generated formats will be declared stable only after use
89
89
 
90
90
  In progress:
91
91
 
92
- * Generated-format migration tooling is implemented for the current public formats; compatibility guarantees still need a documented support and deprecation policy.
92
+ * Generated-format migration tooling and the public support and deprecation policy are implemented for the current formats.
93
93
  * A read-only cross-repository conformance matrix measures validation, retrieval, audit, repeatability, and cache freshness without averaging weak repositories away.
94
94
  * LLMNav, Workduck, Sairon, and AI BOM Generator currently pass repository-isolated conformance checks across JavaScript, TypeScript, Rust, Go, and Python.
95
95
  * The current evidence covers 4 repositories and 5 required languages with no held or failed repository, while 1.0 still requires sustained Windows and Linux verification and published benchmark methodology.
package/docs/api.md CHANGED
@@ -306,3 +306,5 @@ The typed `llmnav/examples/provider-neutral-host.mjs` export composes these APIs
306
306
  ## Compatibility boundary
307
307
 
308
308
  The public API follows package semantic versioning. `index.json` schemaVersion 1 and `llmnav/1` source syntax remain compatible. Contract fingerprints are optional additive index fields. `search-index.json`, `file-state.json`, `graph-state.json`, transaction journals, and performance metrics retain their own schema or implementation versions.
309
+
310
+ See [Compatibility and deprecation policy](compatibility.md) for the pre-1.0 stability classes, removal window, migration guarantees, and security exception.
@@ -0,0 +1,40 @@
1
+ # Compatibility and deprecation policy
2
+
3
+ This policy applies to LLMNav releases before 1.0 and defines which surfaces are durable enough for repositories and integrations to depend on.
4
+
5
+ ## Stability classes
6
+
7
+ | Surface | Stability before 1.0 | Compatibility rule |
8
+ | --- | --- | --- |
9
+ | `llmnav/1` source cards | Stable protocol | Existing valid cards keep their meaning. A breaking grammar or semantic change requires a new protocol header such as `llmnav/2`; LLMNav will not reinterpret `llmnav/1` incompatibly. |
10
+ | `.llmnav/cache/index.json` schemaVersion 1 | Stable primary generated contract | Additive optional fields are allowed. Removing or changing an existing field requires a new schema version and a migration path. |
11
+ | CLI commands, flags, exit codes, JSON fields, ESM exports, and TypeScript declarations documented in this repository | SemVer public API | Compatible additions may ship in a minor release. Removal or incompatible behavior requires a minor release before 1.0, a documented replacement, and the deprecation window below. |
12
+ | Diagnostic codes | Stable identifiers | A code may gain clearer wording, but its documented category and remediation meaning remain compatible. Retiring a code follows the deprecation window. |
13
+ | Search ranking and performance | Behavioral contract | Determinism and published regression thresholds are protected. Exact scores or ordering may change in a minor release when benchmarks justify the change and the changelog explains it. |
14
+ | `search-index.json`, `file-state.json`, `graph-state.json`, prompt bundles, shards, transaction journals, locks, and other generated accelerators | Disposable versioned formats | Consumers must not treat these as source truth. LLMNav may replace an incompatible format after `migrate --check`; it must rebuild or fail closed rather than silently misread it. |
15
+ | Undocumented internals and test helpers | Unstable | They may change in any release. Importing source files outside the documented package exports is unsupported. |
16
+
17
+ Package versions follow Semantic Versioning. Before 1.0, an incompatible change to a documented package API may use a minor version, while patches remain backward compatible. Protocol and generated-schema versions are independent from the npm package version.
18
+
19
+ ## Deprecation window
20
+
21
+ For a documented CLI, JSON, ESM, or TypeScript surface:
22
+
23
+ 1. The introducing release documents the deprecated surface, its replacement, and any automated migration.
24
+ 2. The old surface remains functional for at least one subsequent minor release and for at least 90 days. Removal waits until both conditions are satisfied.
25
+ 3. `llmnav doctor`, `check`, or `migrate --check` reports a stable diagnostic when repository action is required. A warning must not silently become destructive behavior.
26
+ 4. The removal release records the change in `CHANGELOG.md` and `docs/migration.md`.
27
+
28
+ Immediate removal is reserved for an actively exploitable security issue or behavior that can corrupt source or repository state. The security release must explain the exception and provide the safest available migration.
29
+
30
+ ## Upgrade guarantees
31
+
32
+ * Canonical source cards and `.llmnav/ids.jsonl` remain the recovery authority; disposable caches do not.
33
+ * `migrate --check` is read-only. `migrate --write` validates canonical source before mutation and publishes a complete recoverable cache transaction.
34
+ * A supported upgrade path covers the latest release of the previous minor line to the current release. Skipping several minor lines may require running the newest migration tool directly, but must not require installing every intermediate package version.
35
+ * Downgrade compatibility is not guaranteed for generated caches. Restore caches by regenerating them with the target version; never hand-edit generated schema versions.
36
+ * Node.js runtime support follows the declared `engines` range. Dropping a supported Node.js major is an incompatible documented API change and follows the deprecation window unless that runtime is no longer receiving security updates.
37
+
38
+ ## 1.0 commitment
39
+
40
+ At 1.0, incompatible changes to documented package APIs require a new major package version. The `llmnav/1` and primary-index rules above already apply and do not wait for 1.0. The remaining 1.0 gates are sustained cross-platform evidence, published benchmark methodology, and closure of high-severity parser or transaction ambiguity.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "A deterministic semantic navigation layer for LLM coding agents.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/agents.js CHANGED
@@ -42,19 +42,19 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
42
42
  if (adapter === "agents") {
43
43
  const target = path.join(root, "AGENTS.md");
44
44
  await assertNoSymlinkTraversal(root, target, "AGENTS.md");
45
- if (await upsertMarkdown(target, "# Repository instructions", AGENT_PROTOCOL)) {
45
+ if (await upsertMarkdown(root, target, "# Repository instructions", AGENT_PROTOCOL)) {
46
46
  changed.push("AGENTS.md");
47
47
  }
48
48
  } else if (adapter === "claude") {
49
49
  const target = path.join(root, "CLAUDE.md");
50
50
  await assertNoSymlinkTraversal(root, target, "CLAUDE.md");
51
- if (await upsertMarkdown(target, "# Claude Code instructions", AGENT_PROTOCOL)) {
51
+ if (await upsertMarkdown(root, target, "# Claude Code instructions", AGENT_PROTOCOL)) {
52
52
  changed.push("CLAUDE.md");
53
53
  }
54
54
  } else if (adapter === "copilot") {
55
55
  const target = path.join(root, ".github", "copilot-instructions.md");
56
56
  await assertNoSymlinkTraversal(root, target, ".github/copilot-instructions.md");
57
- if (await upsertMarkdown(target, "# GitHub Copilot instructions", AGENT_PROTOCOL)) {
57
+ if (await upsertMarkdown(root, target, "# GitHub Copilot instructions", AGENT_PROTOCOL)) {
58
58
  changed.push(".github/copilot-instructions.md");
59
59
  }
60
60
  } else if (adapter === "cursor") {
@@ -63,7 +63,7 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
63
63
  const content = `---\ndescription: Use LLMNav before broad codebase exploration\nalwaysApply: true\n---\n\n${AGENT_PROTOCOL}\n`;
64
64
  const existing = await readText(target, "");
65
65
  if (existing !== content) {
66
- await atomicWrite(target, content);
66
+ await atomicWrite(root, target, content);
67
67
  changed.push(".cursor/rules/llmnav.mdc");
68
68
  }
69
69
  }
@@ -73,7 +73,7 @@ export async function installAgentInstructions(root, adapters = ["agents"]) {
73
73
  await assertNoSymlinkTraversal(root, canonicalPath, ".llmnav/AGENT_INSTRUCTIONS.md");
74
74
  const canonical = `# LLMNav agent protocol\n\n${AGENT_PROTOCOL}\n`;
75
75
  if ((await readText(canonicalPath, "")) !== canonical) {
76
- await atomicWrite(canonicalPath, canonical);
76
+ await atomicWrite(root, canonicalPath, canonical);
77
77
  changed.push(".llmnav/AGENT_INSTRUCTIONS.md");
78
78
  }
79
79
  return changed;
@@ -102,7 +102,7 @@ function adapterUsageError(message) {
102
102
  return error;
103
103
  }
104
104
 
105
- async function upsertMarkdown(filePath, title, block) {
105
+ async function upsertMarkdown(root, filePath, title, block) {
106
106
  await mkdir(path.dirname(filePath), { recursive: true });
107
107
  const existing = await readText(filePath, "");
108
108
  let next;
@@ -124,6 +124,6 @@ async function upsertMarkdown(filePath, title, block) {
124
124
  next = `${existing.trimEnd()}\n\n${block}\n`;
125
125
  }
126
126
  if (next === existing) return false;
127
- await atomicWrite(filePath, next);
127
+ await atomicWrite(root, filePath, next);
128
128
  return true;
129
129
  }
package/src/cli.js CHANGED
@@ -339,7 +339,7 @@ async function runAudit(root, args, json) {
339
339
  ? { schemaVersion: result.schemaVersion, repositoryId: result.repositoryId, summary: result.summary, failOn }
340
340
  : report;
341
341
  if (outputPath) {
342
- await atomicWrite(outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
342
+ await atomicWrite(root, outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
343
343
  }
344
344
  if (json) {
345
345
  console.log(JSON.stringify(outputPath
package/src/formatter.js CHANGED
@@ -27,7 +27,7 @@ export async function formatProject(root, options = {}) {
27
27
  errors.push(...result.errors.map((error) => ({ file: relativePath, ...error })));
28
28
  if (!result.changed) continue;
29
29
  changedFiles.push(relativePath);
30
- if (!options.check) await atomicWrite(filePath, result.source);
30
+ if (!options.check) await atomicWrite(root, filePath, result.source, options.atomicWriteOptions);
31
31
  }
32
32
  const ok = errors.length === 0 && (options.check ? changedFiles.length === 0 : true);
33
33
  return { ok, changedFiles, errors };
@@ -165,7 +165,7 @@ export function buildFileStateFromProject(project) {
165
165
 
166
166
  export async function persistStatHints(root, hintsPath, statHints) {
167
167
  await assertNoSymlinkTraversal(root, hintsPath, relativePosix(root, hintsPath));
168
- await atomicWrite(hintsPath, stableStringify(statHints));
168
+ await atomicWrite(root, hintsPath, stableStringify(statHints));
169
169
  }
170
170
 
171
171
  export function renderFileState(fileState) {
@@ -123,7 +123,7 @@ async function writeIfMissingOrForced(root, filePath, content, force, changed, d
123
123
  const existing = await readText(filePath, null);
124
124
  if (existing !== null && !force) return;
125
125
  if (existing === content) return;
126
- await atomicWrite(filePath, content);
126
+ await atomicWrite(root, filePath, content);
127
127
  changed.push(displayPath);
128
128
  }
129
129
 
@@ -147,6 +147,6 @@ async function addPackageScripts(root) {
147
147
  parsed.scripts[name] = command;
148
148
  changed = true;
149
149
  }
150
- if (changed) await atomicWrite(packagePath, `${JSON.stringify(parsed, null, 2)}\n`);
150
+ if (changed) await atomicWrite(root, packagePath, `${JSON.stringify(parsed, null, 2)}\n`);
151
151
  return changed;
152
152
  }
package/src/registry.js CHANGED
@@ -79,7 +79,7 @@ export async function ensureActiveIds(root, registry, ids) {
79
79
  }
80
80
  await assertNoSymlinkTraversal(root, registryPath, ".llmnav/ids.jsonl");
81
81
  const { records, changed } = mergeActiveIds(registry, ids);
82
- if (changed) await atomicWrite(registryPath, renderRegistryRecords(records));
82
+ if (changed) await atomicWrite(root, registryPath, renderRegistryRecords(records));
83
83
  return { records, changed };
84
84
  }
85
85
 
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.7.0";
13
+ export const PACKAGE_VERSION = "0.7.2";
14
14
  export const SPEC_VERSION = "1";
15
15
 
16
16
  export const SCOPES = Object.freeze(["file", "module", "symbol"]);
@@ -152,7 +152,7 @@ export async function commitGeneratedCache(root, cacheDirectory, artifacts, opti
152
152
  controlArtifacts: controlRecords,
153
153
  phase: "prepared",
154
154
  };
155
- await atomicWrite(journalPath, stableStringify(journal));
155
+ await atomicWrite(root, journalPath, stableStringify(journal));
156
156
  await invokeFailpoint("after-journal", options);
157
157
 
158
158
  if (hadExistingCache) {
@@ -160,19 +160,19 @@ export async function commitGeneratedCache(root, cacheDirectory, artifacts, opti
160
160
  }
161
161
  await moveControlArtifactsToBackup(root, controlRecords, options.renameOptions);
162
162
  journal = { ...journal, phase: "old-moved" };
163
- await atomicWrite(journalPath, stableStringify(journal));
163
+ await atomicWrite(root, journalPath, stableStringify(journal));
164
164
  await invokeFailpoint("after-cache-moved", options);
165
165
 
166
166
  await renameWithRetry(stagePath, cachePath, options.renameOptions);
167
167
  await installControlArtifacts(root, controlRecords, options.renameOptions);
168
168
  journal = { ...journal, phase: "new-installed" };
169
- await atomicWrite(journalPath, stableStringify(journal));
169
+ await atomicWrite(root, journalPath, stableStringify(journal));
170
170
  await verifyCommittedCache(cachePath, cacheRelative);
171
171
  await verifyControlArtifacts(root, controlRecords);
172
172
  await invokeFailpoint("after-new-installed", options);
173
173
 
174
174
  journal = { ...journal, phase: "committed" };
175
- await atomicWrite(journalPath, stableStringify(journal));
175
+ await atomicWrite(root, journalPath, stableStringify(journal));
176
176
  if (hadExistingCache) await removeWithRetry(backupPath, options.renameOptions);
177
177
  await removeWithRetry(transactionPath, options.renameOptions);
178
178
  await removeWithRetry(journalPath, options.renameOptions);
package/src/util.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
 
5
5
  export function normalizeNewlines(value) {
@@ -137,18 +137,41 @@ export async function readJsonSafe(filePath, fallback = null) {
137
137
  }
138
138
  }
139
139
 
140
- export async function atomicWrite(filePath, content) {
141
- await mkdir(path.dirname(filePath), { recursive: true });
140
+ export async function atomicWrite(root, filePath, content, options = {}) {
141
+ const parent = path.dirname(filePath);
142
+ const label = options.label ?? relativePosix(root, filePath);
143
+ await assertNoSymlinkTraversal(root, filePath, label);
144
+ await mkdir(parent, { recursive: true });
145
+ const parentIdentity = await directoryIdentity(root, parent, label);
142
146
  const temporaryPath = `${filePath}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
143
- await writeFile(temporaryPath, content, "utf8");
147
+ await writeFile(temporaryPath, content, { encoding: "utf8", flag: "wx" });
144
148
  try {
149
+ await options.beforeCommit?.({ filePath, temporaryPath });
150
+ await assertNoSymlinkTraversal(root, filePath, label);
151
+ const currentIdentity = await directoryIdentity(root, parent, label);
152
+ if (currentIdentity !== parentIdentity) throw new Error(`${label} parent directory changed during atomic write.`);
145
153
  await rename(temporaryPath, filePath);
146
154
  } catch (error) {
147
- await rm(temporaryPath, { force: true });
155
+ if (await parentHasIdentity(root, parent, parentIdentity)) await rm(temporaryPath, { force: true });
148
156
  throw error;
149
157
  }
150
158
  }
151
159
 
160
+ async function directoryIdentity(root, directory, label) {
161
+ await assertNoSymlinkTraversal(root, directory, label);
162
+ const [details, canonical] = await Promise.all([lstat(directory), realpath(directory)]);
163
+ if (!details.isDirectory()) throw new Error(`${label} parent is not a directory.`);
164
+ return `${details.dev}:${details.ino}:${path.normalize(canonical)}`;
165
+ }
166
+
167
+ async function parentHasIdentity(root, parent, expected) {
168
+ try {
169
+ return (await directoryIdentity(root, parent, parent)) === expected;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
152
175
  export async function assertNoSymlinkTraversal(root, targetPath, label = targetPath) {
153
176
  const rootPath = path.resolve(root);
154
177
  const target = path.resolve(targetPath);