carto-md 2.0.7 → 2.0.9

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/README.md +290 -26
  2. package/docs/anci/v0.1-DRAFT.md +420 -0
  3. package/docs/scale.md +129 -0
  4. package/package.json +10 -5
  5. package/scripts/postinstall.js +413 -0
  6. package/src/acp/agent.js +5 -5
  7. package/src/acp/providers/index.js +2 -2
  8. package/src/agents/leiden.js +11 -17
  9. package/src/agents/scan-structure.js +1 -1
  10. package/src/anci/consumer.js +305 -0
  11. package/src/anci/deserialize.js +160 -0
  12. package/src/anci/emit.js +85 -0
  13. package/src/anci/serialize.js +264 -0
  14. package/src/anci/yaml.js +401 -0
  15. package/src/bitmap/bitset.js +190 -0
  16. package/src/bitmap/index.js +121 -0
  17. package/src/bitmap/sidecar.js +545 -0
  18. package/src/bitmap/tools.js +310 -0
  19. package/src/cli/anci.js +237 -0
  20. package/src/cli/check.js +57 -0
  21. package/src/cli/index.js +28 -2
  22. package/src/cli/init.js +297 -65
  23. package/src/cli/inspect.js +295 -0
  24. package/src/cli/pr-impact.js +497 -0
  25. package/src/cli/serve.js +1 -1
  26. package/src/cli/watch.js +6 -0
  27. package/src/engine/worker.js +24 -4
  28. package/src/extractors/imports.js +176 -0
  29. package/src/extractors/languages/html.js +4 -1
  30. package/src/extractors/languages/javascript.js +5 -0
  31. package/src/extractors/languages/prisma.js +4 -1
  32. package/src/extractors/languages/python.js +5 -1
  33. package/src/extractors/languages/r.js +4 -1
  34. package/src/extractors/languages/typescript.js +2 -0
  35. package/src/extractors/tree-sitter-parser.js +15 -0
  36. package/src/mcp/change-plan.js +8 -8
  37. package/src/mcp/diff-parser.js +246 -0
  38. package/src/mcp/server-v2.js +489 -8
  39. package/src/mcp/validate.js +304 -0
  40. package/src/store/config-loader.js +77 -0
  41. package/src/store/sqlite-store.js +389 -4
  42. package/src/store/sync-v2.js +472 -97
  43. package/BENCHMARK_RESULTS.md +0 -34
@@ -51,6 +51,10 @@ function extractImports(content, filePath, projectRoot) {
51
51
  return extractJavaImports(content, filePath, projectRoot);
52
52
  } else if (ext === '.rb') {
53
53
  return extractRubyImports(content, filePath, projectRoot);
54
+ } else if (['.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.hh', '.hxx'].includes(ext)) {
55
+ return extractCppImports(content, filePath, projectRoot);
56
+ } else if (ext === '.cs') {
57
+ return extractCsharpImports(content, filePath, projectRoot);
54
58
  }
55
59
 
56
60
  // Resolve and deduplicate
@@ -650,3 +654,175 @@ function _resolveRubyPath(reqPath, baseDir, projectRoot) {
650
654
  }
651
655
  return null;
652
656
  }
657
+
658
+
659
+ // ─── C / C++ imports ──────────────────────────────────────────────────────────
660
+
661
+ /**
662
+ * extractCppImports(content, filePath, projectRoot) → Array<string>
663
+ *
664
+ * Resolves #include directives to actual files in the project.
665
+ *
666
+ * #include "foo.h" → resolved relative to current file's dir,
667
+ * then common include search dirs.
668
+ * #include <foo> → skipped (system header, external).
669
+ *
670
+ * Uses tree-sitter-cpp when available, otherwise falls back to a
671
+ * simple regex over the source.
672
+ */
673
+ function extractCppImports(content, filePath, projectRoot) {
674
+ const results = new Set();
675
+ const fileDir = path.dirname(filePath);
676
+ const ext = path.extname(filePath);
677
+
678
+ // Pull raw includes via tree-sitter when available; fall back to regex.
679
+ let rawImports = [];
680
+ try {
681
+ const tsParser = require('./tree-sitter-parser');
682
+ if (tsParser.isAvailable()) {
683
+ rawImports = tsParser.extractImports(content, ext);
684
+ }
685
+ } catch { /* tree-sitter unavailable */ }
686
+
687
+ if (rawImports.length === 0) {
688
+ const re = /^\s*#\s*include\s+(?:"([^"]+)"|<([^>]+)>)/gm;
689
+ let m;
690
+ while ((m = re.exec(content)) !== null) {
691
+ // m[1] is quoted form, m[2] is angle-bracket form. Keep both shapes
692
+ // so the system-header filter below can drop the angle ones.
693
+ if (m[1]) rawImports.push(m[1]);
694
+ else if (m[2]) rawImports.push('<' + m[2] + '>');
695
+ }
696
+ }
697
+
698
+ for (const raw of rawImports) {
699
+ // Drop system headers (angle brackets). Tree-sitter returns these as
700
+ // "<string>" wrappers; the regex form we just constructed does too.
701
+ if (raw.startsWith('<') && raw.endsWith('>')) continue;
702
+ // Defensive: a tree-sitter grammar quirk could leak the wrappers in.
703
+ if (raw.startsWith('"') && raw.endsWith('"')) {
704
+ const inner = raw.slice(1, -1);
705
+ _tryResolveCppInclude(inner, fileDir, projectRoot, results);
706
+ continue;
707
+ }
708
+ _tryResolveCppInclude(raw, fileDir, projectRoot, results);
709
+ }
710
+
711
+ return [...results].sort();
712
+ }
713
+
714
+ function _tryResolveCppInclude(includePath, fileDir, projectRoot, results) {
715
+ // Search order: relative to current file, then common include roots.
716
+ const candidates = [
717
+ path.resolve(fileDir, includePath),
718
+ path.join(projectRoot, 'include', includePath),
719
+ path.join(projectRoot, 'src', 'include', includePath),
720
+ path.join(projectRoot, 'src', includePath),
721
+ path.join(projectRoot, includePath),
722
+ ];
723
+ for (const c of candidates) {
724
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) {
725
+ results.add(path.relative(projectRoot, c));
726
+ return;
727
+ }
728
+ }
729
+ }
730
+
731
+ // ─── C# imports ───────────────────────────────────────────────────────────────
732
+
733
+ // Cache: projectRoot → Map<namespace, Array<relativeFilePath>>
734
+ const _csNamespaceCache = new Map();
735
+
736
+ /**
737
+ * Build a namespace → files map by scanning every .cs file in the project
738
+ * once. Cached per projectRoot. This is the only reliable way to resolve
739
+ * `using A.B.C;` in C# because (unlike Java) namespace and folder structure
740
+ * are decoupled — a namespace can span many files in many directories.
741
+ */
742
+ function _getCsharpNamespaceMap(projectRoot) {
743
+ if (_csNamespaceCache.has(projectRoot)) return _csNamespaceCache.get(projectRoot);
744
+ const map = new Map();
745
+
746
+ const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', 'packages', '.git', 'dist', 'build']);
747
+ const walk = (dir) => {
748
+ let entries;
749
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
750
+ catch { return; }
751
+ for (const e of entries) {
752
+ if (e.name.startsWith('.')) continue;
753
+ if (e.isDirectory()) {
754
+ if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name));
755
+ continue;
756
+ }
757
+ if (!e.isFile() || !e.name.endsWith('.cs')) continue;
758
+ const full = path.join(dir, e.name);
759
+ let c;
760
+ try { c = fs.readFileSync(full, 'utf-8'); } catch { continue; }
761
+ // Match both file-scoped (`namespace Foo;`) and block-scoped (`namespace Foo {`).
762
+ const m = c.match(/^\s*namespace\s+([\w.]+)\s*[;{]/m);
763
+ if (!m) continue;
764
+ const ns = m[1];
765
+ const rel = path.relative(projectRoot, full);
766
+ if (!map.has(ns)) map.set(ns, []);
767
+ map.get(ns).push(rel);
768
+ }
769
+ };
770
+
771
+ walk(projectRoot);
772
+ _csNamespaceCache.set(projectRoot, map);
773
+ return map;
774
+ }
775
+
776
+ /**
777
+ * extractCsharpImports(content, filePath, projectRoot) → Array<string>
778
+ *
779
+ * Resolves `using Foo.Bar.Baz;` to project files that declare the
780
+ * matching namespace. Skips standard library namespaces (System.*,
781
+ * Microsoft.*, etc.) — those produce only noise.
782
+ */
783
+ function extractCsharpImports(content, filePath, projectRoot) {
784
+ const results = new Set();
785
+ const nsMap = _getCsharpNamespaceMap(projectRoot);
786
+
787
+ // System namespaces and well-known third-party prefixes that almost never
788
+ // resolve to project files. We still check the map (a project could ship a
789
+ // Microsoft.* namespace), but skip the FS fallback for these.
790
+ const EXTERNAL_PREFIXES = ['System', 'Microsoft', 'Windows', 'Newtonsoft', 'Xunit', 'NUnit', 'Mono'];
791
+
792
+ const re = /^\s*using\s+(?:static\s+)?([\w.]+)\s*;/gm;
793
+ let m;
794
+ while ((m = re.exec(content)) !== null) {
795
+ const ns = m[1];
796
+ const isExternal = EXTERNAL_PREFIXES.includes(ns.split('.')[0]);
797
+
798
+ // Primary path: namespace map lookup. Works regardless of folder layout.
799
+ const matched = nsMap.get(ns);
800
+ if (matched) {
801
+ for (const f of matched) results.add(f);
802
+ continue;
803
+ }
804
+
805
+ if (isExternal) continue;
806
+
807
+ // Fallback: filename convention (Foo.Bar.Baz → Foo/Bar/Baz.cs).
808
+ const parts = ns.split('.');
809
+ const asFile = parts.join(path.sep) + '.cs';
810
+ const candidates = [
811
+ path.join(projectRoot, 'src', asFile),
812
+ path.join(projectRoot, asFile),
813
+ ];
814
+ for (const c of candidates) {
815
+ if (fs.existsSync(c)) {
816
+ results.add(path.relative(projectRoot, c));
817
+ break;
818
+ }
819
+ }
820
+ }
821
+
822
+ // Drop self-import — a file's `namespace X;` can match itself when the
823
+ // file imports its own namespace via `using X;`.
824
+ const selfRel = path.relative(projectRoot, filePath);
825
+ results.delete(selfRel);
826
+
827
+ return [...results].sort();
828
+ }
@@ -9,7 +9,10 @@ module.exports = {
9
9
  return { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches, storageKeys };
10
10
  } catch (err) {
11
11
  console.warn(`[CARTO] html plugin error on ${filename}: ${err.message}`);
12
- return { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
12
+ return {
13
+ routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [],
14
+ _errors: [{ phase: 'extract', message: err.message || String(err) }],
15
+ };
13
16
  }
14
17
  }
15
18
  };
@@ -94,6 +94,11 @@ module.exports = {
94
94
  storageKeys: [],
95
95
  _tsImports: tsImports,
96
96
  _tsSymbols: tsSymbols,
97
+ // Surface the parse failure as a breadcrumb so the
98
+ // caller can record it in extraction_errors. Before this hook,
99
+ // the warning only hit stderr and the file lost all of its
100
+ // routes and models silently.
101
+ _errors: [{ phase: 'parse', message: `Babel parse: ${err.message}` }],
97
102
  };
98
103
  }
99
104
 
@@ -20,7 +20,10 @@ module.exports = {
20
20
  };
21
21
  } catch (err) {
22
22
  console.warn(`[CARTO] prisma plugin error on ${filename}: ${err.message}`);
23
- return { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
23
+ return {
24
+ routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [],
25
+ _errors: [{ phase: 'extract', message: err.message || String(err) }],
26
+ };
24
27
  }
25
28
  }
26
29
  };
@@ -34,7 +34,11 @@ module.exports = {
34
34
  };
35
35
  } catch (err) {
36
36
  console.warn(`[CARTO] python plugin error on ${filename}: ${err.message}`);
37
- return { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
37
+ return {
38
+ routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [],
39
+ // Record the failure so it's visible in `carto check`.
40
+ _errors: [{ phase: 'extract', message: err.message || String(err) }],
41
+ };
38
42
  }
39
43
  }
40
44
  };
@@ -249,7 +249,10 @@ module.exports = {
249
249
  };
250
250
  } catch (err) {
251
251
  console.warn(`[CARTO] r plugin error on ${filename}: ${err.message}`);
252
- return { routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [] };
252
+ return {
253
+ routes: [], models: [], functions: [], envVars: [], dbTables: [], fetches: [], storageKeys: [],
254
+ _errors: [{ phase: 'extract', message: err.message || String(err) }],
255
+ };
253
256
  }
254
257
  }
255
258
  };
@@ -63,6 +63,8 @@ module.exports = {
63
63
  jobs: extractQueueAndCron(content),
64
64
  _tsImports: tsImports,
65
65
  _tsSymbols: tsSymbols,
66
+ // Promote silent Babel failure to a breadcrumb.
67
+ _errors: [{ phase: 'parse', message: `Babel parse: ${err.message}` }],
66
68
  };
67
69
  }
68
70
 
@@ -395,11 +395,26 @@ function _inferKind(nameNode, langName) {
395
395
  return 'variable';
396
396
  }
397
397
 
398
+ /**
399
+ * getUnavailableLanguages() → string[]
400
+ * Returns language names whose grammars failed to load (or tree-sitter itself unavailable).
401
+ */
402
+ function getUnavailableLanguages() {
403
+ if (!treeSitterAvailable) return GRAMMAR_DEFS.map(d => d.name);
404
+ const unavailable = [];
405
+ for (const def of GRAMMAR_DEFS) {
406
+ const ext = def.extensions[0];
407
+ if (!_getCompiledGrammar(ext)) unavailable.push(def.name);
408
+ }
409
+ return unavailable;
410
+ }
411
+
398
412
  module.exports = {
399
413
  isAvailable,
400
414
  supportsExtension,
401
415
  extractImports,
402
416
  extractSymbols,
403
417
  extractAll,
418
+ getUnavailableLanguages,
404
419
  GRAMMAR_DEFS,
405
420
  };
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * tokenize(intent) ──► tokens { content, verbs, paths }
10
10
  * └──► IDF over indexed corpus (basenames + symbol names)
11
- * └──► 4-tier anchor selection
11
+ * └──► 4-stage anchor selection
12
12
  * A. route path/method (searchRoutes)
13
13
  * B. file path tokens (pathTokens × IDF)
14
14
  * C. exported symbol names (camelTokens × IDF)
@@ -169,9 +169,9 @@ function computeIdf(store) {
169
169
  * symbols: [{ name, path, tokenSet }] }
170
170
  *
171
171
  * Memoized on the store object. On a 5K-file repo this saves ~30ms per
172
- * `planChange` call (without it, p95 on cal.com sat at 60ms over the
173
- * spec's 50ms target). Re-indexing creates a new store instance, so
174
- * the cache lives only as long as the index it was built from.
172
+ * `planChange` call (without it, p95 on cal.com sat at 60ms, over the
173
+ * 50ms target). Re-indexing creates a new store instance, so the cache
174
+ * lives only as long as the index it was built from.
175
175
  */
176
176
  const CACHE_KEY = '__cartoChangePlanCache';
177
177
 
@@ -257,7 +257,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
257
257
  // Reuse the cached corpus index — saves ~30ms p95 on cal.com.
258
258
  const corpus = buildCorpusIndex(store);
259
259
 
260
- // ── Tier A — route path/method ────────────────────────────────────
260
+ // ── Stage A — route path/method ───────────────────────────────────
261
261
  // Use searchRoutes for each detected URL-path-like token. Filter by
262
262
  // verb when one was extracted.
263
263
  const routesSeen = new Set();
@@ -304,7 +304,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
304
304
  }
305
305
  }
306
306
 
307
- // ── Tier B — file path tokens (IDF-weighted) ──────────────────────
307
+ // ── Stage B — file path tokens (IDF-weighted) ─────────────────────
308
308
  for (const f of corpus.files) {
309
309
  let score = 0;
310
310
  const hits = [];
@@ -339,7 +339,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
339
339
  }
340
340
  }
341
341
 
342
- // ── Tier C — exported symbol names (camelCase split + IDF) ────────
342
+ // ── Stage C — exported symbol names (camelCase split + IDF) ───────
343
343
  for (const s of corpus.symbols) {
344
344
  let score = 0;
345
345
  const hits = [];
@@ -360,7 +360,7 @@ function selectAnchors(store, tokens, idf, maxAnchors = 8) {
360
360
  }
361
361
  }
362
362
 
363
- // ── Tier D — domain name match ────────────────────────────────────
363
+ // ── Stage D — domain name match ───────────────────────────────────
364
364
  let domains = [];
365
365
  try { domains = store.getDomainsList() || []; } catch { domains = []; }
366
366
  for (const d of domains) {
@@ -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 };