@mrsf/cli 0.2.3 → 0.3.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 +9 -2
- package/dist/bin.js +3 -3
- package/dist/commands/validate.js +2 -2
- package/dist/commands/watch.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/lib/discovery.js +1 -1
- package/dist/lib/parser.d.ts +22 -1
- package/dist/lib/parser.js +127 -1
- package/dist/lib/validator.js +4 -1
- package/dist/lib/writer.d.ts +3 -0
- package/dist/lib/writer.js +110 -18
- package/mrsf.schema.json +113 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# MRSF CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
[](MRSF-v1.0.md)
|
|
5
|
+
[](https://www.npmjs.com/package/@mrsf/cli)
|
|
6
|
+
[](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/bin.js
CHANGED
|
@@ -49,7 +49,7 @@ function showBanner() {
|
|
|
49
49
|
${chalk.cyan.bold("╔╦╗")}${chalk.blue.bold("╦═╗")}${chalk.magenta.bold("╔═╗")}${chalk.yellow.bold("╔═╗")}
|
|
50
50
|
${chalk.cyan.bold("║║║")}${chalk.blue.bold("╠╦╝")}${chalk.magenta.bold("╚═╗")}${chalk.yellow.bold("╠╣")}
|
|
51
51
|
${chalk.cyan.bold("╩ ╩")}${chalk.blue.bold("╩╚═")}${chalk.magenta.bold("╚═╝")}${chalk.yellow.bold("╚")}
|
|
52
|
-
${chalk.dim("Markdown Review Sidecar Format")} ${chalk.dim.italic(`v${version}`)}
|
|
52
|
+
${chalk.dim("Markdown Review Sidecar Format")} ${chalk.dim.italic(`v${version} (Node.js)`)}
|
|
53
53
|
${chalk.dim.underline(repo)}
|
|
54
54
|
`;
|
|
55
55
|
process.stderr.write(banner + "\n");
|
|
@@ -59,8 +59,8 @@ showBanner();
|
|
|
59
59
|
const program = new Command();
|
|
60
60
|
program
|
|
61
61
|
.name("mrsf")
|
|
62
|
-
.description("Markdown Review Sidecar Format — CLI & toolkit")
|
|
63
|
-
.version(version)
|
|
62
|
+
.description("Markdown Review Sidecar Format — CLI & toolkit (Node.js)")
|
|
63
|
+
.version(`${version} (Node.js)`)
|
|
64
64
|
.option("--cwd <dir>", "Working directory")
|
|
65
65
|
.option("--config <path>", "Path to .mrsf.yaml")
|
|
66
66
|
.option("--no-color", "Disable colour output")
|
|
@@ -42,11 +42,11 @@ export function registerValidate(program) {
|
|
|
42
42
|
console.log(`${icon} ${sp}`);
|
|
43
43
|
}
|
|
44
44
|
for (const e of result.errors) {
|
|
45
|
-
console.log(chalk.red(`
|
|
45
|
+
console.log(chalk.red(` ERROR: ${e.message}${e.path ? ` (${e.path})` : ""}`));
|
|
46
46
|
}
|
|
47
47
|
for (const w of result.warnings) {
|
|
48
48
|
const fn = opts.strict ? chalk.red : chalk.yellow;
|
|
49
|
-
console.log(fn(` ${opts.strict ? "
|
|
49
|
+
console.log(fn(` ${opts.strict ? "ERROR" : "WARN"}: ${w.message}${w.path ? ` (${w.path})` : ""}`));
|
|
50
50
|
}
|
|
51
51
|
if (verbose) {
|
|
52
52
|
console.log(chalk.dim(` ${result.errors.length} error(s), ${result.warnings.length} warning(s)`));
|
package/dist/commands/watch.js
CHANGED
|
@@ -66,11 +66,11 @@ function printValidateResults(label, result, strict) {
|
|
|
66
66
|
const icon = effectiveValid ? chalk.green("✓") : chalk.red("✗");
|
|
67
67
|
console.log(` ${chalk.cyan("validate")} ${icon} ${label}`);
|
|
68
68
|
for (const e of result.errors) {
|
|
69
|
-
console.log(chalk.red(`
|
|
69
|
+
console.log(chalk.red(` ERROR: ${e.message}${e.path ? ` (${e.path})` : ""}`));
|
|
70
70
|
}
|
|
71
71
|
for (const w of result.warnings) {
|
|
72
72
|
const fn = strict ? chalk.red : chalk.yellow;
|
|
73
|
-
const tag = strict ? "
|
|
73
|
+
const tag = strict ? "ERROR" : "WARN";
|
|
74
74
|
console.log(fn(` ${tag}: ${w.message}${w.path ? ` (${w.path})` : ""}`));
|
|
75
75
|
}
|
|
76
76
|
}
|
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
|
package/dist/lib/discovery.js
CHANGED
|
@@ -38,7 +38,7 @@ export async function loadConfig(workspaceRoot, configPath) {
|
|
|
38
38
|
if (!existsSync(cfgPath))
|
|
39
39
|
return null;
|
|
40
40
|
const raw = await readFile(cfgPath, "utf-8");
|
|
41
|
-
const parsed = yaml.load(raw);
|
|
41
|
+
const parsed = yaml.load(raw, { schema: yaml.JSON_SCHEMA });
|
|
42
42
|
if (!parsed || typeof parsed !== "object")
|
|
43
43
|
return null;
|
|
44
44
|
const config = {};
|
package/dist/lib/parser.d.ts
CHANGED
|
@@ -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).
|
package/dist/lib/parser.js
CHANGED
|
@@ -32,7 +32,7 @@ export function parseSidecarContent(content, filenameHint) {
|
|
|
32
32
|
}
|
|
33
33
|
else {
|
|
34
34
|
try {
|
|
35
|
-
parsed = yaml.load(trimmed);
|
|
35
|
+
parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
|
|
36
36
|
}
|
|
37
37
|
catch (e) {
|
|
38
38
|
throw new Error(`Failed to parse YAML: ${e.message}`);
|
|
@@ -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, { schema: yaml.JSON_SCHEMA });
|
|
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, { schema: yaml.JSON_SCHEMA });
|
|
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).
|
package/dist/lib/validator.js
CHANGED
|
@@ -16,8 +16,11 @@ async function loadSchema() {
|
|
|
16
16
|
if (_schemaCache)
|
|
17
17
|
return _schemaCache;
|
|
18
18
|
// The schema lives at the repo root, two levels up from dist/lib/
|
|
19
|
+
// When installed as a package, it's at the package root (cli/)
|
|
19
20
|
const candidates = [
|
|
20
|
-
path.resolve(__dirname, "
|
|
21
|
+
path.resolve(__dirname, "mrsf.schema.json"), // same dir (esbuild bundle, e.g. MCP server)
|
|
22
|
+
path.resolve(__dirname, "../../mrsf.schema.json"), // from dist/lib/ → cli/ (installed package)
|
|
23
|
+
path.resolve(__dirname, "../../../mrsf.schema.json"), // from dist/lib/ → repo root (dev)
|
|
21
24
|
path.resolve(__dirname, "../../../../mrsf.schema.json"), // fallback
|
|
22
25
|
path.resolve(process.cwd(), "mrsf.schema.json"), // cwd fallback
|
|
23
26
|
];
|
package/dist/lib/writer.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/writer.js
CHANGED
|
@@ -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,48 @@ 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) {
|
|
110
|
+
if (v instanceof Date)
|
|
111
|
+
return JSON.stringify(v.toISOString());
|
|
56
112
|
if (typeof v === "number" || typeof v === "boolean")
|
|
57
113
|
return String(v);
|
|
58
114
|
if (typeof v === "string") {
|
|
59
|
-
//
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
v === "
|
|
65
|
-
v === "
|
|
66
|
-
|
|
115
|
+
// Empty string must be quoted
|
|
116
|
+
if (v === "")
|
|
117
|
+
return JSON.stringify(v);
|
|
118
|
+
// YAML reserved words / null aliases must be quoted
|
|
119
|
+
if (v === "true" || v === "false" || v === "null" ||
|
|
120
|
+
v === "True" || v === "False" || v === "Null" ||
|
|
121
|
+
v === "TRUE" || v === "FALSE" || v === "NULL" ||
|
|
122
|
+
v === "yes" || v === "no" || v === "on" || v === "off" ||
|
|
123
|
+
v === "Yes" || v === "No" || v === "On" || v === "Off" ||
|
|
124
|
+
v === "YES" || v === "NO" || v === "ON" || v === "OFF" ||
|
|
125
|
+
v === "~" || v === ".inf" || v === "-.inf" || v === ".nan") {
|
|
126
|
+
return JSON.stringify(v);
|
|
127
|
+
}
|
|
128
|
+
// Strings starting with digits (could be parsed as number) must be quoted
|
|
129
|
+
if (/^\d/.test(v))
|
|
67
130
|
return JSON.stringify(v);
|
|
131
|
+
// Allowlist: safe plain scalars consist of word chars, hyphens (not
|
|
132
|
+
// leading), dots, slashes, spaces (not leading/trailing), parentheses,
|
|
133
|
+
// and @ — but NO YAML indicators or ambiguous sequences.
|
|
134
|
+
// A safe string:
|
|
135
|
+
// • starts with a letter, underscore
|
|
136
|
+
// • contains only [A-Za-z0-9 _./()@+-] (note: hyphen mid-string is OK)
|
|
137
|
+
// • does NOT contain " #" (inline comment)
|
|
138
|
+
// • does NOT start with "- " or end with ":"
|
|
139
|
+
// • has no leading/trailing whitespace
|
|
140
|
+
// • contains no newlines or tabs
|
|
141
|
+
if (/^[A-Za-z_][A-Za-z0-9 _./()\-@+]*$/.test(v) && !v.includes(" #") && !/\s$/.test(v)) {
|
|
142
|
+
return v;
|
|
68
143
|
}
|
|
69
|
-
|
|
144
|
+
// Everything else: double-quote (JSON.stringify handles escaping)
|
|
145
|
+
return JSON.stringify(v);
|
|
70
146
|
}
|
|
71
147
|
return String(v);
|
|
72
148
|
}
|
|
@@ -262,22 +338,31 @@ export function toJson(doc) {
|
|
|
262
338
|
* whitespace byte-for-byte for unchanged content.
|
|
263
339
|
*
|
|
264
340
|
* For new files or JSON, writes from scratch.
|
|
341
|
+
*
|
|
342
|
+
* Writes to the same file path are serialized (queued) to prevent race
|
|
343
|
+
* conditions from concurrent calls. All writes are atomic (temp + rename).
|
|
265
344
|
*/
|
|
266
345
|
export async function writeSidecar(filePath, doc) {
|
|
267
346
|
const abs = path.resolve(filePath);
|
|
347
|
+
return enqueueWrite(abs, () => writeSidecarInternal(abs, doc));
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Internal implementation of writeSidecar (runs inside the per-file queue).
|
|
351
|
+
*/
|
|
352
|
+
async function writeSidecarInternal(abs, doc) {
|
|
268
353
|
const isJson = abs.endsWith(".review.json");
|
|
269
354
|
if (isJson) {
|
|
270
355
|
// For JSON, always sync hashes and write fresh
|
|
271
356
|
for (const comment of doc.comments)
|
|
272
357
|
syncHash(comment);
|
|
273
|
-
await
|
|
358
|
+
await atomicWriteFile(abs, toJson(doc));
|
|
274
359
|
return;
|
|
275
360
|
}
|
|
276
361
|
// ── YAML round-trip path ──────────────────────────────────────────
|
|
277
362
|
if (!existsSync(abs)) {
|
|
278
363
|
for (const comment of doc.comments)
|
|
279
364
|
syncHash(comment);
|
|
280
|
-
await
|
|
365
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
281
366
|
return;
|
|
282
367
|
}
|
|
283
368
|
let raw;
|
|
@@ -285,7 +370,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
285
370
|
raw = await readFile(abs, "utf-8");
|
|
286
371
|
}
|
|
287
372
|
catch {
|
|
288
|
-
await
|
|
373
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
289
374
|
return;
|
|
290
375
|
}
|
|
291
376
|
// Parse to CST tokens
|
|
@@ -295,7 +380,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
295
380
|
}
|
|
296
381
|
catch {
|
|
297
382
|
// Unparseable — write fresh
|
|
298
|
-
await
|
|
383
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
299
384
|
return;
|
|
300
385
|
}
|
|
301
386
|
// Also parse to plain JS object for value comparison (so we compare
|
|
@@ -307,6 +392,13 @@ export async function writeSidecar(filePath, doc) {
|
|
|
307
392
|
catch {
|
|
308
393
|
// ignore — we'll fall through to source-level comparison
|
|
309
394
|
}
|
|
395
|
+
// ── Structural validation of parsed YAML ──────────────────────────
|
|
396
|
+
// If CST parsed OK but the content isn't a valid MrsfDocument shape
|
|
397
|
+
// (e.g. comments is not an array due to injection), discard and write fresh.
|
|
398
|
+
if (currentDoc && (!Array.isArray(currentDoc.comments))) {
|
|
399
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
310
402
|
// Build lookup of current parsed values by comment id
|
|
311
403
|
const currentById = new Map();
|
|
312
404
|
if (currentDoc?.comments) {
|
|
@@ -340,7 +432,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
340
432
|
}
|
|
341
433
|
const docToken = findCstDocument(tokens);
|
|
342
434
|
if (!docToken?.value || docToken.value.type !== "block-map") {
|
|
343
|
-
await
|
|
435
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
344
436
|
return;
|
|
345
437
|
}
|
|
346
438
|
const body = docToken.value;
|
|
@@ -357,7 +449,7 @@ export async function writeSidecar(filePath, doc) {
|
|
|
357
449
|
const commentsEntry = findCstMapEntry(body, "comments");
|
|
358
450
|
if (!commentsEntry?.value || commentsEntry.value.type !== "block-seq") {
|
|
359
451
|
// No existing comments seq — write fresh
|
|
360
|
-
await
|
|
452
|
+
await atomicWriteFile(abs, toYaml(doc));
|
|
361
453
|
return;
|
|
362
454
|
}
|
|
363
455
|
const seq = commentsEntry.value;
|
|
@@ -434,6 +526,6 @@ export async function writeSidecar(filePath, doc) {
|
|
|
434
526
|
}
|
|
435
527
|
// ── Stringify via CST (byte-identical for untouched content) ───────
|
|
436
528
|
const result = tokens.map((t) => CST.stringify(t)).join("");
|
|
437
|
-
await
|
|
529
|
+
await atomicWriteFile(abs, result);
|
|
438
530
|
}
|
|
439
531
|
//# sourceMappingURL=writer.js.map
|
package/mrsf.schema.json
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
|
|
2
|
+
{
|
|
3
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
4
|
+
"$id": "https://github.com/wictorwilen/MRSF/raw/main/mrsf.schema.json",
|
|
5
|
+
"title": "Markdown Review Sidecar Format (MRSF) v1.0",
|
|
6
|
+
"description": "Schema for MRSF review sidecar files. See MRSF-v1.0.md for the full specification.",
|
|
7
|
+
"type": "object",
|
|
8
|
+
"required": ["mrsf_version", "document", "comments"],
|
|
9
|
+
"additionalProperties": true,
|
|
10
|
+
"properties": {
|
|
11
|
+
"mrsf_version": {
|
|
12
|
+
"type": "string",
|
|
13
|
+
"pattern": "^1\\.\\d+$",
|
|
14
|
+
"description": "MRSF format version. MUST be a supported major.minor version (e.g., 1.0)."
|
|
15
|
+
},
|
|
16
|
+
"document": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"description": "Relative path to the Markdown document being reviewed."
|
|
19
|
+
},
|
|
20
|
+
"comments": {
|
|
21
|
+
"type": "array",
|
|
22
|
+
"items": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"required": ["id", "author", "timestamp", "text", "resolved"],
|
|
25
|
+
"additionalProperties": true,
|
|
26
|
+
"properties": {
|
|
27
|
+
"id": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Globally unique, opaque, collision-resistant identifier for the comment."
|
|
30
|
+
},
|
|
31
|
+
"author": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Creator of the comment. SHOULD follow the convention 'Display Name (identifier)'."
|
|
34
|
+
},
|
|
35
|
+
"timestamp": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"format": "date-time",
|
|
38
|
+
"description": "ISO 8601 / RFC 3339 timestamp of comment creation; SHOULD include timezone offset."
|
|
39
|
+
},
|
|
40
|
+
"text": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"maxLength": 16384,
|
|
43
|
+
"description": "The content of the review comment. MUST be plain text."
|
|
44
|
+
},
|
|
45
|
+
"resolved": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"description": "Whether the comment has been resolved."
|
|
48
|
+
},
|
|
49
|
+
"commit": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Git commit hash associated with the comment. SHOULD be the full (long) SHA."
|
|
52
|
+
},
|
|
53
|
+
"type": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "Categorization of the comment. Recommended values listed in examples.",
|
|
56
|
+
"examples": [
|
|
57
|
+
"suggestion",
|
|
58
|
+
"issue",
|
|
59
|
+
"question",
|
|
60
|
+
"accuracy",
|
|
61
|
+
"style",
|
|
62
|
+
"clarity"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"severity": {
|
|
66
|
+
"type": "string",
|
|
67
|
+
"description": "Importance level of the comment.",
|
|
68
|
+
"enum": ["low", "medium", "high"]
|
|
69
|
+
},
|
|
70
|
+
"reply_to": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "ID of another comment in the same file that this comment replies to."
|
|
73
|
+
},
|
|
74
|
+
"line": {
|
|
75
|
+
"type": "integer",
|
|
76
|
+
"minimum": 1,
|
|
77
|
+
"description": "Starting line number (1-based) in the target document."
|
|
78
|
+
},
|
|
79
|
+
"end_line": {
|
|
80
|
+
"type": "integer",
|
|
81
|
+
"minimum": 1,
|
|
82
|
+
"description": "Ending line number (inclusive, 1-based). MUST be >= line."
|
|
83
|
+
},
|
|
84
|
+
"start_column": {
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"minimum": 0,
|
|
87
|
+
"description": "Starting column index (0-based) within the starting line."
|
|
88
|
+
},
|
|
89
|
+
"end_column": {
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"minimum": 0,
|
|
92
|
+
"description": "Ending column index. MUST be >= start_column when on the same line."
|
|
93
|
+
},
|
|
94
|
+
"selected_text": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"maxLength": 4096,
|
|
97
|
+
"description": "Exact text selected by the reviewer. SHOULD NOT be modified by re-anchoring tools. SHOULD NOT exceed 4096 characters."
|
|
98
|
+
},
|
|
99
|
+
"anchored_text": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"maxLength": 4096,
|
|
102
|
+
"description": "Text currently found at the resolved anchor position. Populated by re-anchoring tools when the document text differs from selected_text. SHOULD be omitted when identical to selected_text."
|
|
103
|
+
},
|
|
104
|
+
"selected_text_hash": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"pattern": "^[a-f0-9]{64}$",
|
|
107
|
+
"description": "Hex-encoded SHA-256 hash of selected_text. Immutable after creation; used for fast exact-match detection during re-anchoring, integrity verification, and staleness checks. SHOULD NOT be modified by re-anchoring tools unless selected_text is also replaced (opt-in behaviour), in which case it MUST be recomputed."
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrsf/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "CLI and library for the Markdown Review Sidecar Format (MRSF)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -31,8 +31,13 @@
|
|
|
31
31
|
"review",
|
|
32
32
|
"sidecar",
|
|
33
33
|
"mrsf",
|
|
34
|
+
"sidemark",
|
|
34
35
|
"anchoring",
|
|
35
|
-
"comments"
|
|
36
|
+
"comments",
|
|
37
|
+
"cli",
|
|
38
|
+
"yaml",
|
|
39
|
+
"validation",
|
|
40
|
+
"reanchor"
|
|
36
41
|
],
|
|
37
42
|
"license": "MIT",
|
|
38
43
|
"author": "Wictor Wilén (https://github.com/wictorwilen)",
|
|
@@ -48,6 +53,7 @@
|
|
|
48
53
|
"files": [
|
|
49
54
|
"dist/**/*.js",
|
|
50
55
|
"dist/**/*.d.ts",
|
|
56
|
+
"mrsf.schema.json",
|
|
51
57
|
"README.md",
|
|
52
58
|
"LICENSE"
|
|
53
59
|
],
|
|
@@ -66,6 +72,7 @@
|
|
|
66
72
|
"@types/js-yaml": "^4.0.9",
|
|
67
73
|
"@types/node": "^22.13.10",
|
|
68
74
|
"@types/uuid": "^10.0.0",
|
|
75
|
+
"@vitest/coverage-v8": "^3.2.4",
|
|
69
76
|
"typescript": "^5.8.2",
|
|
70
77
|
"vitest": "^3.0.9"
|
|
71
78
|
}
|