sigmap 8.27.0 → 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 CHANGED
@@ -10,6 +10,18 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.28.0] — 2026-08-18
14
+
15
+ Minor release — **"Arity Guard" (v8.28, D1)**: verification now checks not just *does this function exist* but *is it being called with a plausible number of arguments* — the payoff of the v8.27 balanced scanner.
16
+
17
+ ### Added
18
+ - **Arity-checked verification (#529, PR #530):** new `src/verify/arity.js` — `parseParams` turns a signature's exact parameter list into an arity range (`=` defaults and TS `?`-optionals lower `min`; `...rest`/`*args`/`**kwargs` mark the signature variadic; destructuring patterns count as one parameter; depth- and quote-aware throughout), `buildArityIndex` builds a per-name range from top-level callables in exact-param languages only (JS/TS via the balanced scanner, Python via AST; names whose signatures disagree across files are marked ambiguous and never checked; indented members excluded), `extractCallArgCounts` reads calls from answer code over masked text (nested calls and comma-containing strings count correctly; argument emptiness judged on the original text; dotted calls, definitions, `new`-expressions, and control keywords skipped), `checkArity` (variadic signatures flag only too-few). Wired into the Hallucination Guard as detector 3b — **`arity-mismatch` at medium confidence**, with the repo signature + file as the suggestion; `verify_suggestion` (MCP) and `sigmap verify-ai-output` inherit automatically; `opts.arityIndex` keeps hermetic callers unchanged. Conservative by construction: uniquely-resolved · top-level · non-variadic · undotted · known-symbol calls in JS/TS/Python code blocks only — unknowns stay `fake-symbol`. `KNOWN_LIMITATIONS.md` documents the checks and their gates.
19
+
20
+ ### Changed
21
+ - 6 new integration tests incl. end-to-end through a real context file (136 test files); bundle rebuilt (151 modules); zero new dependencies.
22
+
23
+ ---
24
+
13
25
  ## [8.27.0] — 2026-08-18
14
26
 
15
27
  Minor release — **"Tokenizer Core I" (v8.27, G4 increment 1)**: the hand-rolled balanced scanner lands and JS/TS extraction stops truncating at the first `)` — the first slice of the v9.0 grounding track and the stated precondition for arity-checked verification (D1).
package/README.md CHANGED
@@ -122,7 +122,7 @@ Ask → Rank → Context → Validate → Judge → Learn
122
122
 
123
123
  <!--SM:benchmarkBlock-->
124
124
  ```
125
- Benchmark : sigmap-v8.27-main (21 repositories, including R language)
125
+ Benchmark : sigmap-v8.28-main (21 repositories, including R language)
126
126
  Date : 2026-08-18
127
127
 
128
128
  Hit@5 : 81.1% (grep-agent baseline 44.0% — 1.73× lift)
package/gen-context.js CHANGED
@@ -15385,7 +15385,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
15385
15385
 
15386
15386
  const SERVER_INFO = {
15387
15387
  name: 'sigmap',
15388
- version: '8.27.0',
15388
+ version: '8.28.0',
15389
15389
  description: 'SigMap MCP server — code signatures on demand',
15390
15390
  };
15391
15391
 
@@ -19579,6 +19579,190 @@ __factories["./src/util/truncate"] = function(module, exports) {
19579
19579
 
19580
19580
  };
19581
19581
 
19582
+ // ── ./src/verify/arity ──
19583
+ __factories["./src/verify/arity"] = function(module, exports) {
19584
+
19585
+ /**
19586
+ * Arity-checked verification (D1, #529).
19587
+ *
19588
+ * With JS/TS params exact (v8.27 balanced scanner) and Python params from the
19589
+ * AST, the signature index carries real parameter lists — so verification can
19590
+ * check not just "does this function exist" but "is this call's argument
19591
+ * count plausible". Deliberately conservative: only uniquely-resolved,
19592
+ * non-variadic, top-level functions from exact-param languages are checked,
19593
+ * and dotted method calls are never flagged.
19594
+ */
19595
+
19596
+ const path = require('path');
19597
+ const { maskCode, readBalanced } = __require('./src/extractors/scan');
19598
+
19599
+ // Files whose signature params are exact (JS/TS via scan.js, Python via AST).
19600
+ const EXACT_PARAM_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py']);
19601
+
19602
+ const CTRL_KEYWORDS = new Set([
19603
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'await',
19604
+ 'do', 'else', 'try', 'finally', 'new', 'in', 'of', 'not', 'and', 'or',
19605
+ 'print', 'super', 'this',
19606
+ ]);
19607
+
19608
+ // Top-level callable sig shapes (indented member sigs are excluded on purpose
19609
+ // — method calls are dotted in answers and dotted calls are skipped anyway).
19610
+ const CALLABLE_RES = [
19611
+ /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
19612
+ /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\(/,
19613
+ /^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/,
19614
+ ];
19615
+
19616
+ /** Strip the ` :start-end` anchor and ` # hint` tail from a sig line. */
19617
+ function cleanSig(sig) {
19618
+ return String(sig).replace(/\s{2}#\s.*$/, '').replace(/\s*:\d+(?:-\d+)?\s*$/, '');
19619
+ }
19620
+
19621
+ /**
19622
+ * Parse a parameter-list string into an arity range.
19623
+ * Depth- and quote-aware top-level comma split; `=` defaults and trailing `?`
19624
+ * lower `min`; `...rest` / `*args` / `**kwargs` mark the signature variadic;
19625
+ * destructuring patterns count as one parameter.
19626
+ * @param {string} paramText text between the signature's parens
19627
+ * @returns {{ min: number, max: number, variadic: boolean }}
19628
+ */
19629
+ function parseParams(paramText) {
19630
+ const text = String(paramText || '').trim();
19631
+ if (!text) return { min: 0, max: 0, variadic: false };
19632
+ const masked = maskCode(text);
19633
+ const pieces = [];
19634
+ let depth = 0;
19635
+ let start = 0;
19636
+ for (let i = 0; i < masked.length; i++) {
19637
+ const ch = masked[i];
19638
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
19639
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
19640
+ else if (ch === ',' && depth === 0) { pieces.push({ raw: text.slice(start, i), masked: masked.slice(start, i) }); start = i + 1; }
19641
+ }
19642
+ pieces.push({ raw: text.slice(start), masked: masked.slice(start) });
19643
+
19644
+ let min = 0;
19645
+ let max = 0;
19646
+ let variadic = false;
19647
+ for (const piece of pieces) {
19648
+ const p = piece.raw.trim();
19649
+ if (!p) continue;
19650
+ if (/^(\.\.\.|\*)/.test(p)) { variadic = true; continue; }
19651
+ max++;
19652
+ // Optional: a top-level `=` default (scan the masked piece at depth 0) or
19653
+ // a `?`-suffixed name (TS optional, survives type stripping as `x?`).
19654
+ let d = 0;
19655
+ let optional = /^[A-Za-z_$][\w$]*\s*\?$/.test(p);
19656
+ const pm = piece.masked;
19657
+ for (let i = 0; i < pm.length && !optional; i++) {
19658
+ const ch = pm[i];
19659
+ if (ch === '(' || ch === '[' || ch === '{') d++;
19660
+ else if (ch === ')' || ch === ']' || ch === '}') d--;
19661
+ else if (ch === '=' && d === 0 && pm[i + 1] !== '>' && pm[i - 1] !== '=' && pm[i - 1] !== '!' && pm[i - 1] !== '<' && pm[i - 1] !== '>') optional = true;
19662
+ }
19663
+ if (!optional) min++;
19664
+ }
19665
+ return { min, max, variadic };
19666
+ }
19667
+
19668
+ /**
19669
+ * Build a per-name arity index from a SigMap signature index.
19670
+ * Only top-level callables from exact-param languages are included; a name
19671
+ * whose signatures disagree across files is marked ambiguous (never checked).
19672
+ * @param {Map<string, string[]>} sigIndex Map<file, sigs[]>
19673
+ * @returns {Map<string, { min, max, variadic, file, sig } | 'ambiguous'>}
19674
+ */
19675
+ function buildArityIndex(sigIndex) {
19676
+ const index = new Map();
19677
+ if (!sigIndex || !(sigIndex instanceof Map)) return index;
19678
+ for (const [file, sigs] of sigIndex.entries()) {
19679
+ if (!EXACT_PARAM_EXTS.has(path.extname(file))) continue;
19680
+ for (const sig of sigs || []) {
19681
+ const cleaned = cleanSig(sig);
19682
+ let name = null;
19683
+ for (const re of CALLABLE_RES) {
19684
+ const m = cleaned.match(re);
19685
+ if (m) { name = m[1]; break; }
19686
+ }
19687
+ if (!name) continue;
19688
+ const openIdx = cleaned.indexOf('(', cleaned.indexOf(name));
19689
+ if (openIdx === -1) continue;
19690
+ const masked = maskCode(cleaned);
19691
+ const closeIdx = readBalanced(masked, openIdx);
19692
+ if (closeIdx === -1) continue;
19693
+ const arity = parseParams(cleaned.slice(openIdx + 1, closeIdx));
19694
+ const entry = { ...arity, file, sig: cleaned.trim() };
19695
+ const existing = index.get(name);
19696
+ if (existing === undefined) index.set(name, entry);
19697
+ else if (existing === 'ambiguous') continue;
19698
+ else if (existing.min !== entry.min || existing.max !== entry.max || existing.variadic !== entry.variadic) {
19699
+ index.set(name, 'ambiguous');
19700
+ }
19701
+ }
19702
+ }
19703
+ return index;
19704
+ }
19705
+
19706
+ /**
19707
+ * Extract call sites with argument counts from answer code.
19708
+ * Dotted/property calls and keyword-preceded definitions are skipped for
19709
+ * precision; nested calls and comma-containing strings count correctly
19710
+ * because the scan is over masked text.
19711
+ * @param {string} code
19712
+ * @returns {{ name: string, args: number, line: number }[]}
19713
+ */
19714
+ function extractCallArgCounts(code) {
19715
+ const src = String(code || '');
19716
+ const masked = maskCode(src);
19717
+ const calls = [];
19718
+ const re = /([A-Za-z_$][\w$]*)\s*\(/g;
19719
+ let m;
19720
+ while ((m = re.exec(masked)) !== null) {
19721
+ const name = m[1];
19722
+ if (CTRL_KEYWORDS.has(name)) continue;
19723
+ let k = m.index - 1;
19724
+ while (k >= 0 && (masked[k] === ' ' || masked[k] === '\t')) k--;
19725
+ if (k >= 0 && (masked[k] === '.' || masked[k] === '$')) continue;
19726
+ const before = masked.slice(Math.max(0, m.index - 12), m.index);
19727
+ if (/(?:function|def|class|new)\s+$/.test(before)) continue;
19728
+ const openIdx = m.index + m[0].length - 1;
19729
+ const closeIdx = readBalanced(masked, openIdx);
19730
+ if (closeIdx === -1) continue;
19731
+ const inner = masked.slice(openIdx + 1, closeIdx);
19732
+ let args = 0;
19733
+ // Emptiness is judged on the ORIGINAL text — masking blanks string
19734
+ // contents, so `f("a,b")` would otherwise look like zero arguments.
19735
+ if (src.slice(openIdx + 1, closeIdx).trim()) {
19736
+ args = 1;
19737
+ let depth = 0;
19738
+ for (let i = 0; i < inner.length; i++) {
19739
+ const ch = inner[i];
19740
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
19741
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
19742
+ else if (ch === ',' && depth === 0) args++;
19743
+ }
19744
+ }
19745
+ calls.push({ name, args, line: src.slice(0, m.index).split('\n').length });
19746
+ }
19747
+ return calls;
19748
+ }
19749
+
19750
+ /**
19751
+ * Check one call against the arity index.
19752
+ * @returns {null | { min, max, variadic, file, sig }} the offended entry, or null when fine/unknowable
19753
+ */
19754
+ function checkArity(name, argCount, arityIndex) {
19755
+ const entry = arityIndex.get(name);
19756
+ if (!entry || entry === 'ambiguous') return null;
19757
+ if (entry.variadic) return argCount < entry.min ? entry : null;
19758
+ if (argCount < entry.min || argCount > entry.max) return entry;
19759
+ return null;
19760
+ }
19761
+
19762
+ module.exports = { parseParams, buildArityIndex, extractCallArgCounts, checkArity, cleanSig, EXACT_PARAM_EXTS };
19763
+
19764
+ };
19765
+
19582
19766
  // ── ./src/verify/closest-match ──
19583
19767
  __factories["./src/verify/closest-match"] = function(module, exports) {
19584
19768
 
@@ -19753,6 +19937,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19753
19937
  const parsers = __require('./src/verify/parsers');
19754
19938
  const { closestMatch, buildSymbolCandidates, formatSuggestion } = __require('./src/verify/closest-match');
19755
19939
  const { buildLibraryIndex } = __require('./src/verify/lib-index');
19940
+ const { buildArityIndex, extractCallArgCounts, checkArity } = __require('./src/verify/arity');
19756
19941
 
19757
19942
  // A path that looks like a test file (JS/TS spec/test, Python test_/_test, or
19758
19943
  // a tests/__tests__ directory). Used to flag fake-test-file separately.
@@ -19808,9 +19993,11 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19808
19993
  const set = new Set();
19809
19994
  let fileKeys = [];
19810
19995
  let symbolCandidates = [];
19996
+ let sigIndex = null;
19811
19997
  try {
19812
19998
  const { buildSigIndex } = __require('./src/retrieval/ranker');
19813
19999
  const idx = buildSigIndex(cwd);
20000
+ sigIndex = idx;
19814
20001
  fileKeys = [...idx.keys()];
19815
20002
  for (const sigs of idx.values()) {
19816
20003
  for (const sig of sigs) {
@@ -19821,7 +20008,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19821
20008
  }
19822
20009
  symbolCandidates = buildSymbolCandidates(idx);
19823
20010
  } catch (_) {}
19824
- return { set, fileKeys, symbolCandidates };
20011
+ return { set, fileKeys, symbolCandidates, sigIndex };
19825
20012
  }
19826
20013
 
19827
20014
  /** Load declared dependency names from package.json. */
@@ -19910,6 +20097,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19910
20097
  let fileBasenames = opts.fileBasenames;
19911
20098
  let symbolCandidates = opts.symbolCandidates || [];
19912
20099
  let fileCandidates = opts.fileCandidates || [];
20100
+ let arityIndex = opts.arityIndex || null;
19913
20101
  if (!symbolSet) {
19914
20102
  const built = buildSymbolSet(cwd);
19915
20103
  symbolSet = built.set;
@@ -19918,6 +20106,9 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19918
20106
  ));
19919
20107
  symbolCandidates = built.symbolCandidates;
19920
20108
  fileCandidates = built.fileKeys;
20109
+ if (!arityIndex && built.sigIndex) {
20110
+ try { arityIndex = buildArityIndex(built.sigIndex); } catch (_) {}
20111
+ }
19921
20112
  }
19922
20113
  if (!fileBasenames) fileBasenames = new Set();
19923
20114
 
@@ -20036,6 +20227,32 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
20036
20227
  }
20037
20228
  }
20038
20229
 
20230
+ // 3b. arity-mismatch (D1, #529) — a call to a KNOWN repo function whose
20231
+ // argument count falls outside the signature's [min, max]. Conservative by
20232
+ // construction: only uniquely-resolved, top-level functions from
20233
+ // exact-param languages (JS/TS via the balanced scanner, Python via AST);
20234
+ // variadic signatures only flag too-few; dotted calls never flag.
20235
+ if (arityIndex && arityIndex.size > 0) {
20236
+ for (const block of parsers.extractCodeBlocks(answerText)) {
20237
+ if (block.lang && !/^(js|jsx|ts|tsx|javascript|typescript|python|py)$/i.test(block.lang)) continue;
20238
+ for (const call of extractCallArgCounts(block.content)) {
20239
+ if (!symbolSet.has(call.name)) continue; // unknown symbols stay fake-symbol territory
20240
+ const entry = checkArity(call.name, call.args, arityIndex);
20241
+ if (!entry) continue;
20242
+ const range = entry.variadic ? `at least ${entry.min}`
20243
+ : (entry.min === entry.max ? String(entry.max) : `${entry.min}–${entry.max}`);
20244
+ add({
20245
+ type: 'arity-mismatch',
20246
+ value: `${call.name}(${call.args} args)`,
20247
+ line: block.line + call.line - 1,
20248
+ message: `${call.name}() called with ${call.args} argument(s) — repo signature takes ${range}`,
20249
+ confidence: 'medium',
20250
+ suggestion: `${entry.sig} (${entry.file})`,
20251
+ });
20252
+ }
20253
+ }
20254
+ }
20255
+
20039
20256
  // 4. fake-npm-script
20040
20257
  if (hasPkg && scripts.size > 0) {
20041
20258
  for (const { name, line } of parsers.extractNpmScripts(answerText)) {
@@ -20997,7 +21214,7 @@ function __tryGit(args, opts = {}) {
20997
21214
  catch (_) { return ''; }
20998
21215
  }
20999
21216
 
21000
- const VERSION = '8.27.0';
21217
+ const VERSION = '8.28.0';
21001
21218
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
21002
21219
 
21003
21220
  function requireSourceOrBundled(key) {
package/llms-full.txt CHANGED
@@ -11,13 +11,13 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.27.0 | Benchmark: sigmap-v8.27-main (2026-08-18)
14
+ # Version: 8.28.0 | Benchmark: sigmap-v8.28-main (2026-08-18)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
18
18
  ---
19
19
 
20
- ## Core metrics (benchmark: sigmap-v8.27-main, 2026-08-18)
20
+ ## Core metrics (benchmark: sigmap-v8.28-main, 2026-08-18)
21
21
 
22
22
  | Metric | Without SigMap | With SigMap |
23
23
  |--------|----------------|-------------|
package/llms.txt CHANGED
@@ -11,7 +11,7 @@ ranking keeps the relevant context in scope (cutting tokens ~97% as a side
11
11
  effect), with no LLM calls, embeddings, or vector database. Works with Claude,
12
12
  Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
13
13
 
14
- # Version: 8.27.0 | Benchmark: sigmap-v8.27-main (2026-08-18)
14
+ # Version: 8.28.0 | Benchmark: sigmap-v8.28-main (2026-08-18)
15
15
  # Source: auto-generated from package.json, version.json, benchmarks/latest.json, src/mcp/tools.js, src/config/defaults.js
16
16
  # Regenerate: npm run generate:llms | Validate: npm run validate:llms
17
17
 
@@ -23,7 +23,7 @@ Cursor, GitHub Copilot, Aider, Windsurf, local LLMs, and MCP.
23
23
  - No blast-radius awareness before editing a hub file — `--impact` shows every file a change touches.
24
24
  - Pasted stack traces, CI logs, and JSON bloat the prompt — `squeeze` minimizes them and enriches the top frame from the symbol index.
25
25
 
26
- ## Core metrics (benchmark: sigmap-v8.27-main, 2026-08-18)
26
+ ## Core metrics (benchmark: sigmap-v8.28-main, 2026-08-18)
27
27
 
28
28
  - hit@5 retrieval: 81.1% vs 44.0% single-shot grep baseline (1.73× lift)
29
29
  - Token reduction: 96.8% average across benchmark repos
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "8.27.0",
3
+ "version": "8.28.0",
4
4
  "description": "The deterministic, verifiable grounding layer for AI code work — a zero-dependency signature-and-evidence map that grounds Claude, Cursor, Copilot, Aider, Windsurf, local LLMs & MCP agents against your real code (repo + installed libraries) so they stop hallucinating files, imports & APIs. Runs offline via npx; byte-stable output; ~97% token reduction as proof.",
5
5
  "main": "packages/core/index.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "8.27.0",
3
+ "version": "8.28.0",
4
4
  "description": "SigMap CLI wrapper — thin adapter for programmatic CLI invocation",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-core",
3
- "version": "8.27.0",
3
+ "version": "8.28.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
package/src/mcp/server.js CHANGED
@@ -18,7 +18,7 @@ const { readContext, searchSignatures, getMap, createCheckpoint, getRouting, exp
18
18
 
19
19
  const SERVER_INFO = {
20
20
  name: 'sigmap',
21
- version: '8.27.0',
21
+ version: '8.28.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -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)) {