sigmap 6.12.0 → 6.14.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/CHANGELOG.md +31 -0
- package/README.md +1 -1
- package/gen-context.js +246 -84
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/extractors/javascript.js +44 -7
- package/src/extractors/typescript.js +22 -6
- package/src/mcp/server.js +1 -1
- package/src/verify/hallucination-guard.js +219 -0
- package/src/verify/parsers.js +149 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,37 @@ Format: [Semantic Versioning](https://semver.org/)
|
|
|
10
10
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
+
## [6.14.0] — 2026-06-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`verify-ai-output` — Hallucination Guard prototype (Phase 1 MVP, #227, PR #228):**
|
|
18
|
+
- New command `sigmap verify-ai-output <answer.md> [--json]` flags fabricated claims in an AI answer against the real repository. Deterministic core — runs fully offline, no LLM.
|
|
19
|
+
- Three detectors: **fake-file** (referenced path absent on disk), **fake-import** (relative import does not resolve; bare import absent from `package.json` deps, with Node/Python builtins allow-listed and scoped packages handled), and **fake-symbol** (called function/class absent from the SigMap symbol index via `buildSigIndex`).
|
|
20
|
+
- Markdown report by default, `--json` for CI (`{ file, issues, summary }`). Exits `1` when any issue is found, `0` when clean.
|
|
21
|
+
- New modules `src/verify/parsers.js` (file/import/symbol/code-block extraction) and `src/verify/hallucination-guard.js` (`verify(answerText, cwd, opts)`); all external lookups are injectable so the core is unit-testable.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## [6.13.0] — 2026-06-05
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **Line anchors for JavaScript + member-level anchors (Surgical Context Phase 2.1, #223, PR #224):**
|
|
30
|
+
- The **JavaScript extractor** now emits `:start-end` line anchors on top-level functions, classes, exported arrow functions, and `module.exports` — previously only TypeScript and Python carried anchors. JS block-comment stripping switched to the newline-preserving blank so anchor line numbers stay exact below a `/* … */`.
|
|
31
|
+
- **Class methods and interface members** (TypeScript **and** JavaScript) now carry their **own** `:start-end` anchor spanning the member body, instead of inheriting nothing — unlocking method-level targeting with the `get_lines` MCP tool.
|
|
32
|
+
- Measured effect: index-mode token reduction on real repos rises from ~4.6% to **32–42%** (axios 42.1%, fastify 41.1%, svelte 36.8%, vue-core 32.4%), now 100% anchored.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- The bundled standalone `gen-context.js` extractor factories are re-synced with `src/` (the bundle had been stale since v6.11.0), so anchors work in the single-file distribution too.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **Token budget could exceed `maxTokens` with many files.** The budget was signature-only and undercounted per-file section headers plus the fixed ~150-token preamble; with anchored (collapsible) signatures keeping more files alive, output could overflow. `applyTokenBudget` now budgets *rendered* cost (signatures + section overhead) against `maxTokens` minus a `max(200, 10%)` preamble reserve.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
13
44
|
## [6.12.0] — 2026-06-05
|
|
14
45
|
|
|
15
46
|
### Added
|
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Ask → Rank → Context → Validate → Judge → Learn
|
|
|
87
87
|
## Benchmark
|
|
88
88
|
|
|
89
89
|
```
|
|
90
|
-
Benchmark : sigmap-v6.
|
|
90
|
+
Benchmark : sigmap-v6.13-main (21 repositories, including R language)
|
|
91
91
|
Date : 2026-06-05
|
|
92
92
|
|
|
93
93
|
Hit@5 : 81.1% (baseline 13.6% — 6.0× lift)
|
package/gen-context.js
CHANGED
|
@@ -916,61 +916,92 @@ __factories["./src/extractors/java"] = function(module, exports) {
|
|
|
916
916
|
|
|
917
917
|
// ── ./src/extractors/javascript ──
|
|
918
918
|
__factories["./src/extractors/javascript"] = function(module, exports) {
|
|
919
|
-
|
|
919
|
+
|
|
920
|
+
const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
|
|
921
|
+
|
|
920
922
|
/**
|
|
921
923
|
* Extract signatures from JavaScript source code.
|
|
924
|
+
* Top-level declarations and class members carry a `:start-end` line anchor
|
|
925
|
+
* (see line-anchor.js); kept parallel to `sigs` and applied once at return.
|
|
922
926
|
* @param {string} src - Raw file content
|
|
923
927
|
* @returns {string[]} Array of signature strings
|
|
924
928
|
*/
|
|
925
929
|
function extract(src) {
|
|
926
930
|
if (!src || typeof src !== 'string') return [];
|
|
927
931
|
const sigs = [];
|
|
932
|
+
const anchors = [];
|
|
928
933
|
const returnHints = buildReturnHints(src);
|
|
929
|
-
|
|
934
|
+
|
|
935
|
+
// Block comments are blanked newline-by-newline (non-newline chars → spaces)
|
|
936
|
+
// so character offsets AND line numbers stay exact for anchors.
|
|
930
937
|
const stripped = src
|
|
931
938
|
.replace(/\/\/.*$/gm, '')
|
|
932
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
933
|
-
|
|
939
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
940
|
+
|
|
941
|
+
const blockEndIdx = (bodyStart) => bodyStart + extractBlock(stripped, bodyStart).length;
|
|
942
|
+
// End line for a function whose match ends at `matchEnd` (before its body brace).
|
|
943
|
+
const fnEndLine = (matchEnd, startLn) => {
|
|
944
|
+
const brace = stripped.indexOf('{', matchEnd);
|
|
945
|
+
return brace !== -1 ? lineAt(stripped, blockEndIdx(brace + 1)) : startLn;
|
|
946
|
+
};
|
|
947
|
+
|
|
934
948
|
// Classes
|
|
935
949
|
const classRegex = /^(export\s+(?:default\s+)?)?class\s+(\w+)(?:\s+extends\s+[\w.]+)?\s*\{/gm;
|
|
936
950
|
for (const m of stripped.matchAll(classRegex)) {
|
|
937
951
|
const prefix = m[1] ? m[1].trim() + ' ' : '';
|
|
952
|
+
const bodyStart = m.index + m[0].length;
|
|
938
953
|
sigs.push(`${prefix}class ${m[2]}`);
|
|
939
|
-
|
|
940
|
-
|
|
954
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
955
|
+
const block = extractBlock(stripped, bodyStart);
|
|
956
|
+
for (const meth of extractClassMembers(block, returnHints)) {
|
|
957
|
+
sigs.push(` ${meth.text}`);
|
|
958
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
959
|
+
}
|
|
941
960
|
}
|
|
942
|
-
|
|
961
|
+
|
|
943
962
|
// Exported named functions
|
|
944
963
|
for (const m of stripped.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
945
964
|
const asyncKw = /export\s+async/.test(m[0]) ? 'async ' : '';
|
|
946
965
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
966
|
+
const startLn = lineAt(stripped, m.index);
|
|
947
967
|
sigs.push(`export ${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
968
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
948
969
|
}
|
|
949
|
-
|
|
970
|
+
|
|
950
971
|
// Exported arrow functions
|
|
951
972
|
for (const m of stripped.matchAll(/^export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*=>/gm)) {
|
|
952
973
|
const asyncKw = m[0].includes('async') ? 'async ' : '';
|
|
953
974
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
975
|
+
const startLn = lineAt(stripped, m.index);
|
|
954
976
|
sigs.push(`export const ${m[1]} = ${asyncKw}(${normalizeParams(m[2])}) =>${retStr}`);
|
|
977
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
955
978
|
}
|
|
956
|
-
|
|
979
|
+
|
|
957
980
|
// module.exports = { ... }
|
|
958
981
|
const moduleExports = stripped.match(/^module\.exports\s*=\s*\{([^}]+)\}/m);
|
|
959
982
|
if (moduleExports) {
|
|
960
983
|
const names = moduleExports[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
961
|
-
if (names.length > 0)
|
|
984
|
+
if (names.length > 0) {
|
|
985
|
+
const startLn = lineAt(stripped, moduleExports.index);
|
|
986
|
+
sigs.push(`module.exports = { ${names.join(', ')} }`);
|
|
987
|
+
anchors.push([startLn, lineAt(stripped, moduleExports.index + moduleExports[0].length)]);
|
|
988
|
+
}
|
|
962
989
|
}
|
|
963
|
-
|
|
990
|
+
|
|
964
991
|
// Top-level named functions (non-exported)
|
|
965
992
|
for (const m of stripped.matchAll(/^(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
966
993
|
const asyncKw = m[0].startsWith('async') ? 'async ' : '';
|
|
967
994
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
995
|
+
const startLn = lineAt(stripped, m.index);
|
|
968
996
|
sigs.push(`${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
997
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
969
998
|
}
|
|
970
|
-
|
|
971
|
-
return sigs
|
|
999
|
+
|
|
1000
|
+
return sigs
|
|
1001
|
+
.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
|
|
1002
|
+
.slice(0, 25);
|
|
972
1003
|
}
|
|
973
|
-
|
|
1004
|
+
|
|
974
1005
|
function extractBlock(src, startIndex) {
|
|
975
1006
|
let depth = 1;
|
|
976
1007
|
let i = startIndex;
|
|
@@ -982,16 +1013,22 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
|
|
|
982
1013
|
}
|
|
983
1014
|
return src.slice(startIndex, i - 1);
|
|
984
1015
|
}
|
|
985
|
-
|
|
1016
|
+
|
|
1017
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
1018
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
1019
|
+
// per-method line anchors that span the method body.
|
|
986
1020
|
function extractClassMembers(block, returnHints) {
|
|
987
1021
|
const members = [];
|
|
988
1022
|
for (const m of block.matchAll(/^\s+(?:static\s+|async\s+|get\s+|set\s+)*(\w+)\s*\(([^)]*)\)\s*\{/gm)) {
|
|
989
1023
|
if (/^_/.test(m[1])) continue;
|
|
990
|
-
|
|
1024
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
1025
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
1026
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
1027
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
991
1028
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
992
1029
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
993
1030
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
994
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
1031
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
995
1032
|
}
|
|
996
1033
|
return members.slice(0, 8);
|
|
997
1034
|
}
|
|
@@ -1016,18 +1053,17 @@ __factories["./src/extractors/javascript"] = function(module, exports) {
|
|
|
1016
1053
|
}
|
|
1017
1054
|
|
|
1018
1055
|
function formatReturnHint(type) {
|
|
1019
|
-
return type ? `
|
|
1056
|
+
return type ? ` → ${type}` : '';
|
|
1020
1057
|
}
|
|
1021
|
-
|
|
1058
|
+
|
|
1022
1059
|
function normalizeParams(params) {
|
|
1023
1060
|
if (!params) return '';
|
|
1024
1061
|
return params.trim().replace(/\s+/g, ' ');
|
|
1025
1062
|
}
|
|
1026
|
-
|
|
1063
|
+
|
|
1027
1064
|
module.exports = { extract };
|
|
1028
|
-
|
|
1029
|
-
};
|
|
1030
1065
|
|
|
1066
|
+
};
|
|
1031
1067
|
// ── ./src/extractors/kotlin ──
|
|
1032
1068
|
__factories["./src/extractors/kotlin"] = function(module, exports) {
|
|
1033
1069
|
|
|
@@ -1799,101 +1835,164 @@ __factories["./src/extractors/swift"] = function(module, exports) {
|
|
|
1799
1835
|
|
|
1800
1836
|
// ── ./src/extractors/typescript ──
|
|
1801
1837
|
__factories["./src/extractors/typescript"] = function(module, exports) {
|
|
1802
|
-
|
|
1838
|
+
|
|
1839
|
+
const { lineAt, withAnchor } = __require('./src/extractors/line-anchor');
|
|
1840
|
+
|
|
1803
1841
|
/**
|
|
1804
1842
|
* Extract signatures from TypeScript source code.
|
|
1843
|
+
* Top-level declarations carry a `:start-end` line anchor (see line-anchor.js);
|
|
1844
|
+
* indented members do not.
|
|
1805
1845
|
* @param {string} src - Raw file content
|
|
1806
1846
|
* @returns {string[]} Array of signature strings
|
|
1807
1847
|
*/
|
|
1808
1848
|
function extract(src) {
|
|
1809
1849
|
if (!src || typeof src !== 'string') return [];
|
|
1810
1850
|
const sigs = [];
|
|
1811
|
-
|
|
1812
|
-
//
|
|
1851
|
+
// anchors[i] is [start, end] for a top-level sig, or null for an indented member.
|
|
1852
|
+
// Kept parallel to `sigs` so existing push/mutation logic stays untouched;
|
|
1853
|
+
// anchors are applied once at return.
|
|
1854
|
+
const anchors = [];
|
|
1855
|
+
|
|
1856
|
+
// Strip comments to simplify matching. Block comments are blanked
|
|
1857
|
+
// newline-by-newline (non-newline chars → spaces) so character offsets AND
|
|
1858
|
+
// line numbers stay exact. Line comments preserve their trailing newline.
|
|
1813
1859
|
const stripped = src
|
|
1814
1860
|
.replace(/\/\/.*$/gm, '')
|
|
1815
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
1816
|
-
|
|
1861
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
1862
|
+
|
|
1863
|
+
// Index of the closing brace for a block whose body starts at bodyStart.
|
|
1864
|
+
const blockEndIdx = (bodyStart) => bodyStart + extractBlock(stripped, bodyStart).length;
|
|
1865
|
+
|
|
1817
1866
|
// Exported interfaces
|
|
1818
1867
|
for (const m of stripped.matchAll(/^export\s+interface\s+(\w+)(?:<[^{]*>)?\s*(?:extends\s+[^{]+)?\{/gm)) {
|
|
1868
|
+
const bodyStart = m.index + m[0].length;
|
|
1819
1869
|
sigs.push(`export interface ${m[1]}`);
|
|
1870
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
1820
1871
|
// Collect members
|
|
1821
|
-
const
|
|
1822
|
-
const block = extractBlock(stripped, start);
|
|
1872
|
+
const block = extractBlock(stripped, bodyStart);
|
|
1823
1873
|
const members = extractInterfaceMembers(block);
|
|
1824
|
-
for (const mem of members)
|
|
1874
|
+
for (const mem of members) {
|
|
1875
|
+
sigs.push(` ${mem.text}`);
|
|
1876
|
+
anchors.push([lineAt(stripped, bodyStart + mem.start), lineAt(stripped, bodyStart + mem.end)]);
|
|
1877
|
+
}
|
|
1825
1878
|
}
|
|
1826
|
-
|
|
1879
|
+
|
|
1827
1880
|
// Exported type aliases
|
|
1828
1881
|
for (const m of stripped.matchAll(/^export\s+type\s+(\w+)(?:<[^=]*>)?\s*=/gm)) {
|
|
1829
1882
|
sigs.push(`export type ${m[1]}`);
|
|
1883
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, m.index + m[0].length)]);
|
|
1830
1884
|
}
|
|
1831
|
-
|
|
1885
|
+
|
|
1832
1886
|
// Exported enums
|
|
1833
1887
|
for (const m of stripped.matchAll(/^export\s+(?:const\s+)?enum\s+(\w+)\s*\{/gm)) {
|
|
1888
|
+
const bodyStart = m.index + m[0].length;
|
|
1834
1889
|
sigs.push(`export enum ${m[1]}`);
|
|
1890
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
1835
1891
|
}
|
|
1836
|
-
|
|
1892
|
+
|
|
1837
1893
|
// Classes (exported and internal)
|
|
1838
1894
|
const classRegex = /^(export\s+)?(abstract\s+)?class\s+(\w+)(?:<[^{]*>)?(?:\s+extends\s+[\w<>, .]+)?(?:\s+implements\s+[\w<> ,]+)?\s*\{/gm;
|
|
1839
1895
|
for (const m of stripped.matchAll(classRegex)) {
|
|
1840
1896
|
const prefix = m[1] ? 'export ' : '';
|
|
1841
1897
|
const abs = m[2] ? 'abstract ' : '';
|
|
1898
|
+
const bodyStart = m.index + m[0].length;
|
|
1842
1899
|
sigs.push(`${prefix}${abs}class ${m[3]}`);
|
|
1843
|
-
|
|
1844
|
-
const block = extractBlock(stripped,
|
|
1900
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
1901
|
+
const block = extractBlock(stripped, bodyStart);
|
|
1845
1902
|
const methods = extractClassMembers(block);
|
|
1846
|
-
for (const meth of methods)
|
|
1903
|
+
for (const meth of methods) {
|
|
1904
|
+
sigs.push(` ${meth.text}`);
|
|
1905
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
1906
|
+
}
|
|
1847
1907
|
}
|
|
1848
|
-
|
|
1908
|
+
|
|
1849
1909
|
// Exported top-level functions (not methods)
|
|
1850
1910
|
for (const m of stripped.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)(?:\s*:\s*[^{]+)?\s*\{/gm)) {
|
|
1851
1911
|
const asyncKw = /export\s+async/.test(m[0]) ? 'async ' : '';
|
|
1852
1912
|
const params = normalizeParams(m[2]);
|
|
1853
1913
|
const retMatch = m[0].match(/\)\s*:\s*([^{]+)\s*\{/);
|
|
1854
1914
|
const retType = retMatch ? retMatch[1].trim().replace(/\s+/g, ' ').slice(0, 30) : '';
|
|
1855
|
-
const retStr = retType ? `
|
|
1915
|
+
const retStr = retType ? ` → ${retType}` : '';
|
|
1916
|
+
const bodyStart = m.index + m[0].length;
|
|
1856
1917
|
sigs.push(`export ${asyncKw}function ${m[1]}(${params})${retStr}`);
|
|
1918
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
1919
|
+
|
|
1920
|
+
// Hooks: capture compact return object shape for use* functions.
|
|
1921
|
+
if (m[1].startsWith('use')) {
|
|
1922
|
+
const body = stripped.slice(bodyStart, bodyStart + 800);
|
|
1923
|
+
const ret = body.match(/return\s*\{([^}]{1,260})\}/);
|
|
1924
|
+
if (ret) {
|
|
1925
|
+
const keys = ret[1]
|
|
1926
|
+
.split(',')
|
|
1927
|
+
.map((s) => s.trim().split(':')[0].split('(')[0].trim())
|
|
1928
|
+
.filter(Boolean)
|
|
1929
|
+
.slice(0, 8);
|
|
1930
|
+
if (keys.length) {
|
|
1931
|
+
sigs[sigs.length - 1] += ` → { ${keys.join(', ')} }`;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1857
1935
|
}
|
|
1858
|
-
|
|
1936
|
+
|
|
1859
1937
|
// Exported arrow functions / const functions
|
|
1860
1938
|
for (const m of stripped.matchAll(/^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*[^=>{]+)?\s*=>/gm)) {
|
|
1861
1939
|
const asyncKw = /=\s*async\s+/.test(m[0]) ? 'async ' : '';
|
|
1862
1940
|
const params = normalizeParams(m[2]);
|
|
1863
1941
|
sigs.push(`export const ${m[1]} = ${asyncKw}(${params}) =>`);
|
|
1942
|
+
const bodyStart = stripped.indexOf('{', m.index + m[0].length);
|
|
1943
|
+
const endLn = bodyStart !== -1
|
|
1944
|
+
? lineAt(stripped, blockEndIdx(bodyStart + 1))
|
|
1945
|
+
: lineAt(stripped, m.index + m[0].length);
|
|
1946
|
+
anchors.push([lineAt(stripped, m.index), endLn]);
|
|
1947
|
+
|
|
1948
|
+
// Hooks: capture compact return object shape for use* functions.
|
|
1949
|
+
if (m[1].startsWith('use')) {
|
|
1950
|
+
if (bodyStart !== -1) {
|
|
1951
|
+
const body = stripped.slice(bodyStart, bodyStart + 800);
|
|
1952
|
+
const ret = body.match(/return\s*\{([^}]{1,260})\}/);
|
|
1953
|
+
if (ret) {
|
|
1954
|
+
const keys = ret[1]
|
|
1955
|
+
.split(',')
|
|
1956
|
+
.map((s) => s.trim().split(':')[0].split('(')[0].trim())
|
|
1957
|
+
.filter(Boolean)
|
|
1958
|
+
.slice(0, 8);
|
|
1959
|
+
if (keys.length) {
|
|
1960
|
+
sigs[sigs.length - 1] += ` → { ${keys.join(', ')} }`;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1864
1965
|
}
|
|
1865
1966
|
|
|
1866
1967
|
// Zustand stores: export const useXxxStore = create<State>()(...)
|
|
1867
1968
|
for (const m of stripped.matchAll(/^export\s+const\s+(use\w+Store)\s*=\s*create(?:<[^>]*>)?\s*\(/gm)) {
|
|
1868
|
-
// Extract the State interface name from create<StateName>
|
|
1869
1969
|
const stateType = m[0].match(/create<([\w]+)>/)?.[1] || '';
|
|
1970
|
+
const startLn = lineAt(stripped, m.index);
|
|
1870
1971
|
sigs.push(`export const ${m[1]} = create<${stateType}>(...)`);
|
|
1871
|
-
|
|
1972
|
+
anchors.push([startLn, startLn]);
|
|
1872
1973
|
const ifaceRe = new RegExp(`interface\\s+${stateType}\\s*\\{([\\s\\S]*?)\\}`);
|
|
1873
1974
|
const ifm = stripped.match(ifaceRe);
|
|
1874
1975
|
if (ifm) {
|
|
1875
|
-
for (const fm of ifm[1].matchAll(/^\s+(\w+)\s*(?:\([^)]*\))?\s*:/gm)) {
|
|
1876
|
-
sigs.push(` ${fm[1]}`);
|
|
1877
|
-
}
|
|
1976
|
+
for (const fm of ifm[1].matchAll(/^\s+(\w+)\s*(?:\([^)]*\))?\s*:/gm)) { sigs.push(` ${fm[1]}`); anchors.push(null); }
|
|
1878
1977
|
}
|
|
1879
1978
|
}
|
|
1880
1979
|
|
|
1881
|
-
// API client objects:
|
|
1882
|
-
// Pattern: const xxxApi = { methodName: async ... }
|
|
1980
|
+
// API client objects: const xxxApi = { method: async () => {} }
|
|
1883
1981
|
for (const m of stripped.matchAll(/^(?:export\s+default\s+|const\s+)(\w*[Aa]pi\w*)\s*=\s*\{/gm)) {
|
|
1884
|
-
const
|
|
1885
|
-
const
|
|
1886
|
-
const
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1982
|
+
const bodyStart = m.index + m[0].length;
|
|
1983
|
+
const block = extractBlock(stripped, bodyStart);
|
|
1984
|
+
const methods = [...block.matchAll(/^\s+(\w+)\s*:\s*(?:async\s+)?(?:\([^)]*\)|\w+)\s*=>/gm)].map(mm => mm[1]);
|
|
1985
|
+
if (methods.length) {
|
|
1986
|
+
sigs.push(`${m[1]}: { ${methods.join(', ')} }`);
|
|
1987
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, bodyStart + block.length)]);
|
|
1890
1988
|
}
|
|
1891
|
-
if (methods.length) sigs.push(`${apiName}: { ${methods.join(', ')} }`);
|
|
1892
1989
|
}
|
|
1893
|
-
|
|
1894
|
-
return sigs
|
|
1990
|
+
|
|
1991
|
+
return sigs
|
|
1992
|
+
.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
|
|
1993
|
+
.slice(0, 35);
|
|
1895
1994
|
}
|
|
1896
|
-
|
|
1995
|
+
|
|
1897
1996
|
function extractBlock(src, startIndex) {
|
|
1898
1997
|
let depth = 1;
|
|
1899
1998
|
let i = startIndex;
|
|
@@ -1905,47 +2004,59 @@ __factories["./src/extractors/typescript"] = function(module, exports) {
|
|
|
1905
2004
|
}
|
|
1906
2005
|
return src.slice(startIndex, i - 1);
|
|
1907
2006
|
}
|
|
1908
|
-
|
|
2007
|
+
|
|
2008
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
2009
|
+
// WITHIN `block`, so the caller can resolve member line anchors.
|
|
1909
2010
|
function extractInterfaceMembers(block) {
|
|
1910
2011
|
const members = [];
|
|
1911
2012
|
for (const m of block.matchAll(/^\s+(readonly\s+)?(\w+)(\??):\s*([^;]+);/gm)) {
|
|
1912
2013
|
const readonly = m[1] ? 'readonly ' : '';
|
|
1913
2014
|
const optional = m[3] ? '?' : '';
|
|
1914
2015
|
const typeStr = m[4].trim().replace(/\s+/g, ' ').slice(0, 35);
|
|
1915
|
-
|
|
2016
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
2017
|
+
members.push({ text: `${readonly}${m[2]}${optional}: ${typeStr}`, start, end: m.index + m[0].length });
|
|
1916
2018
|
}
|
|
1917
2019
|
for (const m of block.matchAll(/^\s+(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)\s*:/gm)) {
|
|
1918
|
-
|
|
2020
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
2021
|
+
members.push({ text: `${m[1]}(${normalizeParams(m[2])})`, start, end: m.index + m[0].length });
|
|
1919
2022
|
}
|
|
1920
2023
|
return members.slice(0, 8);
|
|
1921
2024
|
}
|
|
1922
|
-
|
|
2025
|
+
|
|
2026
|
+
const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
|
|
2027
|
+
|
|
2028
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
2029
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
2030
|
+
// per-method line anchors that span the method body.
|
|
1923
2031
|
function extractClassMembers(block) {
|
|
1924
2032
|
const members = [];
|
|
1925
|
-
// Public methods (skip private/protected/_ prefixed)
|
|
2033
|
+
// Public methods (skip private/protected/_ prefixed and control-flow keywords)
|
|
1926
2034
|
const methodRe = /^\s+(?:public\s+|static\s+|async\s+|override\s+)*(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)(?:\s*:\s*[^{;]+)?\s*\{/gm;
|
|
1927
2035
|
for (const m of block.matchAll(methodRe)) {
|
|
2036
|
+
if (_CTRL_KEYWORDS.has(m[1])) continue;
|
|
1928
2037
|
if (/^(private|protected|_)/.test(m[1])) continue;
|
|
1929
|
-
|
|
2038
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
2039
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
2040
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
2041
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
1930
2042
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
1931
2043
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
1932
2044
|
const retMatch = m[0].match(/\)\s*:\s*([^{;]+)\s*\{/);
|
|
1933
2045
|
const retType = retMatch ? retMatch[1].trim().replace(/\s+/g, ' ').slice(0, 20) : '';
|
|
1934
|
-
const retStr = retType ? `
|
|
1935
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
2046
|
+
const retStr = retType ? ` → ${retType}` : '';
|
|
2047
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
1936
2048
|
}
|
|
1937
2049
|
return members.slice(0, 8);
|
|
1938
2050
|
}
|
|
1939
|
-
|
|
2051
|
+
|
|
1940
2052
|
function normalizeParams(params) {
|
|
1941
2053
|
if (!params) return '';
|
|
1942
2054
|
return params.trim().replace(/\s+/g, ' ').replace(/:[^,)]+/g, '').trim();
|
|
1943
2055
|
}
|
|
1944
|
-
|
|
2056
|
+
|
|
1945
2057
|
module.exports = { extract };
|
|
1946
|
-
|
|
1947
|
-
};
|
|
1948
2058
|
|
|
2059
|
+
};
|
|
1949
2060
|
// ── ./src/extractors/graphql ──
|
|
1950
2061
|
__factories["./src/extractors/graphql"] = function(module, exports) {
|
|
1951
2062
|
|
|
@@ -6043,7 +6154,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
6043
6154
|
|
|
6044
6155
|
const SERVER_INFO = {
|
|
6045
6156
|
name: 'sigmap',
|
|
6046
|
-
version: '6.
|
|
6157
|
+
version: '6.14.0',
|
|
6047
6158
|
description: 'SigMap MCP server — code signatures on demand',
|
|
6048
6159
|
};
|
|
6049
6160
|
|
|
@@ -8867,7 +8978,7 @@ const path = require('path');
|
|
|
8867
8978
|
const os = require('os');
|
|
8868
8979
|
const { execSync } = require('child_process');
|
|
8869
8980
|
|
|
8870
|
-
const VERSION = '6.
|
|
8981
|
+
const VERSION = '6.14.0';
|
|
8871
8982
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
8872
8983
|
|
|
8873
8984
|
function requireSourceOrBundled(key) {
|
|
@@ -9154,10 +9265,18 @@ function computeEffectiveMaxTokens(fileEntries, config) {
|
|
|
9154
9265
|
|
|
9155
9266
|
function applyTokenBudget(fileEntries, maxTokens) {
|
|
9156
9267
|
// fileEntries: [{ filePath, sigs, mtime }]
|
|
9157
|
-
//
|
|
9158
|
-
|
|
9159
|
-
|
|
9160
|
-
|
|
9268
|
+
// Per-file rendered overhead: "### <path>", code fences, trailing blank line.
|
|
9269
|
+
// A signature-only budget undercounts this, so when many files survive the
|
|
9270
|
+
// section headers alone can blow maxTokens.
|
|
9271
|
+
const sectionOverhead = (e) => estimateTokens(String(e.filePath || '')) + 6;
|
|
9272
|
+
const renderedTotal = (entries) =>
|
|
9273
|
+
entries.reduce((s, e) => s + estimateTokens(e.sigs.join('\n')) + sectionOverhead(e), 0);
|
|
9274
|
+
// The generated file also carries a ~150-token fixed preamble (SigMap command
|
|
9275
|
+
// table + headers). Reserve at least that much so small budgets don't overflow;
|
|
9276
|
+
// for large budgets this matches the historical 10% reserve.
|
|
9277
|
+
const budgetForEntries = Math.max(1, maxTokens - Math.max(200, Math.ceil(maxTokens * 0.10)));
|
|
9278
|
+
let total = renderedTotal(fileEntries);
|
|
9279
|
+
if (total <= budgetForEntries) return fileEntries;
|
|
9161
9280
|
|
|
9162
9281
|
// v6.12 Surgical Context — progressive disclosure: before dropping whole files,
|
|
9163
9282
|
// collapse signature BODIES to their line-anchor pointers (keep `symbol :start-end`).
|
|
@@ -9171,12 +9290,13 @@ function applyTokenBudget(fileEntries, maxTokens) {
|
|
|
9171
9290
|
});
|
|
9172
9291
|
return { ...e, sigs: slim };
|
|
9173
9292
|
});
|
|
9174
|
-
const
|
|
9175
|
-
if (
|
|
9176
|
-
console.warn(`[sigmap] budget: collapsed bodies to anchors, reclaimed ~${total -
|
|
9293
|
+
const collapsedRendered = renderedTotal(collapsed);
|
|
9294
|
+
if (collapsedRendered < total) {
|
|
9295
|
+
console.warn(`[sigmap] budget: collapsed bodies to anchors, reclaimed ~${total - collapsedRendered} tokens`);
|
|
9177
9296
|
working = collapsed;
|
|
9178
|
-
total =
|
|
9179
|
-
|
|
9297
|
+
total = collapsedRendered;
|
|
9298
|
+
// Collapsing keeps every file, so the section overhead must fit too — not just sigs.
|
|
9299
|
+
if (total <= budgetForEntries) return working;
|
|
9180
9300
|
}
|
|
9181
9301
|
|
|
9182
9302
|
// Sort by drop priority (drop first = index 0)
|
|
@@ -9203,12 +9323,13 @@ function applyTokenBudget(fileEntries, maxTokens) {
|
|
|
9203
9323
|
|
|
9204
9324
|
const kept = [];
|
|
9205
9325
|
const verboseDropped = [];
|
|
9206
|
-
// Iterate forward: highest drop-priority files (generated=10, mock=9, test=8) are at index 0
|
|
9207
|
-
// Drop those first until
|
|
9326
|
+
// Iterate forward: highest drop-priority files (generated=10, mock=9, test=8) are at index 0.
|
|
9327
|
+
// Drop those first until the RENDERED total (sigs + section overhead) fits maxTokens,
|
|
9328
|
+
// then keep everything else.
|
|
9329
|
+
let rendered = renderedTotal(withPriority);
|
|
9208
9330
|
for (const entry of withPriority) {
|
|
9209
|
-
|
|
9210
|
-
|
|
9211
|
-
total -= entryTokens;
|
|
9331
|
+
if (rendered > budgetForEntries) {
|
|
9332
|
+
rendered -= estimateTokens(entry.sigs.join('\n')) + sectionOverhead(entry);
|
|
9212
9333
|
verboseDropped.push({ filePath: entry.filePath, reason: entry.dropReason });
|
|
9213
9334
|
} else {
|
|
9214
9335
|
kept.push(entry);
|
|
@@ -10616,6 +10737,8 @@ Usage:
|
|
|
10616
10737
|
${cmd} --impact <file> Show every file impacted by changing <file>
|
|
10617
10738
|
${cmd} --impact <file> --json Impact as JSON {changed, direct, transitive, tests, routes}
|
|
10618
10739
|
${cmd} --impact <file> --depth <n> BFS depth limit (default 3, 0=unlimited)
|
|
10740
|
+
${cmd} verify-ai-output <answer.md> Flag fake files/imports/symbols in an AI answer
|
|
10741
|
+
${cmd} verify-ai-output <answer.md> --json Hallucination report as JSON (exits 1 if issues)
|
|
10619
10742
|
${cmd} --init Write example config + .contextignore scaffold
|
|
10620
10743
|
${cmd} --help Show this message
|
|
10621
10744
|
${cmd} --version Show version
|
|
@@ -11737,6 +11860,45 @@ function main() {
|
|
|
11737
11860
|
process.exit(0);
|
|
11738
11861
|
}
|
|
11739
11862
|
|
|
11863
|
+
// `sigmap verify-ai-output <answer.md>` — Hallucination Guard (deterministic core)
|
|
11864
|
+
if (args[0] === 'verify-ai-output') {
|
|
11865
|
+
const target = args[1];
|
|
11866
|
+
const jsonOut = args.includes('--json');
|
|
11867
|
+
if (!target || target.startsWith('--')) {
|
|
11868
|
+
console.error('[sigmap] Usage: sigmap verify-ai-output <answer.md> [--json]');
|
|
11869
|
+
process.exit(1);
|
|
11870
|
+
}
|
|
11871
|
+
const absTarget = path.resolve(cwd, target);
|
|
11872
|
+
if (!fs.existsSync(absTarget)) {
|
|
11873
|
+
console.error(`[sigmap] file not found: ${target}`);
|
|
11874
|
+
process.exit(1);
|
|
11875
|
+
}
|
|
11876
|
+
|
|
11877
|
+
const answerText = fs.readFileSync(absTarget, 'utf8');
|
|
11878
|
+
const { verify } = requireSourceOrBundled('./src/verify/hallucination-guard');
|
|
11879
|
+
const { issues, summary } = verify(answerText, cwd);
|
|
11880
|
+
|
|
11881
|
+
if (jsonOut) {
|
|
11882
|
+
process.stdout.write(JSON.stringify({ file: path.relative(cwd, absTarget) || target, issues, summary }) + '\n');
|
|
11883
|
+
process.exit(summary.total > 0 ? 1 : 0);
|
|
11884
|
+
}
|
|
11885
|
+
|
|
11886
|
+
const rel = path.relative(cwd, absTarget) || target;
|
|
11887
|
+
if (summary.total === 0) {
|
|
11888
|
+
console.log(`[sigmap] ✓ ${rel} — no hallucinations detected (${summary.symbolsIndexed} symbols indexed)`);
|
|
11889
|
+
process.exit(0);
|
|
11890
|
+
}
|
|
11891
|
+
|
|
11892
|
+
const labels = { 'fake-file': 'Fake file', 'fake-import': 'Fake import', 'fake-symbol': 'Fake symbol' };
|
|
11893
|
+
console.log(`[sigmap] ✗ ${rel} — ${summary.total} issue${summary.total === 1 ? '' : 's'} found`);
|
|
11894
|
+
console.log(` fake-file: ${summary.byType['fake-file']} fake-import: ${summary.byType['fake-import']} fake-symbol: ${summary.byType['fake-symbol']}`);
|
|
11895
|
+
console.log('');
|
|
11896
|
+
for (const issue of issues) {
|
|
11897
|
+
console.log(` L${issue.line} [${labels[issue.type] || issue.type}] ${issue.message}`);
|
|
11898
|
+
}
|
|
11899
|
+
process.exit(1);
|
|
11900
|
+
}
|
|
11901
|
+
|
|
11740
11902
|
// Feature 1: `sigmap explain <file>` — why a file is included or excluded
|
|
11741
11903
|
if (args[0] === 'explain' || args.includes('--explain')) {
|
|
11742
11904
|
const target = args[0] === 'explain'
|
package/package.json
CHANGED
|
@@ -1,57 +1,88 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { lineAt, withAnchor } = require('./line-anchor');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* Extract signatures from JavaScript source code.
|
|
7
|
+
* Top-level declarations and class members carry a `:start-end` line anchor
|
|
8
|
+
* (see line-anchor.js); kept parallel to `sigs` and applied once at return.
|
|
5
9
|
* @param {string} src - Raw file content
|
|
6
10
|
* @returns {string[]} Array of signature strings
|
|
7
11
|
*/
|
|
8
12
|
function extract(src) {
|
|
9
13
|
if (!src || typeof src !== 'string') return [];
|
|
10
14
|
const sigs = [];
|
|
15
|
+
const anchors = [];
|
|
11
16
|
const returnHints = buildReturnHints(src);
|
|
12
17
|
|
|
18
|
+
// Block comments are blanked newline-by-newline (non-newline chars → spaces)
|
|
19
|
+
// so character offsets AND line numbers stay exact for anchors.
|
|
13
20
|
const stripped = src
|
|
14
21
|
.replace(/\/\/.*$/gm, '')
|
|
15
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
22
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
|
|
23
|
+
|
|
24
|
+
const blockEndIdx = (bodyStart) => bodyStart + extractBlock(stripped, bodyStart).length;
|
|
25
|
+
// End line for a function whose match ends at `matchEnd` (before its body brace).
|
|
26
|
+
const fnEndLine = (matchEnd, startLn) => {
|
|
27
|
+
const brace = stripped.indexOf('{', matchEnd);
|
|
28
|
+
return brace !== -1 ? lineAt(stripped, blockEndIdx(brace + 1)) : startLn;
|
|
29
|
+
};
|
|
16
30
|
|
|
17
31
|
// Classes
|
|
18
32
|
const classRegex = /^(export\s+(?:default\s+)?)?class\s+(\w+)(?:\s+extends\s+[\w.]+)?\s*\{/gm;
|
|
19
33
|
for (const m of stripped.matchAll(classRegex)) {
|
|
20
34
|
const prefix = m[1] ? m[1].trim() + ' ' : '';
|
|
35
|
+
const bodyStart = m.index + m[0].length;
|
|
21
36
|
sigs.push(`${prefix}class ${m[2]}`);
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
38
|
+
const block = extractBlock(stripped, bodyStart);
|
|
39
|
+
for (const meth of extractClassMembers(block, returnHints)) {
|
|
40
|
+
sigs.push(` ${meth.text}`);
|
|
41
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
42
|
+
}
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
// Exported named functions
|
|
27
46
|
for (const m of stripped.matchAll(/^export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
28
47
|
const asyncKw = /export\s+async/.test(m[0]) ? 'async ' : '';
|
|
29
48
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
49
|
+
const startLn = lineAt(stripped, m.index);
|
|
30
50
|
sigs.push(`export ${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
51
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
// Exported arrow functions
|
|
34
55
|
for (const m of stripped.matchAll(/^export\s+const\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*=>/gm)) {
|
|
35
56
|
const asyncKw = m[0].includes('async') ? 'async ' : '';
|
|
36
57
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
58
|
+
const startLn = lineAt(stripped, m.index);
|
|
37
59
|
sigs.push(`export const ${m[1]} = ${asyncKw}(${normalizeParams(m[2])}) =>${retStr}`);
|
|
60
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
38
61
|
}
|
|
39
62
|
|
|
40
63
|
// module.exports = { ... }
|
|
41
64
|
const moduleExports = stripped.match(/^module\.exports\s*=\s*\{([^}]+)\}/m);
|
|
42
65
|
if (moduleExports) {
|
|
43
66
|
const names = moduleExports[1].split(',').map((s) => s.trim()).filter(Boolean);
|
|
44
|
-
if (names.length > 0)
|
|
67
|
+
if (names.length > 0) {
|
|
68
|
+
const startLn = lineAt(stripped, moduleExports.index);
|
|
69
|
+
sigs.push(`module.exports = { ${names.join(', ')} }`);
|
|
70
|
+
anchors.push([startLn, lineAt(stripped, moduleExports.index + moduleExports[0].length)]);
|
|
71
|
+
}
|
|
45
72
|
}
|
|
46
73
|
|
|
47
74
|
// Top-level named functions (non-exported)
|
|
48
75
|
for (const m of stripped.matchAll(/^(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/gm)) {
|
|
49
76
|
const asyncKw = m[0].startsWith('async') ? 'async ' : '';
|
|
50
77
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
78
|
+
const startLn = lineAt(stripped, m.index);
|
|
51
79
|
sigs.push(`${asyncKw}function ${m[1]}(${normalizeParams(m[2])})${retStr}`);
|
|
80
|
+
anchors.push([startLn, fnEndLine(m.index + m[0].length, startLn)]);
|
|
52
81
|
}
|
|
53
82
|
|
|
54
|
-
return sigs
|
|
83
|
+
return sigs
|
|
84
|
+
.map((s, i) => (anchors[i] ? withAnchor(s, anchors[i][0], anchors[i][1]) : s))
|
|
85
|
+
.slice(0, 25);
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
function extractBlock(src, startIndex) {
|
|
@@ -66,15 +97,21 @@ function extractBlock(src, startIndex) {
|
|
|
66
97
|
return src.slice(startIndex, i - 1);
|
|
67
98
|
}
|
|
68
99
|
|
|
100
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
101
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
102
|
+
// per-method line anchors that span the method body.
|
|
69
103
|
function extractClassMembers(block, returnHints) {
|
|
70
104
|
const members = [];
|
|
71
105
|
for (const m of block.matchAll(/^\s+(?:static\s+|async\s+|get\s+|set\s+)*(\w+)\s*\(([^)]*)\)\s*\{/gm)) {
|
|
72
106
|
if (/^_/.test(m[1])) continue;
|
|
73
|
-
|
|
107
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
108
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
109
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
110
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
74
111
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
75
112
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
76
113
|
const retStr = formatReturnHint(returnHints.get(m[1]));
|
|
77
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
114
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
78
115
|
}
|
|
79
116
|
return members.slice(0, 8);
|
|
80
117
|
}
|
|
@@ -35,7 +35,10 @@ function extract(src) {
|
|
|
35
35
|
// Collect members
|
|
36
36
|
const block = extractBlock(stripped, bodyStart);
|
|
37
37
|
const members = extractInterfaceMembers(block);
|
|
38
|
-
for (const mem of members) {
|
|
38
|
+
for (const mem of members) {
|
|
39
|
+
sigs.push(` ${mem.text}`);
|
|
40
|
+
anchors.push([lineAt(stripped, bodyStart + mem.start), lineAt(stripped, bodyStart + mem.end)]);
|
|
41
|
+
}
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
// Exported type aliases
|
|
@@ -61,7 +64,10 @@ function extract(src) {
|
|
|
61
64
|
anchors.push([lineAt(stripped, m.index), lineAt(stripped, blockEndIdx(bodyStart))]);
|
|
62
65
|
const block = extractBlock(stripped, bodyStart);
|
|
63
66
|
const methods = extractClassMembers(block);
|
|
64
|
-
for (const meth of methods) {
|
|
67
|
+
for (const meth of methods) {
|
|
68
|
+
sigs.push(` ${meth.text}`);
|
|
69
|
+
anchors.push([lineAt(stripped, bodyStart + meth.start), lineAt(stripped, bodyStart + meth.end)]);
|
|
70
|
+
}
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
// Exported top-level functions (not methods)
|
|
@@ -163,22 +169,29 @@ function extractBlock(src, startIndex) {
|
|
|
163
169
|
return src.slice(startIndex, i - 1);
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
173
|
+
// WITHIN `block`, so the caller can resolve member line anchors.
|
|
166
174
|
function extractInterfaceMembers(block) {
|
|
167
175
|
const members = [];
|
|
168
176
|
for (const m of block.matchAll(/^\s+(readonly\s+)?(\w+)(\??):\s*([^;]+);/gm)) {
|
|
169
177
|
const readonly = m[1] ? 'readonly ' : '';
|
|
170
178
|
const optional = m[3] ? '?' : '';
|
|
171
179
|
const typeStr = m[4].trim().replace(/\s+/g, ' ').slice(0, 35);
|
|
172
|
-
|
|
180
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
181
|
+
members.push({ text: `${readonly}${m[2]}${optional}: ${typeStr}`, start, end: m.index + m[0].length });
|
|
173
182
|
}
|
|
174
183
|
for (const m of block.matchAll(/^\s+(\w+)\s*(?:<[^(]*>)?\s*\(([^)]*)\)\s*:/gm)) {
|
|
175
|
-
|
|
184
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
185
|
+
members.push({ text: `${m[1]}(${normalizeParams(m[2])})`, start, end: m.index + m[0].length });
|
|
176
186
|
}
|
|
177
187
|
return members.slice(0, 8);
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
const _CTRL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'do', 'try', 'catch', 'finally', 'else', 'return']);
|
|
181
191
|
|
|
192
|
+
// Returns members as { text, start, end } where start/end are char offsets
|
|
193
|
+
// WITHIN `block` (end = the method's closing brace), so the caller can resolve
|
|
194
|
+
// per-method line anchors that span the method body.
|
|
182
195
|
function extractClassMembers(block) {
|
|
183
196
|
const members = [];
|
|
184
197
|
// Public methods (skip private/protected/_ prefixed and control-flow keywords)
|
|
@@ -186,13 +199,16 @@ function extractClassMembers(block) {
|
|
|
186
199
|
for (const m of block.matchAll(methodRe)) {
|
|
187
200
|
if (_CTRL_KEYWORDS.has(m[1])) continue;
|
|
188
201
|
if (/^(private|protected|_)/.test(m[1])) continue;
|
|
189
|
-
|
|
202
|
+
const bodyStart = m.index + m[0].length; // just past the opening brace
|
|
203
|
+
const end = bodyStart + extractBlock(block, bodyStart).length;
|
|
204
|
+
const start = m.index + (m[0].length - m[0].replace(/^\s+/, '').length);
|
|
205
|
+
if (m[1] === 'constructor') { members.push({ text: `constructor(${normalizeParams(m[2])})`, start, end }); continue; }
|
|
190
206
|
const isAsync = m[0].includes('async ') ? 'async ' : '';
|
|
191
207
|
const isStatic = m[0].includes('static ') ? 'static ' : '';
|
|
192
208
|
const retMatch = m[0].match(/\)\s*:\s*([^{;]+)\s*\{/);
|
|
193
209
|
const retType = retMatch ? retMatch[1].trim().replace(/\s+/g, ' ').slice(0, 20) : '';
|
|
194
210
|
const retStr = retType ? ` → ${retType}` : '';
|
|
195
|
-
members.push(`${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}
|
|
211
|
+
members.push({ text: `${isStatic}${isAsync}${m[1]}(${normalizeParams(m[2])})${retStr}`, start, end });
|
|
196
212
|
}
|
|
197
213
|
return members.slice(0, 8);
|
|
198
214
|
}
|
package/src/mcp/server.js
CHANGED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Hallucination Guard — deterministic core (Phase 1 MVP).
|
|
5
|
+
*
|
|
6
|
+
* Given the text of an AI answer, flag claims that do not match the repo:
|
|
7
|
+
* - fake-file : a referenced path is not on disk
|
|
8
|
+
* - fake-import : a relative import does not resolve; a bare import is
|
|
9
|
+
* absent from package.json deps (builtins allow-listed)
|
|
10
|
+
* - fake-symbol : a called function/class is absent from the symbol index
|
|
11
|
+
*
|
|
12
|
+
* No network, no LLM. Reuses SigMap primitives (buildSigIndex) but every
|
|
13
|
+
* external dependency is injectable via `opts` so the core stays unit-testable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const parsers = require('./parsers');
|
|
19
|
+
|
|
20
|
+
const NODE_BUILTINS = new Set([
|
|
21
|
+
'fs', 'path', 'os', 'util', 'events', 'stream', 'http', 'https', 'crypto',
|
|
22
|
+
'child_process', 'url', 'querystring', 'assert', 'zlib', 'readline', 'net',
|
|
23
|
+
'tls', 'dns', 'buffer', 'process', 'vm', 'module', 'console', 'timers',
|
|
24
|
+
'string_decoder', 'perf_hooks', 'worker_threads', 'cluster', 'dgram', 'v8',
|
|
25
|
+
'tty', 'repl', 'async_hooks', 'inspector', 'fs/promises', 'path/posix',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const PY_BUILTINS = new Set([
|
|
29
|
+
'os', 'sys', 're', 'json', 'math', 'typing', 'collections', 'itertools',
|
|
30
|
+
'functools', 'datetime', 'pathlib', 'subprocess', 'abc', 'dataclasses',
|
|
31
|
+
'enum', 'io', 'time', 'random', 'logging', 'argparse', 'unittest', 'asyncio',
|
|
32
|
+
'copy', 'hashlib', 'threading', 'string', 'csv', 'glob', 'shutil', 'tempfile',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const LANG_GLOBALS = new Set([
|
|
36
|
+
// JS
|
|
37
|
+
'console', 'require', 'module', 'exports', 'process', 'Object', 'Array',
|
|
38
|
+
'String', 'Number', 'Boolean', 'Math', 'JSON', 'Date', 'Promise', 'Map',
|
|
39
|
+
'Set', 'WeakMap', 'WeakSet', 'RegExp', 'Error', 'Symbol', 'parseInt',
|
|
40
|
+
'parseFloat', 'isNaN', 'setTimeout', 'setInterval', 'clearTimeout', 'fetch',
|
|
41
|
+
'Buffer', 'Function', 'eval', 'encodeURIComponent', 'decodeURIComponent',
|
|
42
|
+
// Python
|
|
43
|
+
'print', 'len', 'range', 'str', 'int', 'float', 'dict', 'list', 'tuple',
|
|
44
|
+
'set', 'bool', 'open', 'enumerate', 'zip', 'map', 'filter', 'sorted',
|
|
45
|
+
'sum', 'min', 'max', 'abs', 'isinstance', 'super', 'type', 'getattr',
|
|
46
|
+
'setattr', 'hasattr',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const REL_EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.json', '.py', '.r', '.R', '.vue'];
|
|
50
|
+
const REL_INDEX = ['index.js', 'index.ts', 'index.tsx', 'index.jsx', '__init__.py'];
|
|
51
|
+
|
|
52
|
+
/** Build the set of known symbol identifiers from the SigMap signature index. */
|
|
53
|
+
function buildSymbolSet(cwd) {
|
|
54
|
+
const set = new Set();
|
|
55
|
+
let fileKeys = [];
|
|
56
|
+
try {
|
|
57
|
+
const { buildSigIndex } = require('../retrieval/ranker');
|
|
58
|
+
const idx = buildSigIndex(cwd);
|
|
59
|
+
fileKeys = [...idx.keys()];
|
|
60
|
+
for (const sigs of idx.values()) {
|
|
61
|
+
for (const sig of sigs) {
|
|
62
|
+
const cleaned = String(sig).replace(/\s*:\d+(?:-\d+)?\s*$/, '');
|
|
63
|
+
const ids = cleaned.match(/[A-Za-z_$][\w$]*/g) || [];
|
|
64
|
+
for (const id of ids) set.add(id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
} catch (_) {}
|
|
68
|
+
return { set, fileKeys };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Load declared dependency names from package.json. */
|
|
72
|
+
function loadDeps(cwd) {
|
|
73
|
+
const deps = new Set();
|
|
74
|
+
let hasPkg = false;
|
|
75
|
+
try {
|
|
76
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
77
|
+
hasPkg = true;
|
|
78
|
+
for (const k of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
|
|
79
|
+
if (pkg[k] && typeof pkg[k] === 'object') {
|
|
80
|
+
for (const name of Object.keys(pkg[k])) deps.add(name);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (_) {}
|
|
84
|
+
return { deps, hasPkg };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Default file-existence check: resolve a referenced path against cwd. */
|
|
88
|
+
function defaultFileExists(cwd, ref) {
|
|
89
|
+
const clean = ref.replace(/^\.\//, '');
|
|
90
|
+
for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
|
|
91
|
+
try {
|
|
92
|
+
if (fs.existsSync(c)) return true;
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Default relative-import resolver: fs candidates + basename match in index. */
|
|
99
|
+
function defaultRelativeResolvable(cwd, mod, fileBasenames) {
|
|
100
|
+
const base = path.resolve(cwd, mod);
|
|
101
|
+
for (const e of REL_EXTS) {
|
|
102
|
+
try {
|
|
103
|
+
if (fs.existsSync(base + e)) return true;
|
|
104
|
+
} catch (_) {}
|
|
105
|
+
}
|
|
106
|
+
for (const idx of REL_INDEX) {
|
|
107
|
+
try {
|
|
108
|
+
if (fs.existsSync(path.join(base, idx))) return true;
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
// Fall back to basename match against the indexed file set (the answer's
|
|
112
|
+
// import is relative to a file we cannot know, so a name match is enough
|
|
113
|
+
// to avoid false positives).
|
|
114
|
+
const wantBase = path.basename(mod).replace(/\.[^.]+$/, '').toLowerCase();
|
|
115
|
+
return fileBasenames.has(wantBase);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Verify an AI answer against the repository.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} answerText
|
|
122
|
+
* @param {string} cwd
|
|
123
|
+
* @param {object} [opts]
|
|
124
|
+
* @param {Set<string>} [opts.symbolSet] override known symbols
|
|
125
|
+
* @param {Set<string>} [opts.deps] override package deps
|
|
126
|
+
* @param {boolean} [opts.hasPkg] whether a package.json exists
|
|
127
|
+
* @param {(ref: string) => boolean} [opts.fileExists] override file check
|
|
128
|
+
* @param {(mod: string) => boolean} [opts.relativeResolvable] override rel-import check
|
|
129
|
+
* @returns {{ issues: object[], summary: object }}
|
|
130
|
+
*/
|
|
131
|
+
function verify(answerText, cwd, opts = {}) {
|
|
132
|
+
let symbolSet = opts.symbolSet;
|
|
133
|
+
let fileBasenames = opts.fileBasenames;
|
|
134
|
+
if (!symbolSet) {
|
|
135
|
+
const built = buildSymbolSet(cwd);
|
|
136
|
+
symbolSet = built.set;
|
|
137
|
+
fileBasenames = new Set(built.fileKeys.map(
|
|
138
|
+
(k) => path.basename(k).replace(/\.[^.]+$/, '').toLowerCase()
|
|
139
|
+
));
|
|
140
|
+
}
|
|
141
|
+
if (!fileBasenames) fileBasenames = new Set();
|
|
142
|
+
|
|
143
|
+
let deps = opts.deps;
|
|
144
|
+
let hasPkg = opts.hasPkg;
|
|
145
|
+
if (!deps) {
|
|
146
|
+
const loaded = loadDeps(cwd);
|
|
147
|
+
deps = loaded.deps;
|
|
148
|
+
if (hasPkg === undefined) hasPkg = loaded.hasPkg;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const fileExists = opts.fileExists || ((ref) => defaultFileExists(cwd, ref));
|
|
152
|
+
const relativeResolvable = opts.relativeResolvable
|
|
153
|
+
|| ((mod) => defaultRelativeResolvable(cwd, mod, fileBasenames));
|
|
154
|
+
|
|
155
|
+
const issues = [];
|
|
156
|
+
const dedupe = new Set();
|
|
157
|
+
const add = (issue) => {
|
|
158
|
+
const key = `${issue.type}::${issue.value}`;
|
|
159
|
+
if (dedupe.has(key)) return;
|
|
160
|
+
dedupe.add(key);
|
|
161
|
+
issues.push(issue);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// 1. fake-file
|
|
165
|
+
for (const { path: p, line } of parsers.extractFilePaths(answerText)) {
|
|
166
|
+
if (!fileExists(p)) {
|
|
167
|
+
add({ type: 'fake-file', value: p, line, message: `File not found on disk: ${p}` });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 2. fake-import
|
|
172
|
+
for (const imp of parsers.extractImports(answerText)) {
|
|
173
|
+
if (imp.relative) {
|
|
174
|
+
if (!relativeResolvable(imp.module)) {
|
|
175
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Import does not resolve: ${imp.module}` });
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// Bare module — only verifiable for JS when a package.json exists.
|
|
180
|
+
const top = imp.module.split('/')[0];
|
|
181
|
+
if (imp.kind === 'js') {
|
|
182
|
+
if (!hasPkg) continue;
|
|
183
|
+
if (NODE_BUILTINS.has(imp.module) || NODE_BUILTINS.has(top)) continue;
|
|
184
|
+
if (top.startsWith('@')) {
|
|
185
|
+
const scoped = imp.module.split('/').slice(0, 2).join('/');
|
|
186
|
+
if (deps.has(scoped) || deps.has(imp.module)) continue;
|
|
187
|
+
} else if (deps.has(top) || deps.has(imp.module)) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
add({ type: 'fake-import', value: imp.module, line: imp.line, message: `Package not in dependencies: ${imp.module}` });
|
|
191
|
+
}
|
|
192
|
+
// Python bare imports: stdlib is unbounded offline — skip to keep precision.
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 3. fake-symbol
|
|
196
|
+
if (symbolSet.size > 0) {
|
|
197
|
+
for (const { name, line } of parsers.extractSymbols(answerText)) {
|
|
198
|
+
if (symbolSet.has(name)) continue;
|
|
199
|
+
if (LANG_GLOBALS.has(name) || NODE_BUILTINS.has(name) || PY_BUILTINS.has(name)) continue;
|
|
200
|
+
add({ type: 'fake-symbol', value: name, line, message: `Symbol not found in repo index: ${name}()` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
issues.sort((a, b) => a.line - b.line);
|
|
205
|
+
|
|
206
|
+
const byType = { 'fake-file': 0, 'fake-import': 0, 'fake-symbol': 0 };
|
|
207
|
+
for (const i of issues) byType[i.type] = (byType[i.type] || 0) + 1;
|
|
208
|
+
|
|
209
|
+
const summary = {
|
|
210
|
+
total: issues.length,
|
|
211
|
+
byType,
|
|
212
|
+
clean: issues.length === 0,
|
|
213
|
+
symbolsIndexed: symbolSet.size,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return { issues, summary };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { verify, buildSymbolSet, loadDeps };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parsers for the Hallucination Guard (verify-ai-output).
|
|
5
|
+
*
|
|
6
|
+
* Extract the verifiable claims an AI answer makes about a codebase:
|
|
7
|
+
* - file paths it references
|
|
8
|
+
* - import / require statements it shows
|
|
9
|
+
* - function / class symbols it calls
|
|
10
|
+
* - fenced code blocks (so callers can scope checks to code vs prose)
|
|
11
|
+
*
|
|
12
|
+
* Everything here is deterministic and offline — pure string analysis.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Extensions we are confident name a source/code/config file (no slash required).
|
|
16
|
+
const KNOWN_CODE_EXT = new Set([
|
|
17
|
+
'js', 'jsx', 'mjs', 'cjs', 'ts', 'tsx', 'py', 'pyw', 'rb', 'go', 'rs',
|
|
18
|
+
'java', 'kt', 'swift', 'c', 'h', 'cpp', 'hpp', 'cs', 'php', 'r',
|
|
19
|
+
'vue', 'svelte', 'css', 'scss', 'less', 'html', 'json', 'yml', 'yaml',
|
|
20
|
+
'toml', 'xml', 'sql', 'graphql', 'gql', 'proto', 'tf', 'md', 'sh',
|
|
21
|
+
'gd', 'gdscript',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract fenced code blocks.
|
|
26
|
+
* @param {string} text
|
|
27
|
+
* @returns {{ lang: string, content: string, line: number }[]}
|
|
28
|
+
*/
|
|
29
|
+
function extractCodeBlocks(text) {
|
|
30
|
+
const blocks = [];
|
|
31
|
+
const lines = text.split('\n');
|
|
32
|
+
let inBlock = false;
|
|
33
|
+
let lang = '';
|
|
34
|
+
let buf = [];
|
|
35
|
+
let startLine = 0;
|
|
36
|
+
for (let i = 0; i < lines.length; i++) {
|
|
37
|
+
const m = lines[i].match(/^```(\w*)/);
|
|
38
|
+
if (m) {
|
|
39
|
+
if (!inBlock) {
|
|
40
|
+
inBlock = true;
|
|
41
|
+
lang = m[1] || '';
|
|
42
|
+
buf = [];
|
|
43
|
+
startLine = i + 2; // first content line (1-based)
|
|
44
|
+
} else {
|
|
45
|
+
blocks.push({ lang, content: buf.join('\n'), line: startLine });
|
|
46
|
+
inBlock = false;
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (inBlock) buf.push(lines[i]);
|
|
51
|
+
}
|
|
52
|
+
return blocks;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Extract file-path references (deduped, first-seen line kept).
|
|
57
|
+
* A token counts as a path when it has a `.<letter…>` extension AND
|
|
58
|
+
* either contains a `/` or carries a known code/config extension.
|
|
59
|
+
* @param {string} text
|
|
60
|
+
* @returns {{ path: string, line: number }[]}
|
|
61
|
+
*/
|
|
62
|
+
function extractFilePaths(text) {
|
|
63
|
+
const lines = text.split('\n');
|
|
64
|
+
const seen = new Map();
|
|
65
|
+
const re = /(?:^|[\s`"'(\[<])([A-Za-z0-9_][\w./-]*\.[A-Za-z][A-Za-z0-9]*)/g;
|
|
66
|
+
for (let i = 0; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i];
|
|
68
|
+
let m;
|
|
69
|
+
re.lastIndex = 0;
|
|
70
|
+
while ((m = re.exec(line)) !== null) {
|
|
71
|
+
const p = m[1];
|
|
72
|
+
if (/^https?:/i.test(p)) continue;
|
|
73
|
+
const ext = (p.split('.').pop() || '').toLowerCase();
|
|
74
|
+
const hasSlash = p.includes('/');
|
|
75
|
+
if (!hasSlash && !KNOWN_CODE_EXT.has(ext)) continue;
|
|
76
|
+
if (!seen.has(p)) seen.set(p, i + 1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...seen.entries()].map(([p, line]) => ({ path: p, line }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract import / require statements.
|
|
84
|
+
* @param {string} text
|
|
85
|
+
* @returns {{ module: string, kind: 'js'|'py', relative: boolean, line: number, raw: string }[]}
|
|
86
|
+
*/
|
|
87
|
+
function extractImports(text) {
|
|
88
|
+
const lines = text.split('\n');
|
|
89
|
+
const out = [];
|
|
90
|
+
const push = (module, kind, line, raw) => {
|
|
91
|
+
if (!module) return;
|
|
92
|
+
out.push({ module, kind, relative: /^[./]/.test(module), line, raw: raw.trim() });
|
|
93
|
+
};
|
|
94
|
+
for (let i = 0; i < lines.length; i++) {
|
|
95
|
+
const line = lines[i];
|
|
96
|
+
let m;
|
|
97
|
+
// JS/TS: import ... from 'x' | export ... from 'x'
|
|
98
|
+
if ((m = line.match(/\b(?:import|export)\b[^'"]*\bfrom\s*['"]([^'"]+)['"]/))) {
|
|
99
|
+
push(m[1], 'js', i + 1, line);
|
|
100
|
+
} else if ((m = line.match(/\bimport\s*['"]([^'"]+)['"]/))) {
|
|
101
|
+
// side-effect import 'x'
|
|
102
|
+
push(m[1], 'js', i + 1, line);
|
|
103
|
+
}
|
|
104
|
+
// require('x') / dynamic import('x') — may co-occur, scan separately
|
|
105
|
+
const reqRe = /\b(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
106
|
+
let r;
|
|
107
|
+
while ((r = reqRe.exec(line)) !== null) push(r[1], 'js', i + 1, line);
|
|
108
|
+
|
|
109
|
+
// Python: from x import y | import x
|
|
110
|
+
if ((m = line.match(/^\s*from\s+([.\w]+)\s+import\b/))) {
|
|
111
|
+
push(m[1], 'py', i + 1, line);
|
|
112
|
+
} else if ((m = line.match(/^\s*import\s+([A-Za-z_][\w.]*)/))) {
|
|
113
|
+
push(m[1], 'py', i + 1, line);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract function/class symbol references that look like calls.
|
|
121
|
+
* Restricted to backtick-wrapped calls (`foo(...)`) for high precision.
|
|
122
|
+
* @param {string} text
|
|
123
|
+
* @returns {{ name: string, line: number }[]}
|
|
124
|
+
*/
|
|
125
|
+
function extractSymbols(text) {
|
|
126
|
+
const lines = text.split('\n');
|
|
127
|
+
const out = [];
|
|
128
|
+
const seen = new Set();
|
|
129
|
+
const re = /`([A-Za-z_$][\w$]*)\s*\([^`]*\)`/g;
|
|
130
|
+
for (let i = 0; i < lines.length; i++) {
|
|
131
|
+
let m;
|
|
132
|
+
re.lastIndex = 0;
|
|
133
|
+
while ((m = re.exec(lines[i])) !== null) {
|
|
134
|
+
const name = m[1];
|
|
135
|
+
const key = name + '@' + (i + 1);
|
|
136
|
+
if (seen.has(key)) continue;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
out.push({ name, line: i + 1 });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
extractCodeBlocks,
|
|
146
|
+
extractFilePaths,
|
|
147
|
+
extractImports,
|
|
148
|
+
extractSymbols,
|
|
149
|
+
};
|