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.
- package/README.md +290 -26
- package/docs/anci/v0.1-DRAFT.md +420 -0
- package/docs/scale.md +129 -0
- package/package.json +10 -5
- package/scripts/postinstall.js +413 -0
- package/src/acp/agent.js +5 -5
- package/src/acp/providers/index.js +2 -2
- package/src/agents/leiden.js +11 -17
- package/src/agents/scan-structure.js +1 -1
- package/src/anci/consumer.js +305 -0
- package/src/anci/deserialize.js +160 -0
- package/src/anci/emit.js +85 -0
- package/src/anci/serialize.js +264 -0
- package/src/anci/yaml.js +401 -0
- package/src/bitmap/bitset.js +190 -0
- package/src/bitmap/index.js +121 -0
- package/src/bitmap/sidecar.js +545 -0
- package/src/bitmap/tools.js +310 -0
- package/src/cli/anci.js +237 -0
- package/src/cli/check.js +57 -0
- package/src/cli/index.js +28 -2
- package/src/cli/init.js +297 -65
- package/src/cli/inspect.js +295 -0
- package/src/cli/pr-impact.js +497 -0
- package/src/cli/serve.js +1 -1
- package/src/cli/watch.js +6 -0
- package/src/engine/worker.js +24 -4
- package/src/extractors/imports.js +176 -0
- package/src/extractors/languages/html.js +4 -1
- package/src/extractors/languages/javascript.js +5 -0
- package/src/extractors/languages/prisma.js +4 -1
- package/src/extractors/languages/python.js +5 -1
- package/src/extractors/languages/r.js +4 -1
- package/src/extractors/languages/typescript.js +2 -0
- package/src/extractors/tree-sitter-parser.js +15 -0
- package/src/mcp/change-plan.js +8 -8
- package/src/mcp/diff-parser.js +246 -0
- package/src/mcp/server-v2.js +489 -8
- package/src/mcp/validate.js +304 -0
- package/src/store/config-loader.js +77 -0
- package/src/store/sqlite-store.js +389 -4
- package/src/store/sync-v2.js +472 -97
- 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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
};
|
|
@@ -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
|
};
|
package/src/mcp/change-plan.js
CHANGED
|
@@ -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-
|
|
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
|
|
173
|
-
*
|
|
174
|
-
*
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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
|
-
// ──
|
|
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 };
|