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 +12 -0
- package/README.md +3 -1
- package/ROADMAP.md +1 -1
- package/docs/api.md +2 -0
- package/docs/compatibility.md +40 -0
- package/package.json +1 -1
- package/src/agents.js +7 -7
- package/src/cli.js +1 -1
- package/src/formatter.js +1 -1
- package/src/incremental.js +1 -1
- package/src/initializer.js +2 -2
- package/src/registry.js +1 -1
- package/src/spec.js +1 -1
- package/src/transaction.js +4 -4
- package/src/util.js +28 -5
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.
|
|
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
|
|
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
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 };
|
package/src/incremental.js
CHANGED
|
@@ -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) {
|
package/src/initializer.js
CHANGED
|
@@ -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.
|
|
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"]);
|
package/src/transaction.js
CHANGED
|
@@ -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
|
-
|
|
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);
|