@mrsf/cli 0.1.0 → 0.2.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/README.md CHANGED
@@ -336,6 +336,60 @@ With this config, the sidecar for `docs/architecture.md` is resolved as `.review
336
336
 
337
337
  See [§3.2 Alternate Sidecar Location](../MRSF-v1.0.md) in the spec for details.
338
338
 
339
+ ## Extension Fields (`x_*`)
340
+
341
+ MRSF reserves the `x_` prefix for tool-specific, non-standard extension properties (see [§10 Extension Fields](../MRSF-v1.0.md)). These fields may appear on the top-level document object or on individual comments. The CLI preserves them during round-trip writes and exposes them through the catch-all index signature on both `MrsfDocument` and `Comment`.
342
+
343
+ ### Built-in `x_` fields
344
+
345
+ The CLI and Sidemark VS Code extension use the following extension fields:
346
+
347
+ | Field | Scope | Type | Set by | Description |
348
+ |---|---|---|---|---|
349
+ | `x_reanchor_status` | comment | `"anchored"` \| `"shifted"` \| `"fuzzy"` \| `"orphaned"` | `mrsf reanchor` | Result of the most recent reanchor pass. `orphaned` means the anchor text could not be found in the current document. |
350
+ | `x_reanchor_score` | comment | `number` (0–1) | `mrsf reanchor` | Confidence score from the reanchor algorithm. `1.0` = exact match, lower values indicate fuzzy or shifted matches. |
351
+
352
+ ### Adding your own fields
353
+
354
+ Any property whose key starts with `x_` is valid on both the document root and on each comment. The CLI and Sidemark will silently preserve these fields through all operations (validate, add, resolve, reanchor, etc.).
355
+
356
+ ```yaml
357
+ comments:
358
+ - id: abc123
359
+ author: ci-bot
360
+ timestamp: "2025-01-15T10:00:00Z"
361
+ text: Possible security concern
362
+ resolved: false
363
+ line: 42
364
+ # Custom extension fields
365
+ x_ai_confidence: 0.92
366
+ x_tool_name: security-scanner
367
+ x_category: injection
368
+ ```
369
+
370
+ **Rules** (from the spec):
371
+
372
+ - Keys MUST start with `x_` (underscore, not hyphen).
373
+ - Implementations MUST NOT assign semantic meaning to `x_` fields defined by other tools.
374
+ - Future MRSF spec versions will never introduce normative fields with the `x_` prefix.
375
+ - Unknown `x_` fields are preserved by the CLI's round-trip writer and never stripped or modified.
376
+
377
+ ### Programmatic access
378
+
379
+ ```typescript
380
+ import { parseSidecar } from "@mrsf/cli";
381
+
382
+ const doc = await parseSidecar("file.md.review.yaml");
383
+ for (const comment of doc.comments) {
384
+ // Access built-in x_ fields
385
+ if (comment.x_reanchor_status === "orphaned") {
386
+ console.log(`Comment ${comment.id} is orphaned`);
387
+ }
388
+ // Access custom x_ fields
389
+ const confidence = comment.x_ai_confidence as number | undefined;
390
+ }
391
+ ```
392
+
339
393
  ## Requirements
340
394
 
341
395
  - Node.js 18 or later
package/dist/bin.js CHANGED
@@ -63,7 +63,8 @@ program
63
63
  .option("--cwd <dir>", "Working directory")
64
64
  .option("--config <path>", "Path to .mrsf.yaml")
65
65
  .option("--no-color", "Disable colour output")
66
- .option("-q, --quiet", "Suppress non-essential output");
66
+ .option("-q, --quiet", "Suppress non-essential output")
67
+ .option("-v, --verbose", "Show detailed diagnostic output");
67
68
  registerValidate(program);
68
69
  registerReanchor(program);
69
70
  registerAdd(program);
@@ -26,6 +26,7 @@ export function registerAdd(program) {
26
26
  const parentOpts = program.opts();
27
27
  const cwd = parentOpts.cwd ?? process.cwd();
28
28
  const quiet = parentOpts.quiet ?? false;
29
+ const verbose = parentOpts.verbose ?? false;
29
30
  const docPath = path.resolve(cwd, document);
30
31
  const root = await findWorkspaceRoot(cwd);
31
32
  const sidecarPath = await discoverSidecar(docPath, { cwd: root ?? cwd });
@@ -72,6 +73,14 @@ export function registerAdd(program) {
72
73
  }
73
74
  }
74
75
  await writeSidecar(sidecarPath, doc);
76
+ if (verbose) {
77
+ console.log(chalk.dim(` sidecar: ${sidecarPath}`));
78
+ console.log(chalk.dim(` id: ${comment.id}`));
79
+ if (comment.line)
80
+ console.log(chalk.dim(` line: ${comment.line}`));
81
+ if (comment.selected_text)
82
+ console.log(chalk.dim(` selected_text: ${comment.selected_text.slice(0, 80)}${comment.selected_text.length > 80 ? "…" : ""}`));
83
+ }
75
84
  if (!quiet) {
76
85
  console.log(chalk.green(`Added comment ${comment.id}`) +
77
86
  (comment.line ? ` at line ${comment.line}` : "") +
@@ -15,6 +15,7 @@ export function registerInit(program) {
15
15
  const parentOpts = program.opts();
16
16
  const cwd = parentOpts.cwd ?? process.cwd();
17
17
  const quiet = parentOpts.quiet ?? false;
18
+ const verbose = parentOpts.verbose ?? false;
18
19
  const docPath = path.resolve(cwd, document);
19
20
  // Verify the document exists
20
21
  try {
@@ -47,6 +48,10 @@ export function registerInit(program) {
47
48
  if (!quiet) {
48
49
  console.log(chalk.green(`Created ${sidecarPath}`));
49
50
  }
51
+ if (verbose) {
52
+ console.log(chalk.dim(` document: ${doc.document}`));
53
+ console.log(chalk.dim(` sidecar: ${sidecarPath}`));
54
+ }
50
55
  });
51
56
  }
52
57
  //# sourceMappingURL=init.js.map
@@ -8,7 +8,7 @@ import { readDocumentLines } from "../lib/parser.js";
8
8
  import { sidecarToDocument } from "../lib/discovery.js";
9
9
  import { resolveSidecarPaths } from "../lib/resolve-files.js";
10
10
  import { writeSidecar } from "../lib/writer.js";
11
- import { findRepoRoot, getStagedFiles } from "../lib/git.js";
11
+ import { findRepoRoot, getStagedFiles, getCurrentCommit } from "../lib/git.js";
12
12
  import { reanchorDocument, applyReanchorResults, } from "../lib/reanchor.js";
13
13
  export function registerReanchor(program) {
14
14
  program
@@ -20,10 +20,12 @@ export function registerReanchor(program) {
20
20
  .option("--no-git", "Disable git integration")
21
21
  .option("--from <commit>", "Override from-commit for all comments")
22
22
  .option("--update-text", "Also update selected_text to match current document text")
23
+ .option("-f, --force", "Firmly anchor high-confidence results (update commit to HEAD, clear audit fields)")
23
24
  .action(async (files, opts) => {
24
25
  const parentOpts = program.opts();
25
26
  const cwd = parentOpts.cwd ?? process.cwd();
26
27
  const quiet = parentOpts.quiet ?? false;
28
+ const verbose = parentOpts.verbose ?? false;
27
29
  const threshold = parseFloat(opts.threshold);
28
30
  const noGit = !opts.git;
29
31
  let sidecarPaths;
@@ -55,6 +57,12 @@ export function registerReanchor(program) {
55
57
  console.log("No sidecar files found.");
56
58
  return;
57
59
  }
60
+ if (verbose) {
61
+ console.log(chalk.dim(`threshold: ${threshold}`));
62
+ console.log(chalk.dim(`sidecars: ${sidecarPaths.length}`));
63
+ if (opts.dryRun)
64
+ console.log(chalk.dim("mode: dry-run"));
65
+ }
58
66
  let totalChanged = 0;
59
67
  let totalOrphaned = 0;
60
68
  for (const sp of sidecarPaths) {
@@ -63,6 +71,7 @@ export function registerReanchor(program) {
63
71
  const docPath = sidecarToDocument(sp);
64
72
  const lines = await readDocumentLines(docPath);
65
73
  const repoRoot = !noGit ? await findRepoRoot(cwd) : null;
74
+ const headCommit = repoRoot ? await getCurrentCommit(repoRoot) : undefined;
66
75
  const results = await reanchorDocument(doc, lines, {
67
76
  threshold,
68
77
  noGit,
@@ -75,14 +84,25 @@ export function registerReanchor(program) {
75
84
  if (!opts.dryRun) {
76
85
  const changed = applyReanchorResults(doc, results, {
77
86
  updateText: opts.updateText,
87
+ force: opts.force,
88
+ headCommit: headCommit ?? undefined,
78
89
  });
79
90
  totalChanged += changed;
80
91
  if (changed > 0) {
81
92
  await writeSidecar(sp, doc);
82
93
  }
83
94
  }
95
+ else {
96
+ // Dry-run: compute change count on a clone so we don't mutate
97
+ const clone = structuredClone(doc);
98
+ totalChanged += applyReanchorResults(clone, results, {
99
+ updateText: opts.updateText,
100
+ force: opts.force,
101
+ headCommit: headCommit ?? undefined,
102
+ });
103
+ }
84
104
  if (!quiet) {
85
- printResults(sp, results, opts.dryRun);
105
+ printResults(sp, results, opts.dryRun, verbose);
86
106
  }
87
107
  }
88
108
  catch (err) {
@@ -99,13 +119,21 @@ export function registerReanchor(program) {
99
119
  process.exit(1);
100
120
  });
101
121
  }
102
- function printResults(sidecarPath, results, dryRun) {
122
+ function printResults(sidecarPath, results, dryRun, verbose) {
103
123
  const prefix = dryRun ? "[dry-run] " : "";
104
124
  console.log(`${prefix}${sidecarPath}:`);
105
125
  for (const r of results) {
106
126
  const icon = statusIcon(r.status);
107
127
  const line = r.newLine != null ? `:${r.newLine}` : "";
108
128
  console.log(` ${icon} ${r.commentId}${line} — ${r.reason}`);
129
+ if (verbose) {
130
+ if (r.newLine != null)
131
+ console.log(` new line: ${r.newLine}`);
132
+ if (r.score != null)
133
+ console.log(` score: ${r.score.toFixed(3)}`);
134
+ if (r.status !== "anchored")
135
+ console.log(` status: ${r.status}`);
136
+ }
109
137
  }
110
138
  }
111
139
  function statusIcon(status) {
@@ -15,6 +15,7 @@ export function registerRename(program) {
15
15
  const parentOpts = program.opts();
16
16
  const cwd = parentOpts.cwd ?? process.cwd();
17
17
  const quiet = parentOpts.quiet ?? false;
18
+ const verbose = parentOpts.verbose ?? false;
18
19
  const oldDocPath = path.resolve(cwd, oldDoc);
19
20
  const newDocPath = path.resolve(cwd, newDoc);
20
21
  const root = await findWorkspaceRoot(cwd);
@@ -48,6 +49,10 @@ export function registerRename(program) {
48
49
  if (!quiet) {
49
50
  console.log(chalk.green(`Renamed: ${oldSidecarPath} → ${newSidecarPath}`));
50
51
  }
52
+ if (verbose) {
53
+ console.log(chalk.dim(` document: ${doc.document}`));
54
+ console.log(chalk.dim(` comments: ${doc.comments.length}`));
55
+ }
51
56
  });
52
57
  }
53
58
  //# sourceMappingURL=rename.js.map
@@ -16,6 +16,7 @@ export function registerResolve(program) {
16
16
  const parentOpts = program.opts();
17
17
  const cwd = parentOpts.cwd ?? process.cwd();
18
18
  const quiet = parentOpts.quiet ?? false;
19
+ const verbose = parentOpts.verbose ?? false;
19
20
  const sp = path.resolve(cwd, sidecar);
20
21
  const doc = await parseSidecar(sp);
21
22
  const ok = opts.undo
@@ -30,6 +31,9 @@ export function registerResolve(program) {
30
31
  const action = opts.undo ? "Unresolved" : "Resolved";
31
32
  console.log(chalk.green(`${action} ${id} in ${sidecar}.`));
32
33
  }
34
+ if (verbose) {
35
+ console.log(chalk.dim(` sidecar: ${sp}`));
36
+ }
33
37
  });
34
38
  }
35
39
  //# sourceMappingURL=resolve.js.map
@@ -16,6 +16,7 @@ export function registerStatus(program) {
16
16
  const parentOpts = program.opts();
17
17
  const cwd = parentOpts.cwd ?? process.cwd();
18
18
  const quiet = parentOpts.quiet ?? false;
19
+ const verbose = parentOpts.verbose ?? false;
19
20
  const sidecarPaths = await resolveSidecarPaths(files, cwd);
20
21
  if (sidecarPaths.length === 0) {
21
22
  if (!quiet)
@@ -57,6 +58,11 @@ export function registerStatus(program) {
57
58
  console.log(JSON.stringify(allResults, null, 2));
58
59
  return;
59
60
  }
61
+ if (verbose) {
62
+ for (const sp of sidecarPaths) {
63
+ console.log(chalk.dim(` sidecar: ${sp}`));
64
+ }
65
+ }
60
66
  for (const r of allResults) {
61
67
  const icon = healthIcon(r.health);
62
68
  console.log(`${icon} ${r.commentId}: ${r.reason}`);
@@ -13,6 +13,7 @@ export function registerValidate(program) {
13
13
  const parentOpts = program.opts();
14
14
  const cwd = parentOpts.cwd ?? process.cwd();
15
15
  const quiet = parentOpts.quiet ?? false;
16
+ const verbose = parentOpts.verbose ?? false;
16
17
  const sidecarPaths = await resolveSidecarPaths(files, cwd);
17
18
  if (sidecarPaths.length === 0) {
18
19
  if (!quiet)
@@ -47,6 +48,9 @@ export function registerValidate(program) {
47
48
  const fn = opts.strict ? chalk.red : chalk.yellow;
48
49
  console.log(fn(` ${opts.strict ? "error" : "warn"}: ${w.message}${w.path ? ` (${w.path})` : ""}`));
49
50
  }
51
+ if (verbose) {
52
+ console.log(chalk.dim(` ${result.errors.length} error(s), ${result.warnings.length} warning(s)`));
53
+ }
50
54
  }
51
55
  if (hasErrors)
52
56
  process.exit(1);
@@ -33,6 +33,8 @@ export declare function reanchorDocument(doc: MrsfDocument, documentLines: strin
33
33
  */
34
34
  export declare function applyReanchorResults(doc: MrsfDocument, results: ReanchorResult[], opts?: {
35
35
  updateText?: boolean;
36
+ force?: boolean;
37
+ headCommit?: string;
36
38
  }): number;
37
39
  /**
38
40
  * High-level re-anchor for a single sidecar file path.
@@ -45,8 +45,24 @@ export function reanchorComment(comment, documentLines, opts = {}) {
45
45
  // -----------------------------------------------------------------------
46
46
  // Step 0: Diff-based shift
47
47
  // -----------------------------------------------------------------------
48
- if (selectedText && comment.line != null && opts.diffHunks?.length) {
48
+ if (comment.line != null && opts.diffHunks?.length) {
49
49
  const { shift, modified } = getLineShift(opts.diffHunks, comment.line);
50
+ // For line-only comments (no selected_text), apply the diff shift directly
51
+ if (!selectedText) {
52
+ const shiftedLine = comment.line + shift;
53
+ const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
54
+ const shiftedEndLine = comment.end_line != null ? shiftedLine + lineSpan : undefined;
55
+ return {
56
+ commentId,
57
+ status: shift === 0 ? "anchored" : "shifted",
58
+ score: 1.0,
59
+ newLine: shiftedLine,
60
+ newEndLine: shiftedEndLine,
61
+ reason: shift === 0
62
+ ? "Line-only comment unchanged (diff confirms position)."
63
+ : `Line-only comment shifted by ${shift > 0 ? "+" : ""}${shift} line(s) via diff.`,
64
+ };
65
+ }
50
66
  if (!modified) {
51
67
  const shiftedLine = comment.line + shift;
52
68
  const lineSpan = comment.end_line != null ? comment.end_line - comment.line : 0;
@@ -167,13 +183,18 @@ export function reanchorComment(comment, documentLines, opts = {}) {
167
183
  }
168
184
  }
169
185
  // Pure line fallback
186
+ // If there's no selected_text, the comment is purely line-anchored.
187
+ // A commit change alone doesn't make it ambiguous — we accept it as-is.
188
+ const isLineOnly = !selectedText;
170
189
  return {
171
190
  commentId,
172
- status: opts.commitIsStale ? "ambiguous" : "anchored",
173
- score: opts.commitIsStale ? 0.5 : 0.8,
191
+ status: isLineOnly ? "anchored" : (opts.commitIsStale ? "ambiguous" : "anchored"),
192
+ score: isLineOnly ? 1.0 : (opts.commitIsStale ? 0.5 : 0.8),
174
193
  newLine: comment.line,
175
194
  newEndLine: comment.end_line,
176
- reason: `Line/column fallback${qualifier}.`,
195
+ reason: isLineOnly
196
+ ? "Line-only comment (no selected_text to verify)."
197
+ : `Line/column fallback${qualifier}.`,
177
198
  };
178
199
  }
179
200
  }
@@ -320,6 +341,22 @@ export function applyReanchorResults(doc, results, opts = {}) {
320
341
  comment.x_reanchor_status = result.status;
321
342
  comment.x_reanchor_score = result.score;
322
343
  }
344
+ // --force: firmly anchor high-confidence results
345
+ // Update commit to HEAD and clear audit fields so the comment
346
+ // won't be re-evaluated on the next reanchor run.
347
+ if (opts.force &&
348
+ opts.headCommit &&
349
+ (result.status === "anchored" || result.status === "shifted") &&
350
+ result.score >= HIGH_THRESHOLD) {
351
+ comment.commit = opts.headCommit;
352
+ delete comment.x_reanchor_status;
353
+ delete comment.x_reanchor_score;
354
+ // Clear stale anchored_text since anchor is now confirmed
355
+ if (comment.anchored_text && comment.anchored_text === comment.selected_text) {
356
+ delete comment.anchored_text;
357
+ }
358
+ isChanged = true;
359
+ }
323
360
  if (isChanged)
324
361
  changed++;
325
362
  }
@@ -333,6 +370,7 @@ export async function reanchorFile(sidecarPath, opts = {}) {
333
370
  const docPath = sidecarToDocument(sidecarPath);
334
371
  const documentLines = await readDocumentLines(docPath);
335
372
  const repoRoot = !opts.noGit ? await findRepoRoot(opts.cwd) : null;
373
+ const headCommit = repoRoot ? await getCurrentCommit(repoRoot) : undefined;
336
374
  const results = await reanchorDocument(doc, documentLines, {
337
375
  ...opts,
338
376
  documentPath: docPath,
@@ -341,7 +379,11 @@ export async function reanchorFile(sidecarPath, opts = {}) {
341
379
  let changed = 0;
342
380
  let written = false;
343
381
  if (!opts.dryRun) {
344
- changed = applyReanchorResults(doc, results, { updateText: opts.updateText });
382
+ changed = applyReanchorResults(doc, results, {
383
+ updateText: opts.updateText,
384
+ force: opts.force,
385
+ headCommit: headCommit ?? undefined,
386
+ });
345
387
  if (changed > 0 || opts.autoUpdate) {
346
388
  await writeSidecar(sidecarPath, doc);
347
389
  written = true;
@@ -143,6 +143,8 @@ export interface ReanchorOptions extends BaseOptions {
143
143
  fromCommit?: string;
144
144
  /** Update selected_text to match anchored_text (opt-in per §6.2). */
145
145
  updateText?: boolean;
146
+ /** Force-anchor: update commit to HEAD and clear audit fields for high-confidence results. */
147
+ force?: boolean;
146
148
  }
147
149
  export interface ValidateOptions extends BaseOptions {
148
150
  /** Treat warnings as errors. */
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * MRSF Writer — serialize MrsfDocument back to YAML or JSON.
3
3
  *
4
- * Preserves comment order per §10.1.
4
+ * Uses CST-level (Concrete Syntax Tree) round-trip editing via the `yaml`
5
+ * library so that unchanged content is byte-identical to the original file.
6
+ * - Preserves YAML comments (#)
7
+ * - Preserves scalar styles (>, |, quotes)
8
+ * - Preserves key ordering and whitespace
9
+ * - Only modifies values that actually changed
10
+ *
5
11
  * Auto-computes selected_text_hash when selected_text changes.
6
12
  */
7
13
  import type { MrsfDocument, Comment } from "./types.js";
@@ -16,7 +22,7 @@ export declare function computeHash(text: string): string;
16
22
  */
17
23
  export declare function syncHash(comment: Comment): Comment;
18
24
  /**
19
- * Serialize an MrsfDocument to YAML.
25
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
20
26
  */
21
27
  export declare function toYaml(doc: MrsfDocument): string;
22
28
  /**
@@ -24,7 +30,13 @@ export declare function toYaml(doc: MrsfDocument): string;
24
30
  */
25
31
  export declare function toJson(doc: MrsfDocument): string;
26
32
  /**
27
- * Write an MrsfDocument to disk. Detects format from file extension.
33
+ * Write an MrsfDocument to disk.
34
+ *
35
+ * When a YAML file already exists on disk, performs a **CST-level** round-trip
36
+ * merge that preserves YAML comments, scalar styles, key ordering, and
37
+ * whitespace byte-for-byte for unchanged content.
38
+ *
39
+ * For new files or JSON, writes from scratch.
28
40
  */
29
41
  export declare function writeSidecar(filePath: string, doc: MrsfDocument): Promise<void>;
30
42
  //# sourceMappingURL=writer.d.ts.map
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * MRSF Writer — serialize MrsfDocument back to YAML or JSON.
3
3
  *
4
- * Preserves comment order per §10.1.
4
+ * Uses CST-level (Concrete Syntax Tree) round-trip editing via the `yaml`
5
+ * library so that unchanged content is byte-identical to the original file.
6
+ * - Preserves YAML comments (#)
7
+ * - Preserves scalar styles (>, |, quotes)
8
+ * - Preserves key ordering and whitespace
9
+ * - Only modifies values that actually changed
10
+ *
5
11
  * Auto-computes selected_text_hash when selected_text changes.
6
12
  */
7
- import { writeFile } from "node:fs/promises";
13
+ import { readFile, writeFile } from "node:fs/promises";
14
+ import { existsSync } from "node:fs";
8
15
  import { createHash } from "node:crypto";
9
16
  import path from "node:path";
10
- import yaml from "js-yaml";
17
+ import { Document, Parser, CST, parse as yamlParse } from "yaml";
18
+ /* ------------------------------------------------------------------ */
19
+ /* Hash helpers */
20
+ /* ------------------------------------------------------------------ */
11
21
  /**
12
22
  * Compute SHA-256 hex hash of a string (UTF-8).
13
23
  */
@@ -29,16 +39,214 @@ export function syncHash(comment) {
29
39
  return comment;
30
40
  }
31
41
  /**
32
- * Serialize an MrsfDocument to YAML.
42
+ * Known comment field keys in preferred output order (for new comments).
43
+ */
44
+ const COMMENT_KEY_ORDER = [
45
+ "id", "author", "timestamp", "text", "type", "severity",
46
+ "resolved", "reply_to", "line", "end_line", "start_column", "end_column",
47
+ "selected_text", "selected_text_hash", "anchored_text", "commit",
48
+ ];
49
+ /* ·· value serialisation ·············································· */
50
+ /**
51
+ * Convert a JS value to its YAML source text representation.
52
+ * Strings that require quoting get double-quoted; numbers and booleans
53
+ * are plain scalars.
54
+ */
55
+ function valueToSource(v) {
56
+ if (typeof v === "number" || typeof v === "boolean")
57
+ return String(v);
58
+ if (typeof v === "string") {
59
+ // Strings that need quoting
60
+ if (/[:\n"{}[\],&#*?|><!%@`]/.test(v) ||
61
+ v.includes(" #") ||
62
+ v === "" ||
63
+ v === "true" ||
64
+ v === "false" ||
65
+ v === "null" ||
66
+ /^\d/.test(v)) {
67
+ return JSON.stringify(v);
68
+ }
69
+ return v;
70
+ }
71
+ return String(v);
72
+ }
73
+ /** Return the CST scalar type for a value. */
74
+ function valueType(v) {
75
+ return valueToSource(v).startsWith('"') ? "double-quoted-scalar" : "scalar";
76
+ }
77
+ /* ·· CST node construction ············································ */
78
+ const NL = "\n";
79
+ /**
80
+ * Build a CST map-item (key: value\n) suitable for insertion into a
81
+ * block-map's `items` array.
82
+ *
83
+ * @param isFirst If true, omit leading indent (first item in a block-map
84
+ * inherits indent from the parent seq-item-ind).
85
+ */
86
+ function makeCstMapItem(key, value, indent, isFirst) {
87
+ const start = isFirst
88
+ ? []
89
+ : [{ type: "space", offset: 0, indent: 0, source: " ".repeat(indent) }];
90
+ return {
91
+ start,
92
+ key: { type: "scalar", offset: 0, indent, source: key },
93
+ sep: [
94
+ { type: "map-value-ind", offset: 0, indent, source: ":" },
95
+ { type: "space", offset: 0, indent, source: " " },
96
+ ],
97
+ value: {
98
+ type: valueType(value),
99
+ offset: 0,
100
+ indent,
101
+ source: valueToSource(value),
102
+ end: [{ type: "newline", offset: 0, indent, source: NL }],
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * Build a complete CST seq-item for a brand-new Comment (block-map inside
108
+ * a block-seq).
109
+ */
110
+ function makeCstSeqItem(comment, seqIndent) {
111
+ const mapIndent = seqIndent + 2;
112
+ const items = [];
113
+ // Preferred key order first
114
+ for (const key of COMMENT_KEY_ORDER) {
115
+ const val = comment[key];
116
+ if (val !== undefined) {
117
+ items.push(makeCstMapItem(key, val, mapIndent, items.length === 0));
118
+ }
119
+ }
120
+ // Extension / extra fields
121
+ for (const key of Object.keys(comment)) {
122
+ if (!COMMENT_KEY_ORDER.includes(key) &&
123
+ comment[key] !== undefined) {
124
+ items.push(makeCstMapItem(key, comment[key], mapIndent, items.length === 0));
125
+ }
126
+ }
127
+ return {
128
+ start: [
129
+ { type: "newline", offset: 0, indent: 0, source: NL },
130
+ { type: "space", offset: 0, indent: 0, source: " ".repeat(seqIndent) },
131
+ { type: "seq-item-ind", offset: 0, indent: seqIndent, source: "-" },
132
+ { type: "space", offset: 0, indent: seqIndent, source: " " },
133
+ ],
134
+ value: { type: "block-map", offset: 0, indent: mapIndent, items },
135
+ };
136
+ }
137
+ /* ·· CST navigation ··················································· */
138
+ /** Find the document token in a CST token stream. */
139
+ function findCstDocument(tokens) {
140
+ return tokens.find((t) => t.type === "document") ?? null;
141
+ }
142
+ /** Find an item in a block-map by key source text. */
143
+ function findCstMapEntry(blockMap, keyName) {
144
+ return (blockMap.items?.find((item) => item.key?.source === keyName) ?? null);
145
+ }
146
+ /** Get the index of a map entry by key source text (−1 if missing). */
147
+ function findCstMapIndex(blockMap, keyName) {
148
+ return (blockMap.items?.findIndex((item) => item.key?.source === keyName) ?? -1);
149
+ }
150
+ /** Read the plain-text id from a CST block-map (comment). */
151
+ function cstCommentId(blockMap) {
152
+ const entry = findCstMapEntry(blockMap, "id");
153
+ if (!entry?.value?.source)
154
+ return null;
155
+ const src = entry.value.source;
156
+ // Strip quotes if present
157
+ if (src.startsWith('"') && src.endsWith('"')) {
158
+ return src.slice(1, -1);
159
+ }
160
+ return src;
161
+ }
162
+ /* ·· CST mutation ····················································· */
163
+ /**
164
+ * Update an existing scalar CST value's source text.
165
+ * Returns true if the source was actually changed.
166
+ */
167
+ function updateCstScalar(entry, newValue) {
168
+ const newSrc = valueToSource(newValue);
169
+ if (entry.value?.source === newSrc)
170
+ return false;
171
+ entry.value.source = newSrc;
172
+ // If type changed (e.g. plain → quoted), update it too
173
+ entry.value.type = valueType(newValue);
174
+ return true;
175
+ }
176
+ /**
177
+ * Synchronize a plain Comment object into a CST block-map, preserving
178
+ * untouched scalars byte-for-byte.
179
+ *
180
+ * @param blockMap CST block-map node for the comment
181
+ * @param comment New model values to write
182
+ * @param currentValues Already-parsed values from the existing YAML
183
+ * (used to detect real changes vs source-format
184
+ * differences like quoted vs folded scalars)
185
+ */
186
+ function syncCommentToCst(blockMap, comment, currentValues) {
187
+ const commentRec = comment;
188
+ // Collect existing key names
189
+ const existingKeys = new Set();
190
+ for (const item of blockMap.items ?? []) {
191
+ if (item.key?.source)
192
+ existingKeys.add(item.key.source);
193
+ }
194
+ const allKeys = new Set([...existingKeys, ...Object.keys(comment)]);
195
+ const indent = blockMap.indent ?? 4;
196
+ for (const key of allKeys) {
197
+ const newVal = commentRec[key];
198
+ if (newVal === undefined) {
199
+ // Removed from model → delete from CST
200
+ if (existingKeys.has(key)) {
201
+ const idx = findCstMapIndex(blockMap, key);
202
+ if (idx >= 0)
203
+ blockMap.items.splice(idx, 1);
204
+ }
205
+ continue;
206
+ }
207
+ const entry = findCstMapEntry(blockMap, key);
208
+ if (entry) {
209
+ // Existing key — compare PARSED values (not source text) to detect
210
+ // real semantic changes. This avoids touching block scalars, quoted
211
+ // strings, etc. whose source representation differs but whose parsed
212
+ // value is identical.
213
+ const currentVal = currentValues[key];
214
+ if (deepEqual(currentVal, newVal)) {
215
+ // Value unchanged — leave the CST node byte-for-byte
216
+ continue;
217
+ }
218
+ // Value really changed — update source
219
+ updateCstScalar(entry, newVal);
220
+ }
221
+ else {
222
+ // New key — insert at end
223
+ blockMap.items.push(makeCstMapItem(key, newVal, indent, false));
224
+ }
225
+ }
226
+ }
227
+ /**
228
+ * Simple deep-equal for scalars and basic types (sufficient for MRSF
229
+ * Comment field values which are strings, numbers, and booleans).
230
+ */
231
+ function deepEqual(a, b) {
232
+ if (a === b)
233
+ return true;
234
+ if (typeof a !== typeof b)
235
+ return false;
236
+ // Handle number comparison with potential string/number mismatch from YAML
237
+ if (typeof a === "number" && typeof b === "number")
238
+ return a === b;
239
+ return false;
240
+ }
241
+ /* ------------------------------------------------------------------ */
242
+ /* Public API */
243
+ /* ------------------------------------------------------------------ */
244
+ /**
245
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
33
246
  */
34
247
  export function toYaml(doc) {
35
- return yaml.dump(doc, {
36
- lineWidth: -1, // no wrapping
37
- quotingType: '"',
38
- forceQuotes: false,
39
- noRefs: true,
40
- sortKeys: false, // preserve insertion order
41
- });
248
+ const yamlDoc = new Document(doc);
249
+ return yamlDoc.toString({ lineWidth: 0 });
42
250
  }
43
251
  /**
44
252
  * Serialize an MrsfDocument to JSON.
@@ -47,16 +255,146 @@ export function toJson(doc) {
47
255
  return JSON.stringify(doc, null, 2) + "\n";
48
256
  }
49
257
  /**
50
- * Write an MrsfDocument to disk. Detects format from file extension.
258
+ * Write an MrsfDocument to disk.
259
+ *
260
+ * When a YAML file already exists on disk, performs a **CST-level** round-trip
261
+ * merge that preserves YAML comments, scalar styles, key ordering, and
262
+ * whitespace byte-for-byte for unchanged content.
263
+ *
264
+ * For new files or JSON, writes from scratch.
51
265
  */
52
266
  export async function writeSidecar(filePath, doc) {
53
267
  const abs = path.resolve(filePath);
54
268
  const isJson = abs.endsWith(".review.json");
55
- // Sync hashes for all comments before writing
269
+ if (isJson) {
270
+ // For JSON, always sync hashes and write fresh
271
+ for (const comment of doc.comments)
272
+ syncHash(comment);
273
+ await writeFile(abs, toJson(doc), "utf-8");
274
+ return;
275
+ }
276
+ // ── YAML round-trip path ──────────────────────────────────────────
277
+ if (!existsSync(abs)) {
278
+ for (const comment of doc.comments)
279
+ syncHash(comment);
280
+ await writeFile(abs, toYaml(doc), "utf-8");
281
+ return;
282
+ }
283
+ let raw;
284
+ try {
285
+ raw = await readFile(abs, "utf-8");
286
+ }
287
+ catch {
288
+ await writeFile(abs, toYaml(doc), "utf-8");
289
+ return;
290
+ }
291
+ // Parse to CST tokens
292
+ let tokens;
293
+ try {
294
+ tokens = [...new Parser().parse(raw)];
295
+ }
296
+ catch {
297
+ // Unparseable — write fresh
298
+ await writeFile(abs, toYaml(doc), "utf-8");
299
+ return;
300
+ }
301
+ // Also parse to plain JS object for value comparison (so we compare
302
+ // parsed values, not source-text representations)
303
+ let currentDoc = null;
304
+ try {
305
+ currentDoc = yamlParse(raw);
306
+ }
307
+ catch {
308
+ // ignore — we'll fall through to source-level comparison
309
+ }
310
+ // Build lookup of current parsed values by comment id
311
+ const currentById = new Map();
312
+ if (currentDoc?.comments) {
313
+ for (const c of currentDoc.comments) {
314
+ if (c.id)
315
+ currentById.set(c.id, c);
316
+ }
317
+ }
318
+ // Sync hashes: for existing comments, only add selected_text_hash
319
+ // if selected_text actually changed or hash was already present.
320
+ // For new comments, always sync.
321
+ for (const comment of doc.comments) {
322
+ const cur = currentById.get(comment.id);
323
+ if (!cur) {
324
+ // New comment — always sync hash
325
+ syncHash(comment);
326
+ }
327
+ else {
328
+ // Existing comment — only sync hash if text changed or hash
329
+ // was already tracked in the original
330
+ const textChanged = !deepEqual(cur.selected_text, comment.selected_text);
331
+ const hadHash = cur.selected_text_hash !== undefined;
332
+ if (textChanged || hadHash) {
333
+ syncHash(comment);
334
+ }
335
+ else {
336
+ // Don't inject a hash that wasn't there before
337
+ delete comment.selected_text_hash;
338
+ }
339
+ }
340
+ }
341
+ const docToken = findCstDocument(tokens);
342
+ if (!docToken?.value || docToken.value.type !== "block-map") {
343
+ await writeFile(abs, toYaml(doc), "utf-8");
344
+ return;
345
+ }
346
+ const body = docToken.value;
347
+ // ── Update top-level scalars ──────────────────────────────────────
348
+ const versionEntry = findCstMapEntry(body, "mrsf_version");
349
+ if (versionEntry && !deepEqual(currentDoc?.mrsf_version, doc.mrsf_version)) {
350
+ updateCstScalar(versionEntry, doc.mrsf_version);
351
+ }
352
+ const documentEntry = findCstMapEntry(body, "document");
353
+ if (documentEntry && !deepEqual(currentDoc?.document, doc.document)) {
354
+ updateCstScalar(documentEntry, doc.document);
355
+ }
356
+ // ── Merge comments ────────────────────────────────────────────────
357
+ const commentsEntry = findCstMapEntry(body, "comments");
358
+ if (!commentsEntry?.value || commentsEntry.value.type !== "block-seq") {
359
+ // No existing comments seq — write fresh
360
+ await writeFile(abs, toYaml(doc), "utf-8");
361
+ return;
362
+ }
363
+ const seq = commentsEntry.value;
364
+ const seqIndent = seq.indent ?? 2;
365
+ // Index existing CST comments by id
366
+ const existingById = new Map();
367
+ for (let i = 0; i < seq.items.length; i++) {
368
+ const item = seq.items[i];
369
+ const map = item.value;
370
+ if (map?.type === "block-map") {
371
+ const id = cstCommentId(map);
372
+ if (id)
373
+ existingById.set(id, { index: i, map });
374
+ }
375
+ }
376
+ // Build new seq items list, preserving existing CST nodes (and their
377
+ // YAML comments / formatting) for comments that still exist.
378
+ const idsInDoc = new Set(doc.comments.map((c) => c.id));
379
+ const newSeqItems = [];
56
380
  for (const comment of doc.comments) {
57
- syncHash(comment);
381
+ const existing = existingById.get(comment.id);
382
+ if (existing) {
383
+ // Round-trip: sync changes into the existing CST block-map
384
+ const currentValues = currentById.get(comment.id) ?? {};
385
+ syncCommentToCst(existing.map, comment, currentValues);
386
+ // Re-use the original seq item (preserves preceding YAML comments
387
+ // in the item's `start` tokens)
388
+ newSeqItems.push(seq.items[existing.index]);
389
+ }
390
+ else {
391
+ // Brand new comment — construct CST from scratch
392
+ newSeqItems.push(makeCstSeqItem(comment, seqIndent));
393
+ }
58
394
  }
59
- const content = isJson ? toJson(doc) : toYaml(doc);
60
- await writeFile(abs, content, "utf-8");
395
+ seq.items = newSeqItems;
396
+ // ── Stringify via CST (byte-identical for untouched content) ───────
397
+ const result = tokens.map((t) => CST.stringify(t)).join("");
398
+ await writeFile(abs, result, "utf-8");
61
399
  }
62
400
  //# sourceMappingURL=writer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsf/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -58,7 +58,8 @@
58
58
  "commander": "^13.1.0",
59
59
  "fastest-levenshtein": "^1.0.16",
60
60
  "js-yaml": "^4.1.0",
61
- "uuid": "^11.1.0"
61
+ "uuid": "^11.1.0",
62
+ "yaml": "^2.8.2"
62
63
  },
63
64
  "devDependencies": {
64
65
  "@types/js-yaml": "^4.0.9",