carto-md 2.0.7 → 2.0.8

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.
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal unified-diff parser.
5
+ *
6
+ * Zero deps, zero allocations beyond the result rows. Handles the subset
7
+ * of unified diff that GitHub PRs, `git diff`, and `git format-patch`
8
+ * actually emit:
9
+ *
10
+ * - Per-file headers: `--- a/path` / `+++ b/path`
11
+ * - File-mode markers: `new file mode`, `deleted file mode`, `rename from`,
12
+ * `rename to`. We surface `kind` ∈ { 'modify' | 'add' | 'delete' | 'rename' }.
13
+ * - Hunk headers: `@@ -from,len +to,len @@ optional context`
14
+ * - Body lines: `+added`, `-removed`, ` context`, `\\ No newline at EOF`
15
+ * - `Binary files ... differ` lines — we skip the whole file.
16
+ *
17
+ * Out of scope:
18
+ * - Combined diffs (`@@@ ... @@@`) — git rarely emits these except for
19
+ * octopus merges. We tolerate (skip) the file.
20
+ * - SVN / Mercurial extension lines.
21
+ *
22
+ * Output shape — one entry per file:
23
+ * {
24
+ * path: string, // post-image path (`+++ b/...`)
25
+ * oldPath: string | null, // pre-image path (different on rename)
26
+ * kind: 'modify' | 'add' | 'delete' | 'rename',
27
+ * added: [{lineNo, content}], // line numbers in the new file
28
+ * removed: [{lineNo, content}], // line numbers in the old file
29
+ * }
30
+ *
31
+ * Malformed input must never throw — return whatever we parsed.
32
+ */
33
+
34
+ function stripDiffPath(raw) {
35
+ if (!raw || typeof raw !== 'string') return null;
36
+ // Strip trailing tab/timestamp markers that some unified diffs append.
37
+ let p = raw.split('\t')[0].trim();
38
+ // Drop git's a/ or b/ prefix if present.
39
+ if (p.startsWith('a/') || p.startsWith('b/')) p = p.slice(2);
40
+ // /dev/null sentinel for new/deleted files.
41
+ if (p === '/dev/null') return null;
42
+ return p;
43
+ }
44
+
45
+ /**
46
+ * parseDiff(diffText) → [{ path, oldPath, kind, added, removed }]
47
+ *
48
+ * Top-level entry. Defensive against truncation, malformed hunks, or
49
+ * input that isn't a diff at all (returns []).
50
+ */
51
+ function parseDiff(diffText) {
52
+ if (typeof diffText !== 'string' || diffText.length === 0) return [];
53
+ const lines = diffText.split(/\r?\n/);
54
+ const files = [];
55
+ let cur = null;
56
+ let oldLine = 0;
57
+ let newLine = 0;
58
+ let inHunk = false;
59
+ // `pendingMode` tracks file-mode hints between the `diff --git` header
60
+ // and the `---/+++` lines that finalize the entry.
61
+ let pendingKind = null;
62
+ let pendingOldPath = null;
63
+ let pendingNewPath = null;
64
+
65
+ function flush() {
66
+ // Materialise rename-only entries that never produced a `+++ ` line.
67
+ if (!cur && pendingKind === 'rename' && pendingOldPath && pendingNewPath) {
68
+ cur = {
69
+ path: pendingNewPath,
70
+ oldPath: pendingOldPath !== pendingNewPath ? pendingOldPath : null,
71
+ kind: 'rename',
72
+ added: [],
73
+ removed: [],
74
+ };
75
+ }
76
+ if (cur) files.push(cur);
77
+ cur = null;
78
+ inHunk = false;
79
+ pendingKind = null;
80
+ pendingOldPath = null;
81
+ pendingNewPath = null;
82
+ }
83
+
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i];
86
+
87
+ // `diff --git a/foo b/bar` — start of a new file entry. Pre-resolve
88
+ // the rename paths from the header so we have them even if the
89
+ // rename has zero hunks.
90
+ if (line.startsWith('diff --git ')) {
91
+ flush();
92
+ pendingKind = 'modify';
93
+ pendingOldPath = null;
94
+ pendingNewPath = null;
95
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
96
+ if (m) {
97
+ pendingOldPath = m[1];
98
+ pendingNewPath = m[2];
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (line.startsWith('new file mode')) { pendingKind = 'add'; continue; }
104
+ if (line.startsWith('deleted file mode')) { pendingKind = 'delete'; continue; }
105
+ if (line.startsWith('rename from ')) {
106
+ pendingKind = 'rename';
107
+ pendingOldPath = line.slice('rename from '.length).trim();
108
+ continue;
109
+ }
110
+ if (line.startsWith('rename to ')) {
111
+ pendingKind = 'rename';
112
+ pendingNewPath = line.slice('rename to '.length).trim();
113
+ continue;
114
+ }
115
+
116
+ // Skip binary diffs — we can't validate them anyway.
117
+ if (line.startsWith('Binary files ') && line.endsWith(' differ')) {
118
+ flush();
119
+ continue;
120
+ }
121
+
122
+ if (line.startsWith('--- ')) {
123
+ const oldP = stripDiffPath(line.slice(4));
124
+ if (oldP !== null) pendingOldPath = oldP;
125
+ continue;
126
+ }
127
+
128
+ if (line.startsWith('+++ ')) {
129
+ // Finalize the file header. Open a new `cur` entry.
130
+ const newP = stripDiffPath(line.slice(4));
131
+ if (newP !== null) pendingNewPath = newP;
132
+ const path = pendingNewPath || pendingOldPath;
133
+ if (!path) continue;
134
+ cur = {
135
+ path,
136
+ oldPath: pendingOldPath !== pendingNewPath ? pendingOldPath : null,
137
+ kind: pendingKind || 'modify',
138
+ added: [],
139
+ removed: [],
140
+ };
141
+ // /dev/null on the old side means add; on the new side means delete.
142
+ if (cur.kind === 'modify') {
143
+ if (pendingOldPath === null && pendingNewPath !== null) cur.kind = 'add';
144
+ else if (pendingNewPath === null && pendingOldPath !== null) cur.kind = 'delete';
145
+ }
146
+ pendingKind = null;
147
+ pendingOldPath = null;
148
+ pendingNewPath = null;
149
+ inHunk = false;
150
+ continue;
151
+ }
152
+
153
+ if (!cur) continue;
154
+
155
+ // Hunk header: @@ -oldStart,oldLen +newStart,newLen @@ (lengths optional)
156
+ if (line.startsWith('@@')) {
157
+ const m = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
158
+ if (m) {
159
+ oldLine = parseInt(m[1], 10);
160
+ newLine = parseInt(m[2], 10);
161
+ inHunk = true;
162
+ } else {
163
+ inHunk = false;
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (!inHunk) continue;
169
+
170
+ // Body lines.
171
+ if (line.length === 0) {
172
+ // A blank body line is "context" with empty content. Both old and
173
+ // new advance.
174
+ oldLine++;
175
+ newLine++;
176
+ continue;
177
+ }
178
+ const tag = line[0];
179
+ const content = line.slice(1);
180
+ if (tag === '+') {
181
+ cur.added.push({ lineNo: newLine, content });
182
+ newLine++;
183
+ } else if (tag === '-') {
184
+ cur.removed.push({ lineNo: oldLine, content });
185
+ oldLine++;
186
+ } else if (tag === ' ') {
187
+ oldLine++;
188
+ newLine++;
189
+ } else if (tag === '\\') {
190
+ // "" — no line advance.
191
+ continue;
192
+ } else {
193
+ // Unknown body line — be tolerant and stop hunk parsing.
194
+ inHunk = false;
195
+ }
196
+ }
197
+
198
+ flush();
199
+ return files;
200
+ }
201
+
202
+ /**
203
+ * extractAddedImports(file) → string[]
204
+ *
205
+ * Heuristic: scan added lines for import-style statements common across
206
+ * JS/TS, Python, Go, Rust, Ruby, Java. Returns the bare module / path
207
+ * specifier (the thing inside the quotes / after `from`/`import`/`use`).
208
+ *
209
+ * Used by validate.js to detect new cross-domain edges. Exported for
210
+ * test coverage. Best-effort — we'd rather miss than spuriously flag.
211
+ */
212
+ function extractAddedImports(file) {
213
+ if (!file || !Array.isArray(file.added)) return [];
214
+ const out = [];
215
+ for (const { content } of file.added) {
216
+ if (!content) continue;
217
+ const trimmed = content.trim();
218
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
219
+ // ES module: import X from 'mod' / import 'mod' / import { x } from "mod"
220
+ let m = trimmed.match(/^import\s+(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/);
221
+ if (m) { out.push(m[1]); continue; }
222
+ // CommonJS: const x = require('mod')
223
+ m = trimmed.match(/require\(\s*['"]([^'"]+)['"]\s*\)/);
224
+ if (m) { out.push(m[1]); continue; }
225
+ // Dynamic import: import('mod')
226
+ m = trimmed.match(/^import\(\s*['"]([^'"]+)['"]\s*\)/);
227
+ if (m) { out.push(m[1]); continue; }
228
+ // Python: from mod import x / import mod
229
+ m = trimmed.match(/^from\s+([\w.]+)\s+import\b/);
230
+ if (m) { out.push(m[1]); continue; }
231
+ m = trimmed.match(/^import\s+([\w.]+)\s*$/);
232
+ if (m) { out.push(m[1]); continue; }
233
+ // Go: import "mod" / multi-line import block
234
+ m = trimmed.match(/^import\s+['"]([^'"]+)['"]/);
235
+ if (m) { out.push(m[1]); continue; }
236
+ // Rust: use crate::a::b::c;
237
+ m = trimmed.match(/^use\s+([\w:]+)/);
238
+ if (m) { out.push(m[1]); continue; }
239
+ // Java: import a.b.C;
240
+ m = trimmed.match(/^import\s+([\w.]+);/);
241
+ if (m) { out.push(m[1]); continue; }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ module.exports = { parseDiff, extractAddedImports, stripDiffPath };