@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.
- package/LICENSE +21 -0
- package/README.md +346 -0
- package/dist/bin.d.ts +8 -0
- package/dist/bin.js +76 -0
- package/dist/commands/add.d.ts +6 -0
- package/dist/commands/add.js +82 -0
- package/dist/commands/init.d.ts +6 -0
- package/dist/commands/init.js +52 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +95 -0
- package/dist/commands/reanchor.d.ts +6 -0
- package/dist/commands/reanchor.js +127 -0
- package/dist/commands/rename.d.ts +6 -0
- package/dist/commands/rename.js +53 -0
- package/dist/commands/resolve.d.ts +6 -0
- package/dist/commands/resolve.js +35 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +156 -0
- package/dist/commands/validate.d.ts +6 -0
- package/dist/commands/validate.js +55 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +25 -0
- package/dist/lib/comments.d.ts +52 -0
- package/dist/lib/comments.js +228 -0
- package/dist/lib/discovery.d.ts +40 -0
- package/dist/lib/discovery.js +125 -0
- package/dist/lib/fuzzy.d.ts +39 -0
- package/dist/lib/fuzzy.js +277 -0
- package/dist/lib/git.d.ts +68 -0
- package/dist/lib/git.js +246 -0
- package/dist/lib/parser.d.ts +19 -0
- package/dist/lib/parser.js +56 -0
- package/dist/lib/reanchor.d.ts +45 -0
- package/dist/lib/reanchor.js +389 -0
- package/dist/lib/resolve-files.d.ts +18 -0
- package/dist/lib/resolve-files.js +42 -0
- package/dist/lib/types.d.ts +151 -0
- package/dist/lib/types.js +5 -0
- package/dist/lib/validator.d.ts +13 -0
- package/dist/lib/validator.js +177 -0
- package/dist/lib/writer.d.ts +30 -0
- package/dist/lib/writer.js +62 -0
- 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
|
package/dist/lib/git.js
ADDED
|
@@ -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
|