@mrsf/cli 0.2.3 → 0.3.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
@@ -1,6 +1,11 @@
1
1
  # MRSF CLI
2
2
 
3
- Command-line tool and Node.js library for the **Markdown Review Sidecar Format** ([MRSF v1.0](../MRSF-v1.0.md)).
3
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
4
+ [![MRSF v1.0 Draft](https://img.shields.io/badge/MRSF-v1.0%20Draft-blue)](MRSF-v1.0.md)
5
+ [![@mrsf/cli on npm](https://img.shields.io/npm/v/@mrsf/cli?label=%40mrsf%2Fcli)](https://www.npmjs.com/package/@mrsf/cli)
6
+ [![npm downloads (cli)](https://img.shields.io/npm/dm/@mrsf/cli?label=cli%20downloads)](https://www.npmjs.com/package/@mrsf/cli)
7
+
8
+ Command-line tool and Node.js library for **Sidemark** — the **Markdown Review Sidecar Format** ([MRSF v1.0](../MRSF-v1.0.md)).
4
9
 
5
10
  Validate sidecars, add and resolve review comments, re-anchor comments after document edits, and integrate MRSF into CI/CD pipelines — all from the terminal.
6
11
 
@@ -340,16 +345,18 @@ The CLI is built library-first. All functionality is available as importable fun
340
345
  ```typescript
341
346
  import {
342
347
  parseSidecar,
348
+ parseSidecarLenient,
343
349
  writeSidecar,
344
350
  validate,
345
351
  reanchorDocument,
346
352
  addComment,
347
353
  resolveComment,
354
+ removeComment,
348
355
  filterComments,
349
356
  summarize,
350
357
  discoverSidecar,
351
358
  discoverAllSidecars,
352
- } from "mrsf";
359
+ } from "@mrsf/cli";
353
360
 
354
361
  // Validate a sidecar programmatically
355
362
  const doc = await parseSidecar("docs/api.md.review.yaml");
package/dist/index.d.ts CHANGED
@@ -7,7 +7,8 @@
7
7
  export type { MrsfDocument, Comment, MrsfConfig, DiagnosticSeverity, ValidationDiagnostic, ValidationResult, ReanchorStatus, ReanchorResult, FuzzyCandidate, DiffHunk, AddCommentOptions, CommentFilter, AnchorHealth, StatusResult, BaseOptions, ReanchorOptions, ValidateOptions, } from "./lib/types.js";
8
8
  export { findWorkspaceRoot, loadConfig, discoverSidecar, sidecarToDocument, discoverAllSidecars, } from "./lib/discovery.js";
9
9
  export { resolveSidecarPaths } from "./lib/resolve-files.js";
10
- export { parseSidecar, parseSidecarContent, readDocumentLines, } from "./lib/parser.js";
10
+ export { parseSidecar, parseSidecarContent, parseSidecarLenient, parseSidecarContentLenient, readDocumentLines, } from "./lib/parser.js";
11
+ export type { LenientParseResult } from "./lib/parser.js";
11
12
  export { computeHash, syncHash, toYaml, toJson, writeSidecar, } from "./lib/writer.js";
12
13
  export { validate, validateFile } from "./lib/validator.js";
13
14
  export { exactMatch, normalizedMatch, fuzzySearch, combinedScore, } from "./lib/fuzzy.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ export { findWorkspaceRoot, loadConfig, discoverSidecar, sidecarToDocument, disc
9
9
  // File resolution
10
10
  export { resolveSidecarPaths } from "./lib/resolve-files.js";
11
11
  // Parsing
12
- export { parseSidecar, parseSidecarContent, readDocumentLines, } from "./lib/parser.js";
12
+ export { parseSidecar, parseSidecarContent, parseSidecarLenient, parseSidecarContentLenient, readDocumentLines, } from "./lib/parser.js";
13
13
  // Writing
14
14
  export { computeHash, syncHash, toYaml, toJson, writeSidecar, } from "./lib/writer.js";
15
15
  // Validation
@@ -1,7 +1,16 @@
1
1
  /**
2
2
  * MRSF Parser — load and parse MRSF sidecar files (YAML or JSON).
3
3
  */
4
- import type { MrsfDocument } from "./types.js";
4
+ import type { MrsfDocument, Comment } from "./types.js";
5
+ /** Result from a lenient (non-throwing) parse attempt. */
6
+ export interface LenientParseResult {
7
+ /** Fully parsed document, or null if parsing failed entirely. */
8
+ doc: MrsfDocument | null;
9
+ /** If parsing failed or produced warnings, the error message. */
10
+ error?: string;
11
+ /** Comments that could be salvaged from a partially-corrupted sidecar. */
12
+ partialComments?: Comment[];
13
+ }
5
14
  /**
6
15
  * Parse an MRSF sidecar file from disk.
7
16
  */
@@ -11,6 +20,18 @@ export declare function parseSidecar(filePath: string): Promise<MrsfDocument>;
11
20
  * Detects JSON vs YAML based on content or optional filename hint.
12
21
  */
13
22
  export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
23
+ /**
24
+ * Lenient parse: attempts to parse a sidecar file from disk without
25
+ * throwing. On complete failure, returns `{ doc: null, error }`.
26
+ * On success, returns `{ doc }`. For partially-corrupted YAML (where
27
+ * the top-level parses but some comments are malformed), attempts to
28
+ * salvage individual well-formed comments.
29
+ */
30
+ export declare function parseSidecarLenient(filePath: string): Promise<LenientParseResult>;
31
+ /**
32
+ * Lenient parse from string content.
33
+ */
34
+ export declare function parseSidecarContentLenient(content: string, filenameHint?: string): LenientParseResult;
14
35
  /**
15
36
  * Read a Markdown document from disk and return its lines.
16
37
  * Lines are 1-indexed in the returned array (index 0 is unused).
@@ -43,6 +43,132 @@ export function parseSidecarContent(content, filenameHint) {
43
43
  }
44
44
  return parsed;
45
45
  }
46
+ /**
47
+ * Lenient parse: attempts to parse a sidecar file from disk without
48
+ * throwing. On complete failure, returns `{ doc: null, error }`.
49
+ * On success, returns `{ doc }`. For partially-corrupted YAML (where
50
+ * the top-level parses but some comments are malformed), attempts to
51
+ * salvage individual well-formed comments.
52
+ */
53
+ export async function parseSidecarLenient(filePath) {
54
+ const abs = path.resolve(filePath);
55
+ let content;
56
+ try {
57
+ content = await readFile(abs, "utf-8");
58
+ }
59
+ catch (e) {
60
+ return { doc: null, error: `Cannot read file: ${e.message}` };
61
+ }
62
+ return parseSidecarContentLenient(content, abs);
63
+ }
64
+ /**
65
+ * Lenient parse from string content.
66
+ */
67
+ export function parseSidecarContentLenient(content, filenameHint) {
68
+ const trimmed = content.trim();
69
+ if (!trimmed) {
70
+ return { doc: null, error: "File is empty" };
71
+ }
72
+ const isJson = trimmed.startsWith("{") ||
73
+ (filenameHint && filenameHint.endsWith(".review.json"));
74
+ // First, try a normal parse
75
+ let parsed;
76
+ try {
77
+ parsed = isJson ? JSON.parse(trimmed) : yaml.load(trimmed);
78
+ }
79
+ catch (e) {
80
+ // Total parse failure — try to salvage what we can from YAML
81
+ if (!isJson) {
82
+ return salvageYaml(trimmed);
83
+ }
84
+ return { doc: null, error: `Failed to parse JSON: ${e.message}` };
85
+ }
86
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
87
+ return { doc: null, error: "MRSF sidecar must be a YAML/JSON object" };
88
+ }
89
+ const obj = parsed;
90
+ const doc = {
91
+ mrsf_version: typeof obj.mrsf_version === "string" ? obj.mrsf_version : "1.0",
92
+ document: typeof obj.document === "string" ? obj.document : "unknown",
93
+ comments: [],
94
+ };
95
+ if (!Array.isArray(obj.comments)) {
96
+ return {
97
+ doc,
98
+ error: "comments field is not an array — file may be corrupted",
99
+ };
100
+ }
101
+ // Validate each comment individually
102
+ const good = [];
103
+ const bad = [];
104
+ for (let i = 0; i < obj.comments.length; i++) {
105
+ const c = obj.comments[i];
106
+ if (c && typeof c === "object" && !Array.isArray(c) && typeof c.id === "string") {
107
+ good.push(c);
108
+ }
109
+ else {
110
+ bad.push(i);
111
+ }
112
+ }
113
+ doc.comments = good;
114
+ if (bad.length > 0) {
115
+ return {
116
+ doc,
117
+ error: `${bad.length} comment(s) at indices [${bad.join(", ")}] were malformed and skipped`,
118
+ partialComments: good,
119
+ };
120
+ }
121
+ return { doc };
122
+ }
123
+ /**
124
+ * Attempt to extract individual comment blocks from corrupted YAML by
125
+ * splitting on `- id:` patterns and parsing each block independently.
126
+ */
127
+ function salvageYaml(content) {
128
+ const salvaged = [];
129
+ let mrsf_version = "1.0";
130
+ let document = "unknown";
131
+ // Try to extract top-level fields from the beginning
132
+ const versionMatch = content.match(/^mrsf_version:\s*["']?([^"'\n]+)/m);
133
+ if (versionMatch)
134
+ mrsf_version = versionMatch[1].trim();
135
+ const docMatch = content.match(/^document:\s*["']?([^"'\n]+)/m);
136
+ if (docMatch)
137
+ document = docMatch[1].trim();
138
+ // Split on comment block boundaries (- id: ...)
139
+ const blocks = content.split(/(?=^ - id:\s)/m);
140
+ for (const block of blocks) {
141
+ const trimmed = block.trim();
142
+ if (!trimmed.startsWith("- id:"))
143
+ continue;
144
+ // Wrap in a minimal YAML array context and try to parse
145
+ try {
146
+ const parsed = yaml.load(trimmed);
147
+ if (Array.isArray(parsed) && parsed.length > 0) {
148
+ const c = parsed[0];
149
+ if (c && typeof c === "object" && typeof c.id === "string") {
150
+ salvaged.push(c);
151
+ }
152
+ }
153
+ else if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
154
+ salvaged.push(parsed);
155
+ }
156
+ }
157
+ catch {
158
+ // This block is unparseable — skip
159
+ }
160
+ }
161
+ const doc = {
162
+ mrsf_version,
163
+ document,
164
+ comments: salvaged,
165
+ };
166
+ return {
167
+ doc: salvaged.length > 0 ? doc : null,
168
+ error: `YAML parse failed. Salvaged ${salvaged.length} comment(s) from raw content.`,
169
+ partialComments: salvaged.length > 0 ? salvaged : undefined,
170
+ };
171
+ }
46
172
  /**
47
173
  * Read a Markdown document from disk and return its lines.
48
174
  * Lines are 1-indexed in the returned array (index 0 is unused).
@@ -37,6 +37,9 @@ export declare function toJson(doc: MrsfDocument): string;
37
37
  * whitespace byte-for-byte for unchanged content.
38
38
  *
39
39
  * For new files or JSON, writes from scratch.
40
+ *
41
+ * Writes to the same file path are serialized (queued) to prevent race
42
+ * conditions from concurrent calls. All writes are atomic (temp + rename).
40
43
  */
41
44
  export declare function writeSidecar(filePath: string, doc: MrsfDocument): Promise<void>;
42
45
  //# sourceMappingURL=writer.d.ts.map
@@ -10,12 +10,62 @@
10
10
  *
11
11
  * Auto-computes selected_text_hash when selected_text changes.
12
12
  */
13
- import { readFile, writeFile } from "node:fs/promises";
13
+ import { readFile, writeFile, rename, unlink } from "node:fs/promises";
14
14
  import { existsSync } from "node:fs";
15
- import { createHash } from "node:crypto";
15
+ import { createHash, randomBytes } from "node:crypto";
16
16
  import path from "node:path";
17
17
  import { Document, Parser, CST, parse as yamlParse } from "yaml";
18
18
  /* ------------------------------------------------------------------ */
19
+ /* Per-file write serialization */
20
+ /* ------------------------------------------------------------------ */
21
+ /**
22
+ * Map of absolute file paths to pending write promises.
23
+ * Ensures that concurrent writes to the same sidecar file are serialized
24
+ * (queued), preventing race conditions during the read-modify-write cycle.
25
+ */
26
+ const writeQueue = new Map();
27
+ /**
28
+ * Enqueue a write operation for the given file path, ensuring only one
29
+ * write executes at a time per file.
30
+ */
31
+ function enqueueWrite(abs, fn) {
32
+ const prev = writeQueue.get(abs) ?? Promise.resolve();
33
+ const next = prev.then(fn, fn); // run fn even if previous write failed
34
+ writeQueue.set(abs, next);
35
+ // Clean up map entry when the queue drains
36
+ next.then(() => {
37
+ if (writeQueue.get(abs) === next)
38
+ writeQueue.delete(abs);
39
+ }, () => {
40
+ if (writeQueue.get(abs) === next)
41
+ writeQueue.delete(abs);
42
+ });
43
+ return next;
44
+ }
45
+ /* ------------------------------------------------------------------ */
46
+ /* Atomic file write */
47
+ /* ------------------------------------------------------------------ */
48
+ /**
49
+ * Atomically write content to a file by writing to a temporary file in
50
+ * the same directory, then renaming. On POSIX this is atomic; on
51
+ * Windows it replaces the target.
52
+ */
53
+ async function atomicWriteFile(filePath, content) {
54
+ const tmp = filePath + "." + randomBytes(6).toString("hex") + ".tmp";
55
+ try {
56
+ await writeFile(tmp, content, "utf-8");
57
+ await rename(tmp, filePath);
58
+ }
59
+ catch (err) {
60
+ // Clean up temp file on failure
61
+ try {
62
+ await unlink(tmp);
63
+ }
64
+ catch { /* ignore */ }
65
+ throw err;
66
+ }
67
+ }
68
+ /* ------------------------------------------------------------------ */
19
69
  /* Hash helpers */
20
70
  /* ------------------------------------------------------------------ */
21
71
  /**
@@ -51,22 +101,46 @@ const COMMENT_KEY_ORDER = [
51
101
  * Convert a JS value to its YAML source text representation.
52
102
  * Strings that require quoting get double-quoted; numbers and booleans
53
103
  * are plain scalars.
104
+ *
105
+ * Uses an allowlist approach: only strings that consist entirely of
106
+ * "safe" characters are emitted as bare scalars. Everything else is
107
+ * double-quoted via JSON.stringify, which is always valid YAML.
54
108
  */
55
109
  function valueToSource(v) {
56
110
  if (typeof v === "number" || typeof v === "boolean")
57
111
  return String(v);
58
112
  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)) {
113
+ // Empty string must be quoted
114
+ if (v === "")
115
+ return JSON.stringify(v);
116
+ // YAML reserved words / null aliases must be quoted
117
+ if (v === "true" || v === "false" || v === "null" ||
118
+ v === "True" || v === "False" || v === "Null" ||
119
+ v === "TRUE" || v === "FALSE" || v === "NULL" ||
120
+ v === "yes" || v === "no" || v === "on" || v === "off" ||
121
+ v === "Yes" || v === "No" || v === "On" || v === "Off" ||
122
+ v === "YES" || v === "NO" || v === "ON" || v === "OFF" ||
123
+ v === "~" || v === ".inf" || v === "-.inf" || v === ".nan") {
124
+ return JSON.stringify(v);
125
+ }
126
+ // Strings starting with digits (could be parsed as number) must be quoted
127
+ if (/^\d/.test(v))
67
128
  return JSON.stringify(v);
129
+ // Allowlist: safe plain scalars consist of word chars, hyphens (not
130
+ // leading), dots, slashes, spaces (not leading/trailing), parentheses,
131
+ // and @ — but NO YAML indicators or ambiguous sequences.
132
+ // A safe string:
133
+ // • starts with a letter, underscore
134
+ // • contains only [A-Za-z0-9 _./()@+-] (note: hyphen mid-string is OK)
135
+ // • does NOT contain " #" (inline comment)
136
+ // • does NOT start with "- " or end with ":"
137
+ // • has no leading/trailing whitespace
138
+ // • contains no newlines or tabs
139
+ if (/^[A-Za-z_][A-Za-z0-9 _./()\-@+]*$/.test(v) && !v.includes(" #") && !/\s$/.test(v)) {
140
+ return v;
68
141
  }
69
- return v;
142
+ // Everything else: double-quote (JSON.stringify handles escaping)
143
+ return JSON.stringify(v);
70
144
  }
71
145
  return String(v);
72
146
  }
@@ -262,22 +336,31 @@ export function toJson(doc) {
262
336
  * whitespace byte-for-byte for unchanged content.
263
337
  *
264
338
  * For new files or JSON, writes from scratch.
339
+ *
340
+ * Writes to the same file path are serialized (queued) to prevent race
341
+ * conditions from concurrent calls. All writes are atomic (temp + rename).
265
342
  */
266
343
  export async function writeSidecar(filePath, doc) {
267
344
  const abs = path.resolve(filePath);
345
+ return enqueueWrite(abs, () => writeSidecarInternal(abs, doc));
346
+ }
347
+ /**
348
+ * Internal implementation of writeSidecar (runs inside the per-file queue).
349
+ */
350
+ async function writeSidecarInternal(abs, doc) {
268
351
  const isJson = abs.endsWith(".review.json");
269
352
  if (isJson) {
270
353
  // For JSON, always sync hashes and write fresh
271
354
  for (const comment of doc.comments)
272
355
  syncHash(comment);
273
- await writeFile(abs, toJson(doc), "utf-8");
356
+ await atomicWriteFile(abs, toJson(doc));
274
357
  return;
275
358
  }
276
359
  // ── YAML round-trip path ──────────────────────────────────────────
277
360
  if (!existsSync(abs)) {
278
361
  for (const comment of doc.comments)
279
362
  syncHash(comment);
280
- await writeFile(abs, toYaml(doc), "utf-8");
363
+ await atomicWriteFile(abs, toYaml(doc));
281
364
  return;
282
365
  }
283
366
  let raw;
@@ -285,7 +368,7 @@ export async function writeSidecar(filePath, doc) {
285
368
  raw = await readFile(abs, "utf-8");
286
369
  }
287
370
  catch {
288
- await writeFile(abs, toYaml(doc), "utf-8");
371
+ await atomicWriteFile(abs, toYaml(doc));
289
372
  return;
290
373
  }
291
374
  // Parse to CST tokens
@@ -295,7 +378,7 @@ export async function writeSidecar(filePath, doc) {
295
378
  }
296
379
  catch {
297
380
  // Unparseable — write fresh
298
- await writeFile(abs, toYaml(doc), "utf-8");
381
+ await atomicWriteFile(abs, toYaml(doc));
299
382
  return;
300
383
  }
301
384
  // Also parse to plain JS object for value comparison (so we compare
@@ -307,6 +390,13 @@ export async function writeSidecar(filePath, doc) {
307
390
  catch {
308
391
  // ignore — we'll fall through to source-level comparison
309
392
  }
393
+ // ── Structural validation of parsed YAML ──────────────────────────
394
+ // If CST parsed OK but the content isn't a valid MrsfDocument shape
395
+ // (e.g. comments is not an array due to injection), discard and write fresh.
396
+ if (currentDoc && (!Array.isArray(currentDoc.comments))) {
397
+ await atomicWriteFile(abs, toYaml(doc));
398
+ return;
399
+ }
310
400
  // Build lookup of current parsed values by comment id
311
401
  const currentById = new Map();
312
402
  if (currentDoc?.comments) {
@@ -340,7 +430,7 @@ export async function writeSidecar(filePath, doc) {
340
430
  }
341
431
  const docToken = findCstDocument(tokens);
342
432
  if (!docToken?.value || docToken.value.type !== "block-map") {
343
- await writeFile(abs, toYaml(doc), "utf-8");
433
+ await atomicWriteFile(abs, toYaml(doc));
344
434
  return;
345
435
  }
346
436
  const body = docToken.value;
@@ -357,7 +447,7 @@ export async function writeSidecar(filePath, doc) {
357
447
  const commentsEntry = findCstMapEntry(body, "comments");
358
448
  if (!commentsEntry?.value || commentsEntry.value.type !== "block-seq") {
359
449
  // No existing comments seq — write fresh
360
- await writeFile(abs, toYaml(doc), "utf-8");
450
+ await atomicWriteFile(abs, toYaml(doc));
361
451
  return;
362
452
  }
363
453
  const seq = commentsEntry.value;
@@ -434,6 +524,6 @@ export async function writeSidecar(filePath, doc) {
434
524
  }
435
525
  // ── Stringify via CST (byte-identical for untouched content) ───────
436
526
  const result = tokens.map((t) => CST.stringify(t)).join("");
437
- await writeFile(abs, result, "utf-8");
527
+ await atomicWriteFile(abs, result);
438
528
  }
439
529
  //# sourceMappingURL=writer.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrsf/cli",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",