@mrsf/cli 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +346 -0
  3. package/dist/bin.d.ts +8 -0
  4. package/dist/bin.js +76 -0
  5. package/dist/commands/add.d.ts +6 -0
  6. package/dist/commands/add.js +82 -0
  7. package/dist/commands/init.d.ts +6 -0
  8. package/dist/commands/init.js +52 -0
  9. package/dist/commands/list.d.ts +6 -0
  10. package/dist/commands/list.js +95 -0
  11. package/dist/commands/reanchor.d.ts +6 -0
  12. package/dist/commands/reanchor.js +127 -0
  13. package/dist/commands/rename.d.ts +6 -0
  14. package/dist/commands/rename.js +53 -0
  15. package/dist/commands/resolve.d.ts +6 -0
  16. package/dist/commands/resolve.js +35 -0
  17. package/dist/commands/status.d.ts +6 -0
  18. package/dist/commands/status.js +156 -0
  19. package/dist/commands/validate.d.ts +6 -0
  20. package/dist/commands/validate.js +55 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +25 -0
  23. package/dist/lib/comments.d.ts +52 -0
  24. package/dist/lib/comments.js +228 -0
  25. package/dist/lib/discovery.d.ts +40 -0
  26. package/dist/lib/discovery.js +125 -0
  27. package/dist/lib/fuzzy.d.ts +39 -0
  28. package/dist/lib/fuzzy.js +277 -0
  29. package/dist/lib/git.d.ts +68 -0
  30. package/dist/lib/git.js +246 -0
  31. package/dist/lib/parser.d.ts +19 -0
  32. package/dist/lib/parser.js +56 -0
  33. package/dist/lib/reanchor.d.ts +45 -0
  34. package/dist/lib/reanchor.js +389 -0
  35. package/dist/lib/resolve-files.d.ts +18 -0
  36. package/dist/lib/resolve-files.js +42 -0
  37. package/dist/lib/types.d.ts +151 -0
  38. package/dist/lib/types.js +5 -0
  39. package/dist/lib/validator.d.ts +13 -0
  40. package/dist/lib/validator.js +177 -0
  41. package/dist/lib/writer.d.ts +30 -0
  42. package/dist/lib/writer.js +62 -0
  43. package/package.json +70 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * MRSF Fuzzy Matching Engine
3
+ *
4
+ * Provides exact, normalized, token-level LCS, and character-level
5
+ * Levenshtein matching for re-anchoring selected_text.
6
+ */
7
+ import { distance as levenshtein } from "fastest-levenshtein";
8
+ // ---------------------------------------------------------------------------
9
+ // Exact matching
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * Find all exact occurrences of `needle` in lines (1-based array).
13
+ */
14
+ export function exactMatch(lines, needle) {
15
+ if (!needle)
16
+ return [];
17
+ const results = [];
18
+ const needleLines = needle.split("\n");
19
+ const needleLineCount = needleLines.length;
20
+ // Slide a window across the document
21
+ for (let startLine = 1; startLine <= lines.length - needleLineCount; startLine++) {
22
+ // Build the text for this window
23
+ const windowLines = lines.slice(startLine, startLine + needleLineCount);
24
+ const windowText = windowLines.join("\n");
25
+ // Check if the needle appears anywhere within this window (for single-line)
26
+ if (needleLineCount === 1) {
27
+ let col = 0;
28
+ const line = windowLines[0];
29
+ while (col < line.length) {
30
+ const idx = line.indexOf(needle, col);
31
+ if (idx === -1)
32
+ break;
33
+ results.push({
34
+ text: needle,
35
+ line: startLine,
36
+ endLine: startLine,
37
+ startColumn: idx,
38
+ endColumn: idx + needle.length,
39
+ score: 1.0,
40
+ });
41
+ col = idx + 1;
42
+ }
43
+ }
44
+ else {
45
+ // Multi-line: check if the window contains the exact needle
46
+ const idx = windowText.indexOf(needle);
47
+ if (idx !== -1) {
48
+ // Calculate start column
49
+ const beforeMatch = windowText.slice(0, idx);
50
+ const linesBeforeEnd = beforeMatch.split("\n");
51
+ const startCol = linesBeforeEnd[linesBeforeEnd.length - 1].length;
52
+ // Calculate end column
53
+ const afterMatch = needle.split("\n");
54
+ const endCol = afterMatch[afterMatch.length - 1].length;
55
+ if (startCol === 0 || linesBeforeEnd.length === 1) {
56
+ results.push({
57
+ text: needle,
58
+ line: startLine + linesBeforeEnd.length - 1,
59
+ endLine: startLine + linesBeforeEnd.length - 1 + afterMatch.length - 1,
60
+ startColumn: startCol,
61
+ endColumn: endCol,
62
+ score: 1.0,
63
+ });
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return results;
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Normalized matching (collapse whitespace)
72
+ // ---------------------------------------------------------------------------
73
+ function normalize(text) {
74
+ return text.replace(/\s+/g, " ").trim();
75
+ }
76
+ /**
77
+ * Find matches after normalizing whitespace.
78
+ */
79
+ export function normalizedMatch(lines, needle) {
80
+ const normNeedle = normalize(needle);
81
+ if (!normNeedle)
82
+ return [];
83
+ const results = [];
84
+ // Try expanding windows of varying sizes
85
+ const needleLineEstimate = needle.split("\n").length;
86
+ const minWindow = Math.max(1, needleLineEstimate - 1);
87
+ const maxWindow = Math.min(lines.length - 1, needleLineEstimate + 2);
88
+ for (let winSize = minWindow; winSize <= maxWindow; winSize++) {
89
+ for (let startLine = 1; startLine + winSize - 1 < lines.length; startLine++) {
90
+ const windowLines = lines.slice(startLine, startLine + winSize);
91
+ const windowText = windowLines.join("\n");
92
+ const normWindow = normalize(windowText);
93
+ if (normWindow.includes(normNeedle)) {
94
+ results.push({
95
+ text: windowText,
96
+ line: startLine,
97
+ endLine: startLine + winSize - 1,
98
+ startColumn: 0,
99
+ endColumn: windowLines[windowLines.length - 1].length,
100
+ score: 0.95,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ return deduplicateCandidates(results);
106
+ }
107
+ // ---------------------------------------------------------------------------
108
+ // Token-level LCS
109
+ // ---------------------------------------------------------------------------
110
+ function tokenize(text) {
111
+ return text.split(/\s+/).filter((t) => t.length > 0);
112
+ }
113
+ /**
114
+ * Longest Common Subsequence length of two token arrays.
115
+ */
116
+ function lcsLength(a, b) {
117
+ const m = a.length;
118
+ const n = b.length;
119
+ // Optimize: use two rows instead of full matrix
120
+ let prev = new Array(n + 1).fill(0);
121
+ let curr = new Array(n + 1).fill(0);
122
+ for (let i = 1; i <= m; i++) {
123
+ for (let j = 1; j <= n; j++) {
124
+ if (a[i - 1] === b[j - 1]) {
125
+ curr[j] = prev[j - 1] + 1;
126
+ }
127
+ else {
128
+ curr[j] = Math.max(prev[j], curr[j - 1]);
129
+ }
130
+ }
131
+ [prev, curr] = [curr, prev];
132
+ curr.fill(0);
133
+ }
134
+ return prev[n];
135
+ }
136
+ /**
137
+ * Score two texts using token-level LCS.
138
+ * Returns 0.0–1.0.
139
+ */
140
+ export function tokenLcsScore(a, b) {
141
+ const tokA = tokenize(a);
142
+ const tokB = tokenize(b);
143
+ if (tokA.length === 0 && tokB.length === 0)
144
+ return 1.0;
145
+ if (tokA.length === 0 || tokB.length === 0)
146
+ return 0.0;
147
+ const lcs = lcsLength(tokA, tokB);
148
+ return lcs / Math.max(tokA.length, tokB.length);
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Character-level Levenshtein score
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * Normalized Levenshtein similarity 0.0–1.0.
155
+ */
156
+ export function levenshteinScore(a, b) {
157
+ if (a.length === 0 && b.length === 0)
158
+ return 1.0;
159
+ const maxLen = Math.max(a.length, b.length);
160
+ if (maxLen === 0)
161
+ return 1.0;
162
+ const dist = levenshtein(a, b);
163
+ return 1 - dist / maxLen;
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Combined fuzzy scoring
167
+ // ---------------------------------------------------------------------------
168
+ /**
169
+ * Compute a combined similarity score between two text fragments.
170
+ * Blends token LCS (structural) and Levenshtein (character-level).
171
+ */
172
+ export function combinedScore(needle, candidate) {
173
+ const tScore = tokenLcsScore(needle, candidate);
174
+ // Full Levenshtein is expensive for long texts; only use for short ones
175
+ let lScore;
176
+ if (needle.length < 500 && candidate.length < 500) {
177
+ lScore = levenshteinScore(needle, candidate);
178
+ }
179
+ else {
180
+ lScore = tScore; // fall back to token score only
181
+ }
182
+ // Weight: 60% token LCS, 40% Levenshtein
183
+ return tScore * 0.6 + lScore * 0.4;
184
+ }
185
+ // ---------------------------------------------------------------------------
186
+ // Fuzzy search across document
187
+ // ---------------------------------------------------------------------------
188
+ /**
189
+ * Search the document for fuzzy matches of `needle`.
190
+ *
191
+ * @param lines 1-based line array (index 0 unused).
192
+ * @param needle The original selected_text.
193
+ * @param threshold Minimum score to include (0.0–1.0).
194
+ * @param hintLine Optional original line number for proximity scoring.
195
+ */
196
+ export function fuzzySearch(lines, needle, threshold = 0.6, hintLine) {
197
+ if (!needle)
198
+ return [];
199
+ const needleLines = needle.split("\n");
200
+ const needleLineCount = needleLines.length;
201
+ const candidates = [];
202
+ // Window sizes: ±30% of original line count, minimum 1
203
+ const minWindow = Math.max(1, Math.floor(needleLineCount * 0.7));
204
+ const maxWindow = Math.min(lines.length - 1, Math.ceil(needleLineCount * 1.3) + 1);
205
+ for (let winSize = minWindow; winSize <= maxWindow; winSize++) {
206
+ for (let startLine = 1; startLine + winSize - 1 < lines.length; startLine++) {
207
+ const windowLines = lines.slice(startLine, startLine + winSize);
208
+ const windowText = windowLines.join("\n");
209
+ const score = combinedScore(needle, windowText);
210
+ if (score >= threshold) {
211
+ candidates.push({
212
+ text: windowText,
213
+ line: startLine,
214
+ endLine: startLine + winSize - 1,
215
+ startColumn: 0,
216
+ endColumn: windowLines[windowLines.length - 1].length,
217
+ score,
218
+ });
219
+ }
220
+ }
221
+ }
222
+ // For single-line needles, also try substring matching within each line
223
+ if (needleLineCount === 1 && needle.length < 200) {
224
+ for (let lineNum = 1; lineNum < lines.length; lineNum++) {
225
+ const line = lines[lineNum];
226
+ if (!line)
227
+ continue;
228
+ // Sliding window within line
229
+ const winLen = needle.length;
230
+ const minWinLen = Math.max(3, Math.floor(winLen * 0.7));
231
+ const maxWinLen = Math.min(line.length, Math.ceil(winLen * 1.3));
232
+ for (let len = minWinLen; len <= maxWinLen; len++) {
233
+ for (let col = 0; col + len <= line.length; col++) {
234
+ const sub = line.substring(col, col + len);
235
+ const score = combinedScore(needle, sub);
236
+ if (score >= threshold) {
237
+ candidates.push({
238
+ text: sub,
239
+ line: lineNum,
240
+ endLine: lineNum,
241
+ startColumn: col,
242
+ endColumn: col + len,
243
+ score,
244
+ });
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ // Deduplicate and sort
251
+ let deduped = deduplicateCandidates(candidates);
252
+ // Apply proximity bonus if hintLine is provided
253
+ if (hintLine != null) {
254
+ deduped = deduped.map((c) => {
255
+ const dist = Math.abs(c.line - hintLine);
256
+ // Small bonus for proximity (up to 0.1 for exact line match)
257
+ const proximityBonus = 0.1 * Math.max(0, 1 - dist / 50);
258
+ return { ...c, score: Math.min(1.0, c.score + proximityBonus) };
259
+ });
260
+ }
261
+ return deduped.sort((a, b) => b.score - a.score);
262
+ }
263
+ // ---------------------------------------------------------------------------
264
+ // Helpers
265
+ // ---------------------------------------------------------------------------
266
+ function deduplicateCandidates(candidates) {
267
+ const seen = new Map();
268
+ for (const c of candidates) {
269
+ const key = `${c.line}:${c.startColumn}:${c.endLine}:${c.endColumn}`;
270
+ const existing = seen.get(key);
271
+ if (!existing || c.score > existing.score) {
272
+ seen.set(key, c);
273
+ }
274
+ }
275
+ return Array.from(seen.values());
276
+ }
277
+ //# sourceMappingURL=fuzzy.js.map
@@ -0,0 +1,68 @@
1
+ /**
2
+ * MRSF Git Integration — Git-aware operations for re-anchoring and discovery.
3
+ *
4
+ * Uses child_process.execFile (no shell) for safety.
5
+ * All functions degrade gracefully when Git is unavailable.
6
+ */
7
+ import type { DiffHunk } from "./types.js";
8
+ /**
9
+ * Check whether `git` is available on PATH.
10
+ */
11
+ export declare function isGitAvailable(): Promise<boolean>;
12
+ /** Reset cached availability (for testing). */
13
+ export declare function resetGitCache(): void;
14
+ /**
15
+ * Find the Git repository root from a given directory.
16
+ * Returns null if not in a Git repo.
17
+ */
18
+ export declare function findRepoRoot(cwd?: string): Promise<string | null>;
19
+ /**
20
+ * Get the full HEAD commit SHA.
21
+ */
22
+ export declare function getCurrentCommit(repoRoot: string): Promise<string | null>;
23
+ /**
24
+ * Check if a commit hash is the same as HEAD.
25
+ */
26
+ export declare function isStale(commentCommit: string, repoRoot: string): Promise<boolean>;
27
+ /**
28
+ * Parse unified diff output into structured hunks.
29
+ */
30
+ export declare function parseDiffHunks(diffOutput: string): DiffHunk[];
31
+ /**
32
+ * Get diff hunks between two commits for a specific file.
33
+ */
34
+ export declare function getDiff(fromCommit: string, toCommit: string, filePath: string, repoRoot: string): Promise<DiffHunk[]>;
35
+ /**
36
+ * Calculate the net line shift for a given original line number
37
+ * based on diff hunks.
38
+ *
39
+ * Returns the number of lines to add to the original line number,
40
+ * or null if the line itself was modified/deleted.
41
+ */
42
+ export declare function getLineShift(hunks: DiffHunk[], originalLine: number): {
43
+ shift: number;
44
+ modified: boolean;
45
+ };
46
+ /**
47
+ * Get the contents of a file at a specific commit.
48
+ * Returns null if unavailable.
49
+ */
50
+ export declare function getFileAtCommit(commit: string, filePath: string, repoRoot: string): Promise<string | null>;
51
+ /**
52
+ * Get list of staged files matching a pattern.
53
+ */
54
+ export declare function getStagedFiles(repoRoot: string, pattern?: string): Promise<string[]>;
55
+ /**
56
+ * Get staged diff hunks for a specific file.
57
+ */
58
+ export declare function getStagedDiff(filePath: string, repoRoot: string): Promise<DiffHunk[]>;
59
+ /**
60
+ * Detect file renames between two commits.
61
+ * Returns a map of old path → new path.
62
+ */
63
+ export declare function detectRenames(fromCommit: string, toCommit: string, repoRoot: string): Promise<Map<string, string>>;
64
+ /**
65
+ * Stage a file for commit.
66
+ */
67
+ export declare function stageFile(filePath: string, repoRoot: string): Promise<void>;
68
+ //# sourceMappingURL=git.d.ts.map
@@ -0,0 +1,246 @@
1
+ /**
2
+ * MRSF Git Integration — Git-aware operations for re-anchoring and discovery.
3
+ *
4
+ * Uses child_process.execFile (no shell) for safety.
5
+ * All functions degrade gracefully when Git is unavailable.
6
+ */
7
+ import { execFile as execFileCb } from "node:child_process";
8
+ import { promisify } from "node:util";
9
+ const execFile = promisify(execFileCb);
10
+ const GIT_TIMEOUT = 10_000; // 10s
11
+ // ---------------------------------------------------------------------------
12
+ // Availability
13
+ // ---------------------------------------------------------------------------
14
+ let _gitAvailable = null;
15
+ /**
16
+ * Check whether `git` is available on PATH.
17
+ */
18
+ export async function isGitAvailable() {
19
+ if (_gitAvailable != null)
20
+ return _gitAvailable;
21
+ try {
22
+ await execFile("git", ["--version"], { timeout: GIT_TIMEOUT });
23
+ _gitAvailable = true;
24
+ }
25
+ catch {
26
+ _gitAvailable = false;
27
+ }
28
+ return _gitAvailable;
29
+ }
30
+ /** Reset cached availability (for testing). */
31
+ export function resetGitCache() {
32
+ _gitAvailable = null;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // Repository info
36
+ // ---------------------------------------------------------------------------
37
+ /**
38
+ * Find the Git repository root from a given directory.
39
+ * Returns null if not in a Git repo.
40
+ */
41
+ export async function findRepoRoot(cwd) {
42
+ if (!(await isGitAvailable()))
43
+ return null;
44
+ try {
45
+ const { stdout } = await execFile("git", ["rev-parse", "--show-toplevel"], { cwd: cwd ?? process.cwd(), timeout: GIT_TIMEOUT });
46
+ return stdout.trim();
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Get the full HEAD commit SHA.
54
+ */
55
+ export async function getCurrentCommit(repoRoot) {
56
+ if (!(await isGitAvailable()))
57
+ return null;
58
+ try {
59
+ const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repoRoot, timeout: GIT_TIMEOUT });
60
+ return stdout.trim();
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Check if a commit hash is the same as HEAD.
68
+ */
69
+ export async function isStale(commentCommit, repoRoot) {
70
+ const head = await getCurrentCommit(repoRoot);
71
+ if (!head)
72
+ return false; // can't tell, assume not stale
73
+ // Normalize: compare by prefix match (short vs long SHA)
74
+ const minLen = Math.min(commentCommit.length, head.length);
75
+ return commentCommit.slice(0, minLen) !== head.slice(0, minLen);
76
+ }
77
+ // ---------------------------------------------------------------------------
78
+ // Diff operations
79
+ // ---------------------------------------------------------------------------
80
+ /**
81
+ * Parse unified diff output into structured hunks.
82
+ */
83
+ export function parseDiffHunks(diffOutput) {
84
+ const hunks = [];
85
+ const lines = diffOutput.split("\n");
86
+ let current = null;
87
+ for (const line of lines) {
88
+ const hunkMatch = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
89
+ if (hunkMatch) {
90
+ current = {
91
+ oldStart: parseInt(hunkMatch[1], 10),
92
+ oldCount: hunkMatch[2] != null ? parseInt(hunkMatch[2], 10) : 1,
93
+ newStart: parseInt(hunkMatch[3], 10),
94
+ newCount: hunkMatch[4] != null ? parseInt(hunkMatch[4], 10) : 1,
95
+ lines: [],
96
+ };
97
+ hunks.push(current);
98
+ continue;
99
+ }
100
+ if (current && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" "))) {
101
+ current.lines.push(line);
102
+ }
103
+ }
104
+ return hunks;
105
+ }
106
+ /**
107
+ * Get diff hunks between two commits for a specific file.
108
+ */
109
+ export async function getDiff(fromCommit, toCommit, filePath, repoRoot) {
110
+ if (!(await isGitAvailable()))
111
+ return [];
112
+ try {
113
+ const { stdout } = await execFile("git", [
114
+ "diff",
115
+ `${fromCommit}..${toCommit}`,
116
+ "--unified=0",
117
+ "--no-color",
118
+ "--",
119
+ filePath,
120
+ ], { cwd: repoRoot, timeout: GIT_TIMEOUT });
121
+ return parseDiffHunks(stdout);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ /**
128
+ * Calculate the net line shift for a given original line number
129
+ * based on diff hunks.
130
+ *
131
+ * Returns the number of lines to add to the original line number,
132
+ * or null if the line itself was modified/deleted.
133
+ */
134
+ export function getLineShift(hunks, originalLine) {
135
+ let cumulativeShift = 0;
136
+ for (const hunk of hunks) {
137
+ const oldEnd = hunk.oldStart + hunk.oldCount - 1;
138
+ // Hunk is entirely after our line — stop
139
+ if (hunk.oldStart > originalLine)
140
+ break;
141
+ // Our line falls within this hunk — it was modified
142
+ if (originalLine >= hunk.oldStart && originalLine <= oldEnd) {
143
+ return { shift: cumulativeShift, modified: true };
144
+ }
145
+ // Hunk is entirely before our line — accumulate shift
146
+ if (oldEnd < originalLine) {
147
+ cumulativeShift += hunk.newCount - hunk.oldCount;
148
+ }
149
+ }
150
+ return { shift: cumulativeShift, modified: false };
151
+ }
152
+ // ---------------------------------------------------------------------------
153
+ // File at commit
154
+ // ---------------------------------------------------------------------------
155
+ /**
156
+ * Get the contents of a file at a specific commit.
157
+ * Returns null if unavailable.
158
+ */
159
+ export async function getFileAtCommit(commit, filePath, repoRoot) {
160
+ if (!(await isGitAvailable()))
161
+ return null;
162
+ try {
163
+ const { stdout } = await execFile("git", ["show", `${commit}:${filePath}`], { cwd: repoRoot, timeout: GIT_TIMEOUT });
164
+ return stdout;
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Staged files
172
+ // ---------------------------------------------------------------------------
173
+ /**
174
+ * Get list of staged files matching a pattern.
175
+ */
176
+ export async function getStagedFiles(repoRoot, pattern) {
177
+ if (!(await isGitAvailable()))
178
+ return [];
179
+ try {
180
+ const args = ["diff", "--cached", "--name-only", "--diff-filter=d"];
181
+ if (pattern)
182
+ args.push("--", pattern);
183
+ const { stdout } = await execFile("git", args, {
184
+ cwd: repoRoot,
185
+ timeout: GIT_TIMEOUT,
186
+ });
187
+ return stdout
188
+ .trim()
189
+ .split("\n")
190
+ .filter((f) => f.length > 0);
191
+ }
192
+ catch {
193
+ return [];
194
+ }
195
+ }
196
+ /**
197
+ * Get staged diff hunks for a specific file.
198
+ */
199
+ export async function getStagedDiff(filePath, repoRoot) {
200
+ if (!(await isGitAvailable()))
201
+ return [];
202
+ try {
203
+ const { stdout } = await execFile("git", ["diff", "--cached", "--unified=0", "--no-color", "--", filePath], { cwd: repoRoot, timeout: GIT_TIMEOUT });
204
+ return parseDiffHunks(stdout);
205
+ }
206
+ catch {
207
+ return [];
208
+ }
209
+ }
210
+ // ---------------------------------------------------------------------------
211
+ // Rename detection
212
+ // ---------------------------------------------------------------------------
213
+ /**
214
+ * Detect file renames between two commits.
215
+ * Returns a map of old path → new path.
216
+ */
217
+ export async function detectRenames(fromCommit, toCommit, repoRoot) {
218
+ if (!(await isGitAvailable()))
219
+ return new Map();
220
+ try {
221
+ const { stdout } = await execFile("git", ["diff", "--name-status", "-M", `${fromCommit}..${toCommit}`], { cwd: repoRoot, timeout: GIT_TIMEOUT });
222
+ const renames = new Map();
223
+ for (const line of stdout.trim().split("\n")) {
224
+ const match = line.match(/^R\d*\t(.+)\t(.+)$/);
225
+ if (match) {
226
+ renames.set(match[1], match[2]);
227
+ }
228
+ }
229
+ return renames;
230
+ }
231
+ catch {
232
+ return new Map();
233
+ }
234
+ }
235
+ /**
236
+ * Stage a file for commit.
237
+ */
238
+ export async function stageFile(filePath, repoRoot) {
239
+ if (!(await isGitAvailable()))
240
+ return;
241
+ await execFile("git", ["add", filePath], {
242
+ cwd: repoRoot,
243
+ timeout: GIT_TIMEOUT,
244
+ });
245
+ }
246
+ //# sourceMappingURL=git.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * MRSF Parser — load and parse MRSF sidecar files (YAML or JSON).
3
+ */
4
+ import type { MrsfDocument } from "./types.js";
5
+ /**
6
+ * Parse an MRSF sidecar file from disk.
7
+ */
8
+ export declare function parseSidecar(filePath: string): Promise<MrsfDocument>;
9
+ /**
10
+ * Parse MRSF sidecar content from a string.
11
+ * Detects JSON vs YAML based on content or optional filename hint.
12
+ */
13
+ export declare function parseSidecarContent(content: string, filenameHint?: string): MrsfDocument;
14
+ /**
15
+ * Read a Markdown document from disk and return its lines.
16
+ * Lines are 1-indexed in the returned array (index 0 is unused).
17
+ */
18
+ export declare function readDocumentLines(filePath: string): Promise<string[]>;
19
+ //# sourceMappingURL=parser.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * MRSF Parser — load and parse MRSF sidecar files (YAML or JSON).
3
+ */
4
+ import { readFile } from "node:fs/promises";
5
+ import path from "node:path";
6
+ import yaml from "js-yaml";
7
+ /**
8
+ * Parse an MRSF sidecar file from disk.
9
+ */
10
+ export async function parseSidecar(filePath) {
11
+ const abs = path.resolve(filePath);
12
+ const content = await readFile(abs, "utf-8");
13
+ return parseSidecarContent(content, abs);
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);
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
+ * Read a Markdown document from disk and return its lines.
48
+ * Lines are 1-indexed in the returned array (index 0 is unused).
49
+ */
50
+ export async function readDocumentLines(filePath) {
51
+ const content = await readFile(path.resolve(filePath), "utf-8");
52
+ const lines = content.split("\n");
53
+ // Prepend empty element so lines[1] = first line (1-based)
54
+ return ["", ...lines];
55
+ }
56
+ //# sourceMappingURL=parser.js.map