@mrsf/cli 0.4.3 → 0.6.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.
@@ -12,7 +12,7 @@
12
12
  * Step 4 – orphan
13
13
  */
14
14
  import type { MrsfDocument, ReanchorOptions, ReanchorResult } from "./types.js";
15
- export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./reanchor-core.js";
15
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, resolveAnchor, toReanchorLines, } from "./reanchor-core.js";
16
16
  /**
17
17
  * Re-anchor all comments in an MRSF document.
18
18
  */
@@ -18,7 +18,7 @@ import { sidecarToDocument } from "./discovery.js";
18
18
  import { parseSidecar } from "./parser.js";
19
19
  import { writeSidecar } from "./writer.js";
20
20
  import path from "node:path";
21
- export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, toReanchorLines, } from "./reanchor-core.js";
21
+ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorComment, reanchorDocumentLines, reanchorDocumentText, resolveAnchor, toReanchorLines, } from "./reanchor-core.js";
22
22
  // ---------------------------------------------------------------------------
23
23
  // Constants
24
24
  // ---------------------------------------------------------------------------
@@ -31,6 +31,7 @@ export { applyReanchorResults, DEFAULT_THRESHOLD, HIGH_THRESHOLD, reanchorCommen
31
31
  export async function reanchorDocument(doc, documentLines, opts = {}) {
32
32
  const results = [];
33
33
  const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
34
+ const proximityWindow = opts.proximityWindow;
34
35
  // Determine git context
35
36
  let diffHunks;
36
37
  let commitIsStale = false;
@@ -51,17 +52,18 @@ export async function reanchorDocument(doc, documentLines, opts = {}) {
51
52
  diffHunks: hunks,
52
53
  threshold,
53
54
  commitIsStale: true,
55
+ proximityWindow,
54
56
  });
55
57
  results.push(result);
56
58
  continue;
57
59
  }
58
60
  // non-stale or no commit
59
- results.push(reanchorComment(comment, documentLines, { threshold, commitIsStale: false }));
61
+ results.push(reanchorComment(comment, documentLines, { threshold, commitIsStale: false, proximityWindow }));
60
62
  }
61
63
  return results;
62
64
  }
63
65
  }
64
- return reanchorDocumentLines(doc, documentLines, { threshold });
66
+ return reanchorDocumentLines(doc, documentLines, { threshold, proximityWindow });
65
67
  }
66
68
  /**
67
69
  * High-level re-anchor for a single sidecar file path.
@@ -0,0 +1,106 @@
1
+ export declare const mrsfSchema: {
2
+ $schema: string;
3
+ $id: string;
4
+ title: string;
5
+ description: string;
6
+ type: string;
7
+ required: string[];
8
+ additionalProperties: boolean;
9
+ properties: {
10
+ mrsf_version: {
11
+ type: string;
12
+ pattern: string;
13
+ description: string;
14
+ };
15
+ document: {
16
+ type: string;
17
+ description: string;
18
+ };
19
+ comments: {
20
+ type: string;
21
+ items: {
22
+ type: string;
23
+ required: string[];
24
+ additionalProperties: boolean;
25
+ properties: {
26
+ id: {
27
+ type: string;
28
+ description: string;
29
+ };
30
+ author: {
31
+ type: string;
32
+ description: string;
33
+ };
34
+ timestamp: {
35
+ type: string;
36
+ format: string;
37
+ description: string;
38
+ };
39
+ text: {
40
+ type: string;
41
+ maxLength: number;
42
+ description: string;
43
+ };
44
+ resolved: {
45
+ type: string;
46
+ description: string;
47
+ };
48
+ commit: {
49
+ type: string;
50
+ description: string;
51
+ };
52
+ type: {
53
+ type: string;
54
+ description: string;
55
+ examples: string[];
56
+ };
57
+ severity: {
58
+ type: string;
59
+ description: string;
60
+ enum: string[];
61
+ };
62
+ reply_to: {
63
+ type: string;
64
+ description: string;
65
+ };
66
+ line: {
67
+ type: string;
68
+ minimum: number;
69
+ description: string;
70
+ };
71
+ end_line: {
72
+ type: string;
73
+ minimum: number;
74
+ description: string;
75
+ };
76
+ start_column: {
77
+ type: string;
78
+ minimum: number;
79
+ description: string;
80
+ };
81
+ end_column: {
82
+ type: string;
83
+ minimum: number;
84
+ description: string;
85
+ };
86
+ selected_text: {
87
+ type: string;
88
+ maxLength: number;
89
+ description: string;
90
+ };
91
+ anchored_text: {
92
+ type: string;
93
+ maxLength: number;
94
+ description: string;
95
+ };
96
+ selected_text_hash: {
97
+ type: string;
98
+ pattern: string;
99
+ description: string;
100
+ };
101
+ };
102
+ };
103
+ };
104
+ };
105
+ };
106
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1,3 @@
1
+ import mrsfSchemaJson from "../../mrsf.schema.json" with { type: "json" };
2
+ export const mrsfSchema = mrsfSchemaJson;
3
+ //# sourceMappingURL=schema.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * MRSF string (de)serialization — pure, dependency-light helpers that parse and
3
+ * serialize MRSF sidecar content to/from strings.
4
+ *
5
+ * This module intentionally has **no Node-only imports** (no `node:fs`,
6
+ * `node:path`, `node:crypto`, …) so it can be consumed from browser/host
7
+ * adapters via the slim `@mrsf/cli/browser` entry point. Filesystem-bound
8
+ * helpers (read/discover/write a file) live in `parser.ts` / `writer.ts`.
9
+ */
10
+ import type { MrsfDocument, Comment } from "./types.js";
11
+ /** Result from a lenient (non-throwing) parse attempt. */
12
+ export interface LenientParseResult {
13
+ /** Fully parsed document, or null if parsing failed entirely. */
14
+ doc: MrsfDocument | null;
15
+ /** If parsing failed or produced warnings, the error message. */
16
+ error?: string;
17
+ /** Comments that could be salvaged from a partially-corrupted sidecar. */
18
+ partialComments?: Comment[];
19
+ }
20
+ /**
21
+ * Parse MRSF sidecar content from a string.
22
+ * Detects JSON vs YAML based on content or optional filename hint.
23
+ */
24
+ export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
25
+ /**
26
+ * Lenient parse from string content.
27
+ */
28
+ export declare function parseSidecarContentLenient(content: string, filenameHint?: string): LenientParseResult;
29
+ /**
30
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
31
+ */
32
+ export declare function toYaml(doc: MrsfDocument): string;
33
+ /**
34
+ * Serialize an MrsfDocument to JSON.
35
+ */
36
+ export declare function toJson(doc: MrsfDocument): string;
37
+ //# sourceMappingURL=serialize.d.ts.map
@@ -0,0 +1,170 @@
1
+ /**
2
+ * MRSF string (de)serialization — pure, dependency-light helpers that parse and
3
+ * serialize MRSF sidecar content to/from strings.
4
+ *
5
+ * This module intentionally has **no Node-only imports** (no `node:fs`,
6
+ * `node:path`, `node:crypto`, …) so it can be consumed from browser/host
7
+ * adapters via the slim `@mrsf/cli/browser` entry point. Filesystem-bound
8
+ * helpers (read/discover/write a file) live in `parser.ts` / `writer.ts`.
9
+ */
10
+ import yaml from "js-yaml";
11
+ import { Document } from "yaml";
12
+ // ---------------------------------------------------------------------------
13
+ // Parsing (string → document)
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * Parse MRSF sidecar content from a string.
17
+ * Detects JSON vs YAML based on content or optional filename hint.
18
+ */
19
+ export function parseSidecarContent(content, filenameHint) {
20
+ const trimmed = content.trim();
21
+ let parsed;
22
+ // Detect JSON by content or filename
23
+ const isJson = trimmed.startsWith("{") ||
24
+ (filenameHint && filenameHint.endsWith(".review.json"));
25
+ if (isJson) {
26
+ try {
27
+ parsed = JSON.parse(trimmed);
28
+ }
29
+ catch (e) {
30
+ throw new Error(`Failed to parse JSON: ${e.message}`);
31
+ }
32
+ }
33
+ else {
34
+ try {
35
+ parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
36
+ }
37
+ catch (e) {
38
+ throw new Error(`Failed to parse YAML: ${e.message}`);
39
+ }
40
+ }
41
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
42
+ throw new Error("MRSF sidecar must be a YAML/JSON object");
43
+ }
44
+ return parsed;
45
+ }
46
+ /**
47
+ * Lenient parse from string content.
48
+ */
49
+ export function parseSidecarContentLenient(content, filenameHint) {
50
+ const trimmed = content.trim();
51
+ if (!trimmed) {
52
+ return { doc: null, error: "File is empty" };
53
+ }
54
+ const isJson = trimmed.startsWith("{") ||
55
+ (filenameHint && filenameHint.endsWith(".review.json"));
56
+ // First, try a normal parse
57
+ let parsed;
58
+ try {
59
+ parsed = isJson ? JSON.parse(trimmed) : yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
60
+ }
61
+ catch (e) {
62
+ // Total parse failure — try to salvage what we can from YAML
63
+ if (!isJson) {
64
+ return salvageYaml(trimmed);
65
+ }
66
+ return { doc: null, error: `Failed to parse JSON: ${e.message}` };
67
+ }
68
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
69
+ return { doc: null, error: "MRSF sidecar must be a YAML/JSON object" };
70
+ }
71
+ const obj = parsed;
72
+ const doc = {
73
+ mrsf_version: typeof obj.mrsf_version === "string" ? obj.mrsf_version : "1.0",
74
+ document: typeof obj.document === "string" ? obj.document : "unknown",
75
+ comments: [],
76
+ };
77
+ if (!Array.isArray(obj.comments)) {
78
+ return {
79
+ doc,
80
+ error: "comments field is not an array — file may be corrupted",
81
+ };
82
+ }
83
+ // Validate each comment individually
84
+ const good = [];
85
+ const bad = [];
86
+ for (let i = 0; i < obj.comments.length; i++) {
87
+ const c = obj.comments[i];
88
+ if (c && typeof c === "object" && !Array.isArray(c) && typeof c.id === "string") {
89
+ good.push(c);
90
+ }
91
+ else {
92
+ bad.push(i);
93
+ }
94
+ }
95
+ doc.comments = good;
96
+ if (bad.length > 0) {
97
+ return {
98
+ doc,
99
+ error: `${bad.length} comment(s) at indices [${bad.join(", ")}] were malformed and skipped`,
100
+ partialComments: good,
101
+ };
102
+ }
103
+ return { doc };
104
+ }
105
+ /**
106
+ * Attempt to extract individual comment blocks from corrupted YAML by
107
+ * splitting on `- id:` patterns and parsing each block independently.
108
+ */
109
+ function salvageYaml(content) {
110
+ const salvaged = [];
111
+ let mrsf_version = "1.0";
112
+ let document = "unknown";
113
+ // Try to extract top-level fields from the beginning
114
+ const versionMatch = content.match(/^mrsf_version:\s*["']?([^"'\n]+)/m);
115
+ if (versionMatch)
116
+ mrsf_version = versionMatch[1].trim();
117
+ const docMatch = content.match(/^document:\s*["']?([^"'\n]+)/m);
118
+ if (docMatch)
119
+ document = docMatch[1].trim();
120
+ // Split on comment block boundaries (- id: ...)
121
+ const blocks = content.split(/(?=^ - id:\s)/m);
122
+ for (const block of blocks) {
123
+ const trimmed = block.trim();
124
+ if (!trimmed.startsWith("- id:"))
125
+ continue;
126
+ // Wrap in a minimal YAML array context and try to parse
127
+ try {
128
+ const parsed = yaml.load(trimmed, { schema: yaml.JSON_SCHEMA });
129
+ if (Array.isArray(parsed) && parsed.length > 0) {
130
+ const c = parsed[0];
131
+ if (c && typeof c === "object" && typeof c.id === "string") {
132
+ salvaged.push(c);
133
+ }
134
+ }
135
+ else if (parsed && typeof parsed === "object" && typeof parsed.id === "string") {
136
+ salvaged.push(parsed);
137
+ }
138
+ }
139
+ catch {
140
+ // This block is unparseable — skip
141
+ }
142
+ }
143
+ const doc = {
144
+ mrsf_version,
145
+ document,
146
+ comments: salvaged,
147
+ };
148
+ return {
149
+ doc: salvaged.length > 0 ? doc : null,
150
+ error: `YAML parse failed. Salvaged ${salvaged.length} comment(s) from raw content.`,
151
+ partialComments: salvaged.length > 0 ? salvaged : undefined,
152
+ };
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Serialization (document → string)
156
+ // ---------------------------------------------------------------------------
157
+ /**
158
+ * Serialize an MrsfDocument to YAML (for new files / non-round-trip use).
159
+ */
160
+ export function toYaml(doc) {
161
+ const yamlDoc = new Document(doc);
162
+ return yamlDoc.toString({ lineWidth: 0 });
163
+ }
164
+ /**
165
+ * Serialize an MrsfDocument to JSON.
166
+ */
167
+ export function toJson(doc) {
168
+ return JSON.stringify(doc, null, 2) + "\n";
169
+ }
170
+ //# sourceMappingURL=serialize.js.map
@@ -40,6 +40,7 @@ export interface MrsfConfig {
40
40
  export type DiagnosticSeverity = "error" | "warning";
41
41
  export interface ValidationDiagnostic {
42
42
  severity: DiagnosticSeverity;
43
+ code: string;
43
44
  message: string;
44
45
  /** Path within the document, e.g. "/comments/0/end_line". */
45
46
  path?: string;
@@ -69,6 +70,23 @@ export interface ReanchorResult {
69
70
  /** Human-readable explanation of the resolution. */
70
71
  reason: string;
71
72
  }
73
+ export interface AnchorPosition {
74
+ status: ReanchorStatus;
75
+ score: number;
76
+ /** Absolute 0-based character offset in normalized document text (start). */
77
+ from?: number;
78
+ /** Absolute 0-based character offset in normalized document text (end, exclusive). */
79
+ to?: number;
80
+ /** 1-based start line. */
81
+ line?: number;
82
+ /** 1-based end line. */
83
+ endLine?: number;
84
+ /** 0-based start column. */
85
+ startColumn?: number;
86
+ /** 0-based end column. */
87
+ endColumn?: number;
88
+ reason: string;
89
+ }
72
90
  export interface FuzzyCandidate {
73
91
  /** Matched text in the document. */
74
92
  text: string;
@@ -154,6 +172,13 @@ export interface ReanchorOptions extends BaseOptions {
154
172
  fromCommit?: string;
155
173
  /** Update selected_text to match anchored_text (opt-in per §6.2). */
156
174
  updateText?: boolean;
175
+ /**
176
+ * Proximity window (in lines) for the §7.4 step 1a relocation guard.
177
+ * A lone exact match farther than this from the original line, when the
178
+ * original position's text has changed, is kept in place and flagged for
179
+ * re-anchoring rather than relocated with full confidence. Default 5.
180
+ */
181
+ proximityWindow?: number;
157
182
  /** Force-anchor: update commit to HEAD and clear audit fields for high-confidence results. */
158
183
  force?: boolean;
159
184
  }
@@ -0,0 +1,6 @@
1
+ import type { MrsfDocument, ValidationDiagnostic, ValidationResult } from "./types.js";
2
+ export type HashFunction = (text: string) => string;
3
+ export declare function validateDocument(doc: MrsfDocument, schema?: object): ValidationResult;
4
+ export declare function validateSchema(doc: MrsfDocument, rawSchema: object, errors: ValidationDiagnostic[]): void;
5
+ export declare function validateCrossFields(doc: MrsfDocument, errors: ValidationDiagnostic[], warnings: ValidationDiagnostic[], hash?: HashFunction): void;
6
+ //# sourceMappingURL=validate-core.d.ts.map
@@ -0,0 +1,128 @@
1
+ import AjvModule from "ajv";
2
+ import addFormatsModule from "ajv-formats";
3
+ import { mrsfSchema } from "./schema.js";
4
+ const Ajv = AjvModule.default ?? AjvModule;
5
+ const addFormats = addFormatsModule.default ?? addFormatsModule;
6
+ export function validateDocument(doc, schema = mrsfSchema) {
7
+ const errors = [];
8
+ const warnings = [];
9
+ validateSchema(doc, schema, errors);
10
+ validateCrossFields(doc, errors, warnings);
11
+ return {
12
+ valid: errors.length === 0,
13
+ errors,
14
+ warnings,
15
+ };
16
+ }
17
+ export function validateSchema(doc, rawSchema, errors) {
18
+ const { $schema, ...schema } = rawSchema;
19
+ void $schema;
20
+ const ajv = new Ajv({ allErrors: true, strict: false });
21
+ addFormats(ajv);
22
+ const ajvValidate = ajv.compile(schema);
23
+ const schemaValid = ajvValidate(doc);
24
+ if (!schemaValid && ajvValidate.errors) {
25
+ for (const err of ajvValidate.errors) {
26
+ errors.push({
27
+ severity: "error",
28
+ code: "schema-violation",
29
+ message: `${err.instancePath || "/"}: ${err.message ?? "schema error"}`,
30
+ path: err.instancePath || "/",
31
+ });
32
+ }
33
+ }
34
+ }
35
+ export function validateCrossFields(doc, errors, warnings, hash) {
36
+ if (!Array.isArray(doc.comments))
37
+ return;
38
+ const ids = new Set();
39
+ const allIds = doc.comments.map((x) => x.id);
40
+ for (let i = 0; i < doc.comments.length; i++) {
41
+ const c = doc.comments[i];
42
+ const prefix = `/comments/${i}`;
43
+ if (c.id) {
44
+ if (ids.has(c.id)) {
45
+ errors.push({
46
+ severity: "error",
47
+ code: "duplicate-id",
48
+ message: `Duplicate comment id "${c.id}"`,
49
+ path: `${prefix}/id`,
50
+ commentId: c.id,
51
+ });
52
+ }
53
+ ids.add(c.id);
54
+ }
55
+ if (c.line != null && c.end_line != null && c.end_line < c.line) {
56
+ errors.push({
57
+ severity: "error",
58
+ code: "end-line-before-line",
59
+ message: `end_line (${c.end_line}) must be ≥ line (${c.line})`,
60
+ path: `${prefix}/end_line`,
61
+ commentId: c.id,
62
+ });
63
+ }
64
+ if (c.start_column != null &&
65
+ c.end_column != null &&
66
+ (c.line == null || c.end_line == null || c.line === c.end_line) &&
67
+ c.end_column < c.start_column) {
68
+ errors.push({
69
+ severity: "error",
70
+ code: "end-column-before-start-column",
71
+ message: `end_column (${c.end_column}) must be ≥ start_column (${c.start_column}) on the same line`,
72
+ path: `${prefix}/end_column`,
73
+ commentId: c.id,
74
+ });
75
+ }
76
+ if (c.selected_text && c.selected_text.length > 4096) {
77
+ errors.push({
78
+ severity: "error",
79
+ code: "selected-text-too-long",
80
+ message: `selected_text exceeds 4096 characters (${c.selected_text.length})`,
81
+ path: `${prefix}/selected_text`,
82
+ commentId: c.id,
83
+ });
84
+ }
85
+ if (c.text && c.text.length > 16384) {
86
+ warnings.push({
87
+ severity: "warning",
88
+ code: "text-too-long",
89
+ message: `text exceeds recommended 16384 characters (${c.text.length})`,
90
+ path: `${prefix}/text`,
91
+ commentId: c.id,
92
+ });
93
+ }
94
+ // Browser-safe callers do not get the Node SHA-256 implementation.
95
+ // The hash check runs only when a hash function is injected by the Node validator.
96
+ if (hash && c.selected_text && c.selected_text_hash) {
97
+ const expected = hash(c.selected_text);
98
+ if (c.selected_text_hash !== expected) {
99
+ warnings.push({
100
+ severity: "warning",
101
+ code: "hash-mismatch",
102
+ message: `selected_text_hash mismatch (expected ${expected.slice(0, 12)}…, got ${c.selected_text_hash.slice(0, 12)}…)`,
103
+ path: `${prefix}/selected_text_hash`,
104
+ commentId: c.id,
105
+ });
106
+ }
107
+ }
108
+ if (c.reply_to && !ids.has(c.reply_to) && !allIds.includes(c.reply_to)) {
109
+ warnings.push({
110
+ severity: "warning",
111
+ code: "unresolved-reply-to",
112
+ message: `reply_to "${c.reply_to}" does not resolve to any comment id in this file`,
113
+ path: `${prefix}/reply_to`,
114
+ commentId: c.id,
115
+ });
116
+ }
117
+ if (c.line != null && !c.selected_text) {
118
+ warnings.push({
119
+ severity: "warning",
120
+ code: "missing-selected-text",
121
+ message: "Comment has line anchors but no selected_text — anchoring will be fragile across edits",
122
+ path: `${prefix}/selected_text`,
123
+ commentId: c.id,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ //# sourceMappingURL=validate-core.js.map