sigmap 8.26.2 → 8.28.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 +25 -0
- package/README.md +6 -6
- package/gen-context.js +468 -61
- package/llms-full.txt +4 -4
- package/llms.txt +4 -4
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/extractors/javascript.js +57 -27
- package/src/extractors/scan.js +91 -0
- package/src/extractors/typescript.js +96 -31
- package/src/mcp/server.js +1 -1
- package/src/verify/arity.js +180 -0
- package/src/verify/hallucination-guard.js +34 -1
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Arity-checked verification (D1, #529).
|
|
5
|
+
*
|
|
6
|
+
* With JS/TS params exact (v8.27 balanced scanner) and Python params from the
|
|
7
|
+
* AST, the signature index carries real parameter lists — so verification can
|
|
8
|
+
* check not just "does this function exist" but "is this call's argument
|
|
9
|
+
* count plausible". Deliberately conservative: only uniquely-resolved,
|
|
10
|
+
* non-variadic, top-level functions from exact-param languages are checked,
|
|
11
|
+
* and dotted method calls are never flagged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const path = require('path');
|
|
15
|
+
const { maskCode, readBalanced } = require('../extractors/scan');
|
|
16
|
+
|
|
17
|
+
// Files whose signature params are exact (JS/TS via scan.js, Python via AST).
|
|
18
|
+
const EXACT_PARAM_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py']);
|
|
19
|
+
|
|
20
|
+
const CTRL_KEYWORDS = new Set([
|
|
21
|
+
'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'await',
|
|
22
|
+
'do', 'else', 'try', 'finally', 'new', 'in', 'of', 'not', 'and', 'or',
|
|
23
|
+
'print', 'super', 'this',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Top-level callable sig shapes (indented member sigs are excluded on purpose
|
|
27
|
+
// — method calls are dotted in answers and dotted calls are skipped anyway).
|
|
28
|
+
const CALLABLE_RES = [
|
|
29
|
+
/^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
|
|
30
|
+
/^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\(/,
|
|
31
|
+
/^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Strip the ` :start-end` anchor and ` # hint` tail from a sig line. */
|
|
35
|
+
function cleanSig(sig) {
|
|
36
|
+
return String(sig).replace(/\s{2}#\s.*$/, '').replace(/\s*:\d+(?:-\d+)?\s*$/, '');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse a parameter-list string into an arity range.
|
|
41
|
+
* Depth- and quote-aware top-level comma split; `=` defaults and trailing `?`
|
|
42
|
+
* lower `min`; `...rest` / `*args` / `**kwargs` mark the signature variadic;
|
|
43
|
+
* destructuring patterns count as one parameter.
|
|
44
|
+
* @param {string} paramText text between the signature's parens
|
|
45
|
+
* @returns {{ min: number, max: number, variadic: boolean }}
|
|
46
|
+
*/
|
|
47
|
+
function parseParams(paramText) {
|
|
48
|
+
const text = String(paramText || '').trim();
|
|
49
|
+
if (!text) return { min: 0, max: 0, variadic: false };
|
|
50
|
+
const masked = maskCode(text);
|
|
51
|
+
const pieces = [];
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let start = 0;
|
|
54
|
+
for (let i = 0; i < masked.length; i++) {
|
|
55
|
+
const ch = masked[i];
|
|
56
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
57
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
58
|
+
else if (ch === ',' && depth === 0) { pieces.push({ raw: text.slice(start, i), masked: masked.slice(start, i) }); start = i + 1; }
|
|
59
|
+
}
|
|
60
|
+
pieces.push({ raw: text.slice(start), masked: masked.slice(start) });
|
|
61
|
+
|
|
62
|
+
let min = 0;
|
|
63
|
+
let max = 0;
|
|
64
|
+
let variadic = false;
|
|
65
|
+
for (const piece of pieces) {
|
|
66
|
+
const p = piece.raw.trim();
|
|
67
|
+
if (!p) continue;
|
|
68
|
+
if (/^(\.\.\.|\*)/.test(p)) { variadic = true; continue; }
|
|
69
|
+
max++;
|
|
70
|
+
// Optional: a top-level `=` default (scan the masked piece at depth 0) or
|
|
71
|
+
// a `?`-suffixed name (TS optional, survives type stripping as `x?`).
|
|
72
|
+
let d = 0;
|
|
73
|
+
let optional = /^[A-Za-z_$][\w$]*\s*\?$/.test(p);
|
|
74
|
+
const pm = piece.masked;
|
|
75
|
+
for (let i = 0; i < pm.length && !optional; i++) {
|
|
76
|
+
const ch = pm[i];
|
|
77
|
+
if (ch === '(' || ch === '[' || ch === '{') d++;
|
|
78
|
+
else if (ch === ')' || ch === ']' || ch === '}') d--;
|
|
79
|
+
else if (ch === '=' && d === 0 && pm[i + 1] !== '>' && pm[i - 1] !== '=' && pm[i - 1] !== '!' && pm[i - 1] !== '<' && pm[i - 1] !== '>') optional = true;
|
|
80
|
+
}
|
|
81
|
+
if (!optional) min++;
|
|
82
|
+
}
|
|
83
|
+
return { min, max, variadic };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a per-name arity index from a SigMap signature index.
|
|
88
|
+
* Only top-level callables from exact-param languages are included; a name
|
|
89
|
+
* whose signatures disagree across files is marked ambiguous (never checked).
|
|
90
|
+
* @param {Map<string, string[]>} sigIndex Map<file, sigs[]>
|
|
91
|
+
* @returns {Map<string, { min, max, variadic, file, sig } | 'ambiguous'>}
|
|
92
|
+
*/
|
|
93
|
+
function buildArityIndex(sigIndex) {
|
|
94
|
+
const index = new Map();
|
|
95
|
+
if (!sigIndex || !(sigIndex instanceof Map)) return index;
|
|
96
|
+
for (const [file, sigs] of sigIndex.entries()) {
|
|
97
|
+
if (!EXACT_PARAM_EXTS.has(path.extname(file))) continue;
|
|
98
|
+
for (const sig of sigs || []) {
|
|
99
|
+
const cleaned = cleanSig(sig);
|
|
100
|
+
let name = null;
|
|
101
|
+
for (const re of CALLABLE_RES) {
|
|
102
|
+
const m = cleaned.match(re);
|
|
103
|
+
if (m) { name = m[1]; break; }
|
|
104
|
+
}
|
|
105
|
+
if (!name) continue;
|
|
106
|
+
const openIdx = cleaned.indexOf('(', cleaned.indexOf(name));
|
|
107
|
+
if (openIdx === -1) continue;
|
|
108
|
+
const masked = maskCode(cleaned);
|
|
109
|
+
const closeIdx = readBalanced(masked, openIdx);
|
|
110
|
+
if (closeIdx === -1) continue;
|
|
111
|
+
const arity = parseParams(cleaned.slice(openIdx + 1, closeIdx));
|
|
112
|
+
const entry = { ...arity, file, sig: cleaned.trim() };
|
|
113
|
+
const existing = index.get(name);
|
|
114
|
+
if (existing === undefined) index.set(name, entry);
|
|
115
|
+
else if (existing === 'ambiguous') continue;
|
|
116
|
+
else if (existing.min !== entry.min || existing.max !== entry.max || existing.variadic !== entry.variadic) {
|
|
117
|
+
index.set(name, 'ambiguous');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return index;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract call sites with argument counts from answer code.
|
|
126
|
+
* Dotted/property calls and keyword-preceded definitions are skipped for
|
|
127
|
+
* precision; nested calls and comma-containing strings count correctly
|
|
128
|
+
* because the scan is over masked text.
|
|
129
|
+
* @param {string} code
|
|
130
|
+
* @returns {{ name: string, args: number, line: number }[]}
|
|
131
|
+
*/
|
|
132
|
+
function extractCallArgCounts(code) {
|
|
133
|
+
const src = String(code || '');
|
|
134
|
+
const masked = maskCode(src);
|
|
135
|
+
const calls = [];
|
|
136
|
+
const re = /([A-Za-z_$][\w$]*)\s*\(/g;
|
|
137
|
+
let m;
|
|
138
|
+
while ((m = re.exec(masked)) !== null) {
|
|
139
|
+
const name = m[1];
|
|
140
|
+
if (CTRL_KEYWORDS.has(name)) continue;
|
|
141
|
+
let k = m.index - 1;
|
|
142
|
+
while (k >= 0 && (masked[k] === ' ' || masked[k] === '\t')) k--;
|
|
143
|
+
if (k >= 0 && (masked[k] === '.' || masked[k] === '$')) continue;
|
|
144
|
+
const before = masked.slice(Math.max(0, m.index - 12), m.index);
|
|
145
|
+
if (/(?:function|def|class|new)\s+$/.test(before)) continue;
|
|
146
|
+
const openIdx = m.index + m[0].length - 1;
|
|
147
|
+
const closeIdx = readBalanced(masked, openIdx);
|
|
148
|
+
if (closeIdx === -1) continue;
|
|
149
|
+
const inner = masked.slice(openIdx + 1, closeIdx);
|
|
150
|
+
let args = 0;
|
|
151
|
+
// Emptiness is judged on the ORIGINAL text — masking blanks string
|
|
152
|
+
// contents, so `f("a,b")` would otherwise look like zero arguments.
|
|
153
|
+
if (src.slice(openIdx + 1, closeIdx).trim()) {
|
|
154
|
+
args = 1;
|
|
155
|
+
let depth = 0;
|
|
156
|
+
for (let i = 0; i < inner.length; i++) {
|
|
157
|
+
const ch = inner[i];
|
|
158
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
159
|
+
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
160
|
+
else if (ch === ',' && depth === 0) args++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
calls.push({ name, args, line: src.slice(0, m.index).split('\n').length });
|
|
164
|
+
}
|
|
165
|
+
return calls;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Check one call against the arity index.
|
|
170
|
+
* @returns {null | { min, max, variadic, file, sig }} the offended entry, or null when fine/unknowable
|
|
171
|
+
*/
|
|
172
|
+
function checkArity(name, argCount, arityIndex) {
|
|
173
|
+
const entry = arityIndex.get(name);
|
|
174
|
+
if (!entry || entry === 'ambiguous') return null;
|
|
175
|
+
if (entry.variadic) return argCount < entry.min ? entry : null;
|
|
176
|
+
if (argCount < entry.min || argCount > entry.max) return entry;
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { parseParams, buildArityIndex, extractCallArgCounts, checkArity, cleanSig, EXACT_PARAM_EXTS };
|
|
@@ -22,6 +22,7 @@ const path = require('path');
|
|
|
22
22
|
const parsers = require('./parsers');
|
|
23
23
|
const { closestMatch, buildSymbolCandidates, formatSuggestion } = require('./closest-match');
|
|
24
24
|
const { buildLibraryIndex } = require('./lib-index');
|
|
25
|
+
const { buildArityIndex, extractCallArgCounts, checkArity } = require('./arity');
|
|
25
26
|
|
|
26
27
|
// A path that looks like a test file (JS/TS spec/test, Python test_/_test, or
|
|
27
28
|
// a tests/__tests__ directory). Used to flag fake-test-file separately.
|
|
@@ -77,9 +78,11 @@ function buildSymbolSet(cwd) {
|
|
|
77
78
|
const set = new Set();
|
|
78
79
|
let fileKeys = [];
|
|
79
80
|
let symbolCandidates = [];
|
|
81
|
+
let sigIndex = null;
|
|
80
82
|
try {
|
|
81
83
|
const { buildSigIndex } = require('../retrieval/ranker');
|
|
82
84
|
const idx = buildSigIndex(cwd);
|
|
85
|
+
sigIndex = idx;
|
|
83
86
|
fileKeys = [...idx.keys()];
|
|
84
87
|
for (const sigs of idx.values()) {
|
|
85
88
|
for (const sig of sigs) {
|
|
@@ -90,7 +93,7 @@ function buildSymbolSet(cwd) {
|
|
|
90
93
|
}
|
|
91
94
|
symbolCandidates = buildSymbolCandidates(idx);
|
|
92
95
|
} catch (_) {}
|
|
93
|
-
return { set, fileKeys, symbolCandidates };
|
|
96
|
+
return { set, fileKeys, symbolCandidates, sigIndex };
|
|
94
97
|
}
|
|
95
98
|
|
|
96
99
|
/** Load declared dependency names from package.json. */
|
|
@@ -179,6 +182,7 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
179
182
|
let fileBasenames = opts.fileBasenames;
|
|
180
183
|
let symbolCandidates = opts.symbolCandidates || [];
|
|
181
184
|
let fileCandidates = opts.fileCandidates || [];
|
|
185
|
+
let arityIndex = opts.arityIndex || null;
|
|
182
186
|
if (!symbolSet) {
|
|
183
187
|
const built = buildSymbolSet(cwd);
|
|
184
188
|
symbolSet = built.set;
|
|
@@ -187,6 +191,9 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
187
191
|
));
|
|
188
192
|
symbolCandidates = built.symbolCandidates;
|
|
189
193
|
fileCandidates = built.fileKeys;
|
|
194
|
+
if (!arityIndex && built.sigIndex) {
|
|
195
|
+
try { arityIndex = buildArityIndex(built.sigIndex); } catch (_) {}
|
|
196
|
+
}
|
|
190
197
|
}
|
|
191
198
|
if (!fileBasenames) fileBasenames = new Set();
|
|
192
199
|
|
|
@@ -305,6 +312,32 @@ function verify(answerText, cwd, opts = {}) {
|
|
|
305
312
|
}
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
// 3b. arity-mismatch (D1, #529) — a call to a KNOWN repo function whose
|
|
316
|
+
// argument count falls outside the signature's [min, max]. Conservative by
|
|
317
|
+
// construction: only uniquely-resolved, top-level functions from
|
|
318
|
+
// exact-param languages (JS/TS via the balanced scanner, Python via AST);
|
|
319
|
+
// variadic signatures only flag too-few; dotted calls never flag.
|
|
320
|
+
if (arityIndex && arityIndex.size > 0) {
|
|
321
|
+
for (const block of parsers.extractCodeBlocks(answerText)) {
|
|
322
|
+
if (block.lang && !/^(js|jsx|ts|tsx|javascript|typescript|python|py)$/i.test(block.lang)) continue;
|
|
323
|
+
for (const call of extractCallArgCounts(block.content)) {
|
|
324
|
+
if (!symbolSet.has(call.name)) continue; // unknown symbols stay fake-symbol territory
|
|
325
|
+
const entry = checkArity(call.name, call.args, arityIndex);
|
|
326
|
+
if (!entry) continue;
|
|
327
|
+
const range = entry.variadic ? `at least ${entry.min}`
|
|
328
|
+
: (entry.min === entry.max ? String(entry.max) : `${entry.min}–${entry.max}`);
|
|
329
|
+
add({
|
|
330
|
+
type: 'arity-mismatch',
|
|
331
|
+
value: `${call.name}(${call.args} args)`,
|
|
332
|
+
line: block.line + call.line - 1,
|
|
333
|
+
message: `${call.name}() called with ${call.args} argument(s) — repo signature takes ${range}`,
|
|
334
|
+
confidence: 'medium',
|
|
335
|
+
suggestion: `${entry.sig} (${entry.file})`,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
// 4. fake-npm-script
|
|
309
342
|
if (hasPkg && scripts.size > 0) {
|
|
310
343
|
for (const { name, line } of parsers.extractNpmScripts(answerText)) {
|