llmnav 0.6.6 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ The npm package follows Semantic Versioning. The `llmnav/N` source protocol is v
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] — 2026-08-13
10
+
11
+ ### Fixed
12
+
13
+ * 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.
14
+
15
+ ## [0.7.0] — 2026-08-13
16
+
17
+ ### Added
18
+
19
+ * Added `llmnav migrate --check/--write` and `migrateProject` to report generated-format compatibility and apply full transactional upgrades from canonical source.
20
+ * Added migration regression coverage for read-only planning, idempotent writes, validation blocking, and transaction rollback.
21
+
9
22
  ## [0.6.6] — 2026-08-12
10
23
 
11
24
  ### Added
package/README.md CHANGED
@@ -179,6 +179,7 @@ Line-comment cards require an explicit terminator and work with `//`, `#`, and `
179
179
  | `llmnav context` | Build a bounded context bundle around one ID |
180
180
  | `llmnav eval` | Run repository-specific search regression queries |
181
181
  | `llmnav doctor` | Verify installation, cache integrity, transaction recovery, and drift |
182
+ | `llmnav migrate` | Check or transactionally apply generated-format upgrades from canonical source |
182
183
  | `llmnav spec` | Print source-spec vocabularies and key order |
183
184
  | `llmnav tools` | Print stable provider-neutral agent tool schemas |
184
185
  | `llmnav bundle` | Inspect the generated prompt-prefix cache partitions |
@@ -291,7 +292,7 @@ The project uses the Node.js standard library and built-in test runner. There is
291
292
 
292
293
  ## Status
293
294
 
294
- LLMNav is an experimental protocol and a usable v0.6 CLI. The source format remains `llmnav/1`; npm package changes and source-grammar changes are versioned independently.
295
+ LLMNav is an experimental protocol and a usable v0.7 CLI. The source format remains `llmnav/1`; npm package changes and source-grammar changes are versioned independently.
295
296
 
296
297
  ## License
297
298
 
package/ROADMAP.md CHANGED
@@ -73,12 +73,23 @@ Implemented:
73
73
  * path-specific coverage-rule suggestions without automatic source annotation
74
74
  * audit guidance in generated agent instructions and package scripts
75
75
 
76
+ ## 0.7 — generated-format migration
77
+
78
+ Implemented:
79
+
80
+ * read-only compatibility reports for the primary index and generated accelerators
81
+ * full migration plans reconstructed from canonical repository source
82
+ * explicit transactional writes with staging verification, rollback, and interrupted-process recovery
83
+ * validation blocking before mutation and idempotent repeated writes
84
+ * public CLI, ESM API, TypeScript declarations, and migration documentation
85
+
76
86
  ## 1.0 criteria
77
87
 
78
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.
79
89
 
80
90
  In progress:
81
91
 
92
+ * Generated-format migration tooling is implemented for the current public formats; compatibility guarantees still need a documented support and deprecation policy.
82
93
  * A read-only cross-repository conformance matrix measures validation, retrieval, audit, repeatability, and cache freshness without averaging weak repositories away.
83
94
  * LLMNav, Workduck, Sairon, and AI BOM Generator currently pass repository-isolated conformance checks across JavaScript, TypeScript, Rust, Go, and Python.
84
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
@@ -229,6 +229,7 @@ The package exports versioned generated-format constants:
229
229
  import {
230
230
  CONTRACT_FINGERPRINT_SCHEMA_VERSION,
231
231
  FILE_STATE_SCHEMA_VERSION,
232
+ MIGRATION_REPORT_SCHEMA_VERSION,
232
233
  SEARCH_INDEX_ENCODING,
233
234
  SEARCH_INDEX_SCHEMA_VERSION,
234
235
  SEARCH_SHARD_ENCODING,
@@ -240,6 +241,8 @@ import {
240
241
  } from "llmnav";
241
242
  ```
242
243
 
244
+ `migrateProject(root, { write: false })` returns a schemaVersion 1 compatibility report and a full-regeneration plan without committing generated artifacts. With `{ write: true }`, it applies a required upgrade through the same generation lock and recoverable transaction used by `generateProject`. Source validation errors stop the operation before mutation, and successful repeated writes are no-ops.
245
+
243
246
  `diagnosticsToSarif(diagnostics)` maps existing diagnostics to a deterministic SARIF 2.1.0 object without discovering or mutating diagnostics.
244
247
 
245
248
  `diagnosticsToEditor(diagnostics)` groups repository-relative diagnostics into schemaVersion 1 documents with zero-based ranges and stable severity mappings. `renderEditorDiagnostics` serializes the report deterministically. `getEditorIntegration("vscode")` returns a VS Code task and custom problem matcher without modifying editor files.
package/docs/cli.md CHANGED
@@ -212,6 +212,19 @@ Checks:
212
212
 
213
213
  `doctor` may perform transaction recovery, but it does not regenerate stale cache content.
214
214
 
215
+ ## `llmnav migrate`
216
+
217
+ ```sh
218
+ llmnav migrate [--check] [--json]
219
+ llmnav migrate --write [--json]
220
+ ```
221
+
222
+ The default and `--check` modes inspect the primary index, search accelerator, file state, graph, graph state, prompt-prefix bundle, and manifest against the formats supported by the installed LLMNav version. They also perform a full source reconstruction in check mode. No generated artifacts are committed. Exit status 1 means migration is required or source validation blocks a safe migration.
223
+
224
+ `--write` first builds the same plan from canonical source. If source validation succeeds and migration is required, it performs a full regeneration through the normal generation lock, staging verification, directory transaction, rollback, and interrupted-process recovery boundaries. It never rewrites source cards or reassigns semantic IDs. Repeating `--write` after a successful migration makes no changes and exits successfully.
225
+
226
+ `--check` and `--write` are mutually exclusive. Review JSON `formats`, `changedFiles`, and `diagnostics` before applying an upgrade in automation.
227
+
215
228
  ## `llmnav spec`
216
229
 
217
230
  ```sh
package/docs/migration.md CHANGED
@@ -1,23 +1,24 @@
1
1
  # Gradual migration
2
2
 
3
- ## Upgrade to 0.5
3
+ ## Upgrade to 0.7
4
4
 
5
5
  No source-card migration is required. Keep every `llmnav/1` comment and the existing `.llmnav/cache/index.json` consumer contract.
6
6
 
7
7
  Upgrade and regenerate:
8
8
 
9
9
  ```sh
10
- npm install --save-dev llmnav@^0.6.0
10
+ npm install --save-dev llmnav@^0.7.0
11
11
  npx llmnav init --agents all
12
- npx llmnav generate
12
+ npx llmnav migrate --check
13
+ npx llmnav migrate --write
13
14
  npx llmnav doctor
14
15
  ```
15
16
 
16
- The regeneration preserves the schemaVersion 1 primary index and existing graph artifacts, then adds deterministic `prompt-prefix.json`. Initialization refreshes the managed agent protocol so structured-tool hosts learn the trusted-root boundary. Volatile `state/`, `.transactions/`, and `generation-transaction.json` remain ignored.
17
+ The migration planner preserves the `llmnav/1` source comments and schemaVersion 1 primary-index contract. It reports incompatible or missing generated formats before applying a full transactional regeneration from canonical source. Initialization refreshes the managed agent protocol so structured-tool hosts learn the trusted-root boundary. Volatile `state/`, `.transactions/`, and `generation-transaction.json` remain ignored.
17
18
 
18
- Review `generate --json` during the first upgrade. Existing cards are reported as added only when no previous compatible primary index exists. The prompt-prefix artifact appears in affected catalogs when its bytes change. `LNV009` remains a non-failing contract-fingerprint review signal.
19
+ Review `migrate --check --json` during the first upgrade. Exit status 1 means generated formats need migration or source validation prevents a safe write. `migrate --write` stages and verifies a complete replacement before swapping the cache, and a repeated write is a no-op. Existing cards are reported as added only when no previous compatible primary index exists. `LNV009` remains a non-failing contract-fingerprint review signal.
19
20
 
20
- Do not delete `index.json`, rewrite semantic IDs, or copy generated paths or graph edges into comments. v0.5 tool schemas, prompt partitions, editor diagnostics, and host examples are additive; no source-card migration is required.
21
+ Do not delete `index.json`, rewrite semantic IDs, or copy generated paths or graph edges into comments. The migration command never edits source cards or the semantic ID registry; it upgrades disposable generated formats from their canonical inputs.
21
22
 
22
23
  ## Do not annotate the whole repository
23
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llmnav",
3
- "version": "0.6.6",
3
+ "version": "0.7.1",
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
@@ -39,6 +39,7 @@ import { getAgentToolDefinitions } from "./agent-protocol.js";
39
39
  import { loadPromptPrefixBundle } from "./prompt-bundle.js";
40
40
  import { diagnosticsToEditor, getEditorIntegration } from "./editor.js";
41
41
  import { AUDIT_PRIORITIES, auditHasFindings, auditProject } from "./audit.js";
42
+ import { migrateProject } from "./migration.js";
42
43
 
43
44
  const VALUE_OPTIONS = new Set(["--root", "--format", "--top", "--depth", "--budget", "--max-edges", "--agents", "--file", "--fail-on", "--output"]);
44
45
 
@@ -53,6 +54,7 @@ const COMMAND_OPTIONS = Object.freeze({
53
54
  context: new Set(["--depth", "--budget", "--max-edges", "--root", "--json"]),
54
55
  eval: new Set(["--file", "--top", "--root", "--json"]),
55
56
  doctor: new Set(["--root", "--json"]),
57
+ migrate: new Set(["--check", "--write", "--root", "--json"]),
56
58
  audit: new Set(["--root", "--json", "--summary", "--fail-on", "--output"]),
57
59
  spec: new Set(["--root", "--json"]),
58
60
  tools: new Set(["--json"]),
@@ -100,6 +102,8 @@ export async function runCli(argv) {
100
102
  return runEval(root, args, json);
101
103
  case "doctor":
102
104
  return runDoctor(root, json);
105
+ case "migrate":
106
+ return runMigrate(root, args, json);
103
107
  case "audit":
104
108
  return runAudit(root, args, json);
105
109
  case "spec":
@@ -295,6 +299,26 @@ async function runDoctor(root, json) {
295
299
  return result.ok ? 0 : 1;
296
300
  }
297
301
 
302
+ async function runMigrate(root, args, json) {
303
+ if (hasFlag(args, "--check") && hasFlag(args, "--write")) {
304
+ throw usageError("migrate accepts either --check or --write, not both.");
305
+ }
306
+ const result = await migrateProject(root, { write: hasFlag(args, "--write") });
307
+ if (json) {
308
+ console.log(JSON.stringify(result, null, 2));
309
+ } else {
310
+ for (const format of result.formats) {
311
+ console.log(`${format.status} ${format.id}: ${format.path}${format.detail ? ` (${format.detail})` : ""}`);
312
+ }
313
+ if (result.diagnostics.length > 0) printDiagnostics(result.diagnostics, "text");
314
+ if (result.ok && result.applied) console.log(`Migrated ${result.changedFiles.length} generated file(s) transactionally.`);
315
+ else if (result.ok) console.log("Generated LLMNav formats are current.");
316
+ else if (result.required && result.mode === "check") console.log("Migration is required. Run llmnav migrate --write after reviewing this plan.");
317
+ else if (result.required) console.error("Migration was not applied because source validation failed.");
318
+ }
319
+ return result.ok ? 0 : 1;
320
+ }
321
+
298
322
  async function runAudit(root, args, json) {
299
323
  const failOn = getOption(args, "--fail-on") ?? "none";
300
324
  if (failOn !== "none" && !AUDIT_PRIORITIES.includes(failOn)) {
@@ -315,7 +339,7 @@ async function runAudit(root, args, json) {
315
339
  ? { schemaVersion: result.schemaVersion, repositoryId: result.repositoryId, summary: result.summary, failOn }
316
340
  : report;
317
341
  if (outputPath) {
318
- await atomicWrite(outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
342
+ await atomicWrite(root, outputPath, `${JSON.stringify(selectedReport, null, 2)}\n`);
319
343
  }
320
344
  if (json) {
321
345
  console.log(JSON.stringify(outputPath
@@ -492,6 +516,7 @@ Usage
492
516
  llmnav context <semantic-id> [--depth 1] [--budget 2500] [--max-edges 24]
493
517
  llmnav eval [--file path] [--top 5]
494
518
  llmnav doctor
519
+ llmnav migrate [--check|--write]
495
520
  llmnav audit [--summary] [--output path] [--fail-on none|high|medium|low]
496
521
  llmnav spec
497
522
  llmnav tools [--json]
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) {
package/src/index.d.ts CHANGED
@@ -561,6 +561,27 @@ export interface GenerationResult {
561
561
  transaction: TransactionResult;
562
562
  }
563
563
 
564
+ export interface MigrationFormatRecord {
565
+ id: string;
566
+ path: string;
567
+ status: "current" | "missing" | "incompatible";
568
+ expected: string;
569
+ detail: string | null;
570
+ }
571
+
572
+ export interface MigrationResult {
573
+ schemaVersion: 1;
574
+ ok: boolean;
575
+ mode: "check" | "write";
576
+ required: boolean;
577
+ applied: boolean;
578
+ changedFiles: string[];
579
+ formats: MigrationFormatRecord[];
580
+ previousFormats?: MigrationFormatRecord[];
581
+ diagnostics: Diagnostic[];
582
+ transaction: TransactionResult;
583
+ }
584
+
564
585
  export interface EvaluationResult {
565
586
  ok: boolean;
566
587
  errors: string[];
@@ -588,6 +609,7 @@ export const AGENT_OPERATION_SCHEMA_VERSION: 1;
588
609
  export const AGENT_TOOL_SCHEMA_VERSION: 1;
589
610
  export const BOUNDARY_KINDS: readonly DetectedBoundary["kind"][];
590
611
  export const GRAPH_INPUT_SCHEMA_VERSION: 1;
612
+ export const MIGRATION_REPORT_SCHEMA_VERSION: 1;
591
613
  export const EDITOR_DIAGNOSTIC_SCHEMA_VERSION: 1;
592
614
  export const EDITOR_INTEGRATION_SCHEMA_VERSION: 1;
593
615
  export const GRAPH_SCHEMA_VERSION: 1;
@@ -663,6 +685,7 @@ export function auditHasFindings(result: AuditResult, minimumPriority?: AuditPri
663
685
  export function findAttachedDeclaration(source: string, block: LlmnavBlock, filePath: string): Declaration | null;
664
686
  export function extractImports(source: string, filePath: string): string[];
665
687
  export function doctorProject(root: string): Promise<{ ok: boolean; checks: Array<{ name: string; ok: boolean; message: string }> }>;
688
+ 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>;
666
689
  export function evaluateProject(root: string, options?: { top?: number; file?: string }): Promise<EvaluationResult>;
667
690
  export function collectSourceFiles(root: string, config: LlmnavConfig, requestedPaths?: string[]): Promise<string[]>;
668
691
  export function findProjectRoot(start?: string): Promise<string>;
package/src/index.js CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  } from "./contracts.js";
50
50
  export { findAttachedDeclaration, extractImports } from "./declaration.js";
51
51
  export { doctorProject } from "./doctor.js";
52
+ export { MIGRATION_REPORT_SCHEMA_VERSION, migrateProject } from "./migration.js";
52
53
  export { evaluateProject } from "./evaluation.js";
53
54
  export { collectSourceFiles, findProjectRoot } from "./files.js";
54
55
  export { formatProject } from "./formatter.js";
@@ -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
  }
@@ -0,0 +1,194 @@
1
+ /* llmnav/1 module
2
+ id=llmnav.project.migrate
3
+ role=Plan and apply safe generated-format upgrades from canonical repository source.
4
+ owns=migration planning|format compatibility reporting|full transactional regeneration
5
+ excludes=source-card rewriting|semantic ID reassignment
6
+ search=llmnav migrate|cache format upgrade|migration dry run
7
+ invariant=Check mode never commits generated artifacts.
8
+ invariant=Write mode stops before mutation when canonical source validation fails.
9
+ rel=workflow>llmnav.index.generate
10
+ rel=workflow>llmnav.index.transaction
11
+ stability=contract
12
+ */
13
+
14
+ import path from "node:path";
15
+ import { loadConfig } from "./config.js";
16
+ import { generateProject } from "./generator.js";
17
+ import { compatibleGraphState, GRAPH_SCHEMA_VERSION, GRAPH_STATE_SCHEMA_VERSION, isCompatibleRepositoryGraph } from "./graph.js";
18
+ import { FILE_STATE_SCHEMA_VERSION, SOURCE_INDEXER_VERSION, usableFileState } from "./incremental.js";
19
+ import { isCompatibleSearchIndex, SEARCH_INDEX_ENCODING, SEARCH_INDEX_SCHEMA_VERSION } from "./inverted-index.js";
20
+ import { isCompatiblePromptPrefixBundle, PROMPT_BUNDLE_SCHEMA_VERSION } from "./prompt-bundle.js";
21
+ import { SEARCH_SHARD_SCHEMA_VERSION } from "./search-shards.js";
22
+ import { assertNoSymlinkTraversal, readText, toPosix } from "./util.js";
23
+
24
+ export const MIGRATION_REPORT_SCHEMA_VERSION = 1;
25
+
26
+ export async function migrateProject(root, options = {}) {
27
+ const write = options.write === true;
28
+ const { config } = await loadConfig(root);
29
+ const before = await inspectGeneratedFormats(root, config);
30
+ const plan = await generateProject(root, {
31
+ check: true,
32
+ incremental: false,
33
+ lockOptions: options.lockOptions,
34
+ renameOptions: options.renameOptions,
35
+ });
36
+ const required = plan.changedFiles.length > 0 || before.some((format) => format.status !== "current");
37
+ const blocked = plan.counts.error > 0;
38
+
39
+ if (!write || blocked || !required) {
40
+ return {
41
+ schemaVersion: MIGRATION_REPORT_SCHEMA_VERSION,
42
+ ok: !blocked && !required,
43
+ mode: write ? "write" : "check",
44
+ required,
45
+ applied: false,
46
+ changedFiles: plan.changedFiles,
47
+ formats: before,
48
+ diagnostics: plan.diagnostics,
49
+ transaction: plan.transaction,
50
+ };
51
+ }
52
+
53
+ const applied = await generateProject(root, {
54
+ check: false,
55
+ incremental: false,
56
+ failpoint: options.failpoint,
57
+ lockOptions: options.lockOptions,
58
+ renameOptions: options.renameOptions,
59
+ onTransactionPhase: options.onTransactionPhase,
60
+ });
61
+ const after = applied.ok ? await inspectGeneratedFormats(root, config) : before;
62
+ return {
63
+ schemaVersion: MIGRATION_REPORT_SCHEMA_VERSION,
64
+ ok: applied.ok && after.every((format) => format.status === "current"),
65
+ mode: "write",
66
+ required,
67
+ applied: applied.ok,
68
+ changedFiles: plan.changedFiles,
69
+ formats: after,
70
+ previousFormats: before,
71
+ diagnostics: applied.diagnostics,
72
+ transaction: applied.transaction,
73
+ };
74
+ }
75
+
76
+ async function inspectGeneratedFormats(root, config) {
77
+ const cacheDirectory = toPosix(config.generation.cacheDirectory).replace(/\/+$/u, "");
78
+ const cacheRoot = path.join(root, cacheDirectory);
79
+ await assertNoSymlinkTraversal(root, cacheRoot, cacheDirectory);
80
+ const repositoryId = config.repositoryId;
81
+ const records = [];
82
+
83
+ const index = await inspectJson(root, path.join(cacheRoot, "index.json"), `${cacheDirectory}/index.json`);
84
+ records.push(formatRecord(
85
+ "primary-index",
86
+ `${cacheDirectory}/index.json`,
87
+ index,
88
+ (value) => value?.schemaVersion === 1 && value.repositoryId === repositoryId && Array.isArray(value.cards),
89
+ "schemaVersion 1",
90
+ ));
91
+
92
+ const search = await inspectJson(root, path.join(cacheRoot, "search-index.json"), `${cacheDirectory}/search-index.json`);
93
+ records.push(formatRecord(
94
+ "search-index",
95
+ `${cacheDirectory}/search-index.json`,
96
+ search,
97
+ (value) => isCompatibleSearchIndex(value, repositoryId),
98
+ `schemaVersion ${SEARCH_INDEX_SCHEMA_VERSION} ${SEARCH_INDEX_ENCODING}`,
99
+ ));
100
+
101
+ const fileState = await inspectJson(root, path.join(cacheRoot, "file-state.json"), `${cacheDirectory}/file-state.json`);
102
+ records.push(formatRecord(
103
+ "file-state",
104
+ `${cacheDirectory}/file-state.json`,
105
+ fileState,
106
+ usableFileState,
107
+ `schemaVersion ${FILE_STATE_SCHEMA_VERSION} indexerVersion ${SOURCE_INDEXER_VERSION}`,
108
+ ));
109
+
110
+ const graph = await inspectJson(root, path.join(cacheRoot, "graph.json"), `${cacheDirectory}/graph.json`);
111
+ records.push(formatRecord(
112
+ "repository-graph",
113
+ `${cacheDirectory}/graph.json`,
114
+ graph,
115
+ (value) => isCompatibleRepositoryGraph(value, repositoryId),
116
+ `schemaVersion ${GRAPH_SCHEMA_VERSION}`,
117
+ ));
118
+
119
+ const graphState = await inspectJson(root, path.join(cacheRoot, "graph-state.json"), `${cacheDirectory}/graph-state.json`);
120
+ records.push(formatRecord(
121
+ "graph-state",
122
+ `${cacheDirectory}/graph-state.json`,
123
+ graphState,
124
+ (value) => compatibleGraphState(value, repositoryId),
125
+ `schemaVersion ${GRAPH_STATE_SCHEMA_VERSION}`,
126
+ ));
127
+
128
+ const prompt = await inspectJson(root, path.join(cacheRoot, "prompt-prefix.json"), `${cacheDirectory}/prompt-prefix.json`);
129
+ records.push(formatRecord(
130
+ "prompt-prefix",
131
+ `${cacheDirectory}/prompt-prefix.json`,
132
+ prompt,
133
+ (value) => isCompatiblePromptPrefixBundle(value, repositoryId),
134
+ `schemaVersion ${PROMPT_BUNDLE_SCHEMA_VERSION}`,
135
+ ));
136
+
137
+ const manifest = await inspectJson(root, path.join(cacheRoot, "manifest.json"), `${cacheDirectory}/manifest.json`);
138
+ records.push(formatRecord(
139
+ "manifest",
140
+ `${cacheDirectory}/manifest.json`,
141
+ manifest,
142
+ (value) => compatibleManifest(value, repositoryId),
143
+ "schemaVersion 1 with current generated format versions",
144
+ ));
145
+
146
+ return records;
147
+ }
148
+
149
+ function compatibleManifest(value, repositoryId) {
150
+ return Boolean(
151
+ value &&
152
+ value.schemaVersion === 1 &&
153
+ value.repositoryId === repositoryId &&
154
+ value.fileStateSchemaVersion === FILE_STATE_SCHEMA_VERSION &&
155
+ value.searchIndexSchemaVersion === SEARCH_INDEX_SCHEMA_VERSION &&
156
+ value.graphSchemaVersion === GRAPH_SCHEMA_VERSION &&
157
+ value.graphStateSchemaVersion === GRAPH_STATE_SCHEMA_VERSION &&
158
+ value.promptBundleSchemaVersion === PROMPT_BUNDLE_SCHEMA_VERSION &&
159
+ (value.searchShardSchemaVersion === null || value.searchShardSchemaVersion === SEARCH_SHARD_SCHEMA_VERSION) &&
160
+ value.files && typeof value.files === "object",
161
+ );
162
+ }
163
+
164
+ async function inspectJson(root, filePath, label) {
165
+ await assertNoSymlinkTraversal(root, filePath, label);
166
+ const text = await readText(filePath, null);
167
+ if (text === null) return { state: "missing", value: null, detail: "file is missing" };
168
+ try {
169
+ return { state: "present", value: JSON.parse(text), detail: null };
170
+ } catch (error) {
171
+ return { state: "malformed", value: null, detail: error instanceof Error ? error.message : String(error) };
172
+ }
173
+ }
174
+
175
+ function formatRecord(id, relativePath, inspected, compatible, expected) {
176
+ if (inspected.state === "missing") return { id, path: relativePath, status: "missing", expected, detail: inspected.detail };
177
+ if (inspected.state === "malformed") return { id, path: relativePath, status: "incompatible", expected, detail: inspected.detail };
178
+ const current = compatible(inspected.value);
179
+ return {
180
+ id,
181
+ path: relativePath,
182
+ status: current ? "current" : "incompatible",
183
+ expected,
184
+ detail: current ? null : describeVersion(inspected.value),
185
+ };
186
+ }
187
+
188
+ function describeVersion(value) {
189
+ if (!value || typeof value !== "object") return "expected a JSON object";
190
+ const fields = ["schemaVersion", "indexerVersion", "encoding", "tokenizerVersion"]
191
+ .filter((name) => value[name] !== undefined)
192
+ .map((name) => `${name}=${JSON.stringify(value[name])}`);
193
+ return fields.length > 0 ? `found ${fields.join(" ")}` : "version fields are missing";
194
+ }
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.6.6";
13
+ export const PACKAGE_VERSION = "0.7.1";
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);