@mrsf/cli 0.1.0 → 0.2.0

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
@@ -24,6 +24,7 @@ export function registerReanchor(program) {
24
24
  const parentOpts = program.opts();
25
25
  const cwd = parentOpts.cwd ?? process.cwd();
26
26
  const quiet = parentOpts.quiet ?? false;
27
+ const verbose = parentOpts.verbose ?? false;
27
28
  const threshold = parseFloat(opts.threshold);
28
29
  const noGit = !opts.git;
29
30
  let sidecarPaths;
@@ -55,6 +56,12 @@ export function registerReanchor(program) {
55
56
  console.log("No sidecar files found.");
56
57
  return;
57
58
  }
59
+ if (verbose) {
60
+ console.log(chalk.dim(`threshold: ${threshold}`));
61
+ console.log(chalk.dim(`sidecars: ${sidecarPaths.length}`));
62
+ if (opts.dryRun)
63
+ console.log(chalk.dim("mode: dry-run"));
64
+ }
58
65
  let totalChanged = 0;
59
66
  let totalOrphaned = 0;
60
67
  for (const sp of sidecarPaths) {
@@ -81,8 +88,15 @@ export function registerReanchor(program) {
81
88
  await writeSidecar(sp, doc);
82
89
  }
83
90
  }
91
+ else {
92
+ // Dry-run: compute change count on a clone so we don't mutate
93
+ const clone = structuredClone(doc);
94
+ totalChanged += applyReanchorResults(clone, results, {
95
+ updateText: opts.updateText,
96
+ });
97
+ }
84
98
  if (!quiet) {
85
- printResults(sp, results, opts.dryRun);
99
+ printResults(sp, results, opts.dryRun, verbose);
86
100
  }
87
101
  }
88
102
  catch (err) {
@@ -99,13 +113,21 @@ export function registerReanchor(program) {
99
113
  process.exit(1);
100
114
  });
101
115
  }
102
- function printResults(sidecarPath, results, dryRun) {
116
+ function printResults(sidecarPath, results, dryRun, verbose) {
103
117
  const prefix = dryRun ? "[dry-run] " : "";
104
118
  console.log(`${prefix}${sidecarPath}:`);
105
119
  for (const r of results) {
106
120
  const icon = statusIcon(r.status);
107
121
  const line = r.newLine != null ? `:${r.newLine}` : "";
108
122
  console.log(` ${icon} ${r.commentId}${line} — ${r.reason}`);
123
+ if (verbose) {
124
+ if (r.newLine != null)
125
+ console.log(` new line: ${r.newLine}`);
126
+ if (r.score != null)
127
+ console.log(` score: ${r.score.toFixed(3)}`);
128
+ if (r.status !== "anchored")
129
+ console.log(` status: ${r.status}`);
130
+ }
109
131
  }
110
132
  }
111
133
  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);
@@ -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.0",
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",