@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 +54 -0
- package/dist/bin.js +2 -1
- package/dist/commands/add.js +9 -0
- package/dist/commands/init.js +5 -0
- package/dist/commands/reanchor.js +24 -2
- package/dist/commands/rename.js +5 -0
- package/dist/commands/resolve.js +4 -0
- package/dist/commands/status.js +6 -0
- package/dist/commands/validate.js +4 -0
- package/dist/lib/writer.d.ts +15 -3
- package/dist/lib/writer.js +354 -16
- package/package.json +3 -2
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);
|
package/dist/commands/add.js
CHANGED
|
@@ -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}` : "") +
|
package/dist/commands/init.js
CHANGED
|
@@ -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) {
|
package/dist/commands/rename.js
CHANGED
|
@@ -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
|
package/dist/commands/resolve.js
CHANGED
|
@@ -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
|
package/dist/commands/status.js
CHANGED
|
@@ -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);
|
package/dist/lib/writer.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MRSF Writer — serialize MrsfDocument back to YAML or JSON.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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.
|
|
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
|
package/dist/lib/writer.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MRSF Writer — serialize MrsfDocument back to YAML or JSON.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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.
|
|
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",
|