sigmap 8.27.0 → 8.28.1

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,31 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [8.28.1] — 2026-08-22
14
+
15
+ Patch release — two silent-failure bug fixes: a false "zero importers" in the Python import graph, and an empty index under the per-module strategy.
16
+
17
+ ### Fixed
18
+ - **Python absolute imports resolve through any ancestor root (#532, PR #533)** — reported and precisely diagnosed by **@ruurdboeke**, thank you: `extractFileDeps` probed only the importing file's directory and one parent for `from package.module import`, so `src/`-layout projects (source root on `sys.path`) silently produced no graph edge whenever the importer sat two or more directories below the root — and `get_impact` then reported **zero importers**, exactly the signal that says a change is safe to make. Fix: an ancestor walk from the importing file up to the project root (nearest first, 16-level cap), probing `<module>.py` and `<module>/__init__.py` per level; nearest-first preserves the old first-match semantics for same-dir/one-parent cases. 4 new tests incl. the verbatim issue repro and `get_impact` end-to-end.
19
+ - **Per-module strategy: `ask`/`query_context` find signatures again (#534, PR #535):** the per-module strategy writes one `context-<module>.md` per top-level srcDir and leaves the primary file as a thin overview — but the sig-index strategy enrichment merged only `context-cold.md` (hot-cold) plus the cache, so `sigmap ask` failed with "no context file found" and the `query_context` MCP tool returned an empty index on every per-module repo. Fix: enumerate `.github/context-*.md` (sorted, deterministic) and merge each — the cold-file merge generalized to every strategy split. 4 new tests on a real generated two-module fixture incl. the exact reported failure; hot-cold and full strategies verified unchanged.
20
+
21
+ ### Changed
22
+ - 8 new integration tests (138 test files); bundle rebuilt; zero new dependencies.
23
+
24
+ ---
25
+
26
+ ## [8.28.0] — 2026-08-18
27
+
28
+ 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.
29
+
30
+ ### Added
31
+ - **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.
32
+
33
+ ### Changed
34
+ - 6 new integration tests incl. end-to-end through a real context file (136 test files); bundle rebuilt (151 modules); zero new dependencies.
35
+
36
+ ---
37
+
13
38
  ## [8.27.0] — 2026-08-18
14
39
 
15
40
  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,8 +122,8 @@ 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)
126
- Date : 2026-08-18
125
+ Benchmark : sigmap-v8.28-main (21 repositories, including R language)
126
+ Date : 2026-08-22
127
127
 
128
128
  Hit@5 : 81.1% (grep-agent baseline 44.0% — 1.73× lift)
129
129
  Token reduction: 96.8% (across 21 repos)
package/gen-context.js CHANGED
@@ -11573,23 +11573,31 @@ __factories["./src/graph/builder"] = function(module, exports) {
11573
11573
  }
11574
11574
  }
11575
11575
 
11576
- // Absolute imports: from package.module import ... (infer from project structure)
11576
+ // Absolute imports: from package.module import ... (infer from project
11577
+ // structure). The module is resolved against EVERY ancestor of the
11578
+ // importing file up to the project root, nearest first — any of them can
11579
+ // be the source root on sys.path (src/ layouts, pytest rootdir). The old
11580
+ // dir + one-parent probe silently dropped edges for files nested two or
11581
+ // more directories below the source root (#532), and a false "zero
11582
+ // importers" from get_impact is exactly the signal that says a change is
11583
+ // safe to make.
11577
11584
  const reAbs = /^[ \t]*from\s+([\w.]+)\s+import/gm;
11578
11585
  while ((m = reAbs.exec(content)) !== null) {
11579
11586
  const modulePath = m[1].replace(/\./g, '/');
11580
- const candidates = [
11581
- path.join(dir, modulePath + '.py'),
11582
- path.join(dir, modulePath, '__init__.py'),
11583
- path.resolve(dir, '..', modulePath + '.py'),
11584
- path.resolve(dir, '..', modulePath, '__init__.py'),
11585
- ];
11586
- for (const c of candidates) {
11587
- const normC = normalizePath(c);
11588
- if (fileSet.has(normC)) {
11589
- found.push(normC);
11590
- break;
11587
+ const normCwd = normalizePath(path.resolve(cwd));
11588
+ let base = dir;
11589
+ let hit = null;
11590
+ for (let depth = 0; depth < 16 && !hit; depth++) {
11591
+ for (const c of [path.join(base, modulePath + '.py'), path.join(base, modulePath, '__init__.py')]) {
11592
+ const normC = normalizePath(c);
11593
+ if (fileSet.has(normC)) { hit = normC; break; }
11591
11594
  }
11595
+ if (normalizePath(base) === normCwd) break;
11596
+ const parent = path.dirname(base);
11597
+ if (parent === base) break;
11598
+ base = parent;
11592
11599
  }
11600
+ if (hit) found.push(hit);
11593
11601
  }
11594
11602
  }
11595
11603
 
@@ -15385,7 +15393,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
15385
15393
 
15386
15394
  const SERVER_INFO = {
15387
15395
  name: 'sigmap',
15388
- version: '8.27.0',
15396
+ version: '8.28.1',
15389
15397
  description: 'SigMap MCP server — code signatures on demand',
15390
15398
  };
15391
15399
 
@@ -16975,9 +16983,22 @@ __factories["./src/retrieval/ranker"] = function(module, exports) {
16975
16983
  * @returns {Map<string, string[]>}
16976
16984
  */
16977
16985
  function _enrichSigIndexFromStrategy(cwd, index) {
16986
+ const fs = require('fs');
16978
16987
  const path = require('path');
16979
- const coldPath = path.join(cwd, '.github', 'context-cold.md');
16980
- _mergeSigIndex(index, _parseContextFile(coldPath));
16988
+ // Merge every strategy split file: context-cold.md (hot-cold) AND each
16989
+ // per-module context-<module>.md — the per-module strategy stores ALL
16990
+ // signatures in these, leaving the primary file as a thin overview, so
16991
+ // skipping them made ask/query_context see an empty index (#534).
16992
+ // Sorted for deterministic merge order.
16993
+ try {
16994
+ const ghDir = path.join(cwd, '.github');
16995
+ const splits = fs.readdirSync(ghDir)
16996
+ .filter((f) => /^context-[\w.-]+\.md$/.test(f))
16997
+ .sort();
16998
+ for (const f of splits) {
16999
+ _mergeSigIndex(index, _parseContextFile(path.join(ghDir, f)));
17000
+ }
17001
+ } catch (_) {}
16981
17002
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
16982
17003
  return index;
16983
17004
  }
@@ -19579,6 +19600,190 @@ __factories["./src/util/truncate"] = function(module, exports) {
19579
19600
 
19580
19601
  };
19581
19602
 
19603
+ // ── ./src/verify/arity ──
19604
+ __factories["./src/verify/arity"] = function(module, exports) {
19605
+
19606
+ /**
19607
+ * Arity-checked verification (D1, #529).
19608
+ *
19609
+ * With JS/TS params exact (v8.27 balanced scanner) and Python params from the
19610
+ * AST, the signature index carries real parameter lists — so verification can
19611
+ * check not just "does this function exist" but "is this call's argument
19612
+ * count plausible". Deliberately conservative: only uniquely-resolved,
19613
+ * non-variadic, top-level functions from exact-param languages are checked,
19614
+ * and dotted method calls are never flagged.
19615
+ */
19616
+
19617
+ const path = require('path');
19618
+ const { maskCode, readBalanced } = __require('./src/extractors/scan');
19619
+
19620
+ // Files whose signature params are exact (JS/TS via scan.js, Python via AST).
19621
+ const EXACT_PARAM_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py']);
19622
+
19623
+ const CTRL_KEYWORDS = new Set([
19624
+ 'if', 'for', 'while', 'switch', 'catch', 'return', 'typeof', 'await',
19625
+ 'do', 'else', 'try', 'finally', 'new', 'in', 'of', 'not', 'and', 'or',
19626
+ 'print', 'super', 'this',
19627
+ ]);
19628
+
19629
+ // Top-level callable sig shapes (indented member sigs are excluded on purpose
19630
+ // — method calls are dotted in answers and dotted calls are skipped anyway).
19631
+ const CALLABLE_RES = [
19632
+ /^(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/,
19633
+ /^(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?\(/,
19634
+ /^(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(/,
19635
+ ];
19636
+
19637
+ /** Strip the ` :start-end` anchor and ` # hint` tail from a sig line. */
19638
+ function cleanSig(sig) {
19639
+ return String(sig).replace(/\s{2}#\s.*$/, '').replace(/\s*:\d+(?:-\d+)?\s*$/, '');
19640
+ }
19641
+
19642
+ /**
19643
+ * Parse a parameter-list string into an arity range.
19644
+ * Depth- and quote-aware top-level comma split; `=` defaults and trailing `?`
19645
+ * lower `min`; `...rest` / `*args` / `**kwargs` mark the signature variadic;
19646
+ * destructuring patterns count as one parameter.
19647
+ * @param {string} paramText text between the signature's parens
19648
+ * @returns {{ min: number, max: number, variadic: boolean }}
19649
+ */
19650
+ function parseParams(paramText) {
19651
+ const text = String(paramText || '').trim();
19652
+ if (!text) return { min: 0, max: 0, variadic: false };
19653
+ const masked = maskCode(text);
19654
+ const pieces = [];
19655
+ let depth = 0;
19656
+ let start = 0;
19657
+ for (let i = 0; i < masked.length; i++) {
19658
+ const ch = masked[i];
19659
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
19660
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
19661
+ else if (ch === ',' && depth === 0) { pieces.push({ raw: text.slice(start, i), masked: masked.slice(start, i) }); start = i + 1; }
19662
+ }
19663
+ pieces.push({ raw: text.slice(start), masked: masked.slice(start) });
19664
+
19665
+ let min = 0;
19666
+ let max = 0;
19667
+ let variadic = false;
19668
+ for (const piece of pieces) {
19669
+ const p = piece.raw.trim();
19670
+ if (!p) continue;
19671
+ if (/^(\.\.\.|\*)/.test(p)) { variadic = true; continue; }
19672
+ max++;
19673
+ // Optional: a top-level `=` default (scan the masked piece at depth 0) or
19674
+ // a `?`-suffixed name (TS optional, survives type stripping as `x?`).
19675
+ let d = 0;
19676
+ let optional = /^[A-Za-z_$][\w$]*\s*\?$/.test(p);
19677
+ const pm = piece.masked;
19678
+ for (let i = 0; i < pm.length && !optional; i++) {
19679
+ const ch = pm[i];
19680
+ if (ch === '(' || ch === '[' || ch === '{') d++;
19681
+ else if (ch === ')' || ch === ']' || ch === '}') d--;
19682
+ else if (ch === '=' && d === 0 && pm[i + 1] !== '>' && pm[i - 1] !== '=' && pm[i - 1] !== '!' && pm[i - 1] !== '<' && pm[i - 1] !== '>') optional = true;
19683
+ }
19684
+ if (!optional) min++;
19685
+ }
19686
+ return { min, max, variadic };
19687
+ }
19688
+
19689
+ /**
19690
+ * Build a per-name arity index from a SigMap signature index.
19691
+ * Only top-level callables from exact-param languages are included; a name
19692
+ * whose signatures disagree across files is marked ambiguous (never checked).
19693
+ * @param {Map<string, string[]>} sigIndex Map<file, sigs[]>
19694
+ * @returns {Map<string, { min, max, variadic, file, sig } | 'ambiguous'>}
19695
+ */
19696
+ function buildArityIndex(sigIndex) {
19697
+ const index = new Map();
19698
+ if (!sigIndex || !(sigIndex instanceof Map)) return index;
19699
+ for (const [file, sigs] of sigIndex.entries()) {
19700
+ if (!EXACT_PARAM_EXTS.has(path.extname(file))) continue;
19701
+ for (const sig of sigs || []) {
19702
+ const cleaned = cleanSig(sig);
19703
+ let name = null;
19704
+ for (const re of CALLABLE_RES) {
19705
+ const m = cleaned.match(re);
19706
+ if (m) { name = m[1]; break; }
19707
+ }
19708
+ if (!name) continue;
19709
+ const openIdx = cleaned.indexOf('(', cleaned.indexOf(name));
19710
+ if (openIdx === -1) continue;
19711
+ const masked = maskCode(cleaned);
19712
+ const closeIdx = readBalanced(masked, openIdx);
19713
+ if (closeIdx === -1) continue;
19714
+ const arity = parseParams(cleaned.slice(openIdx + 1, closeIdx));
19715
+ const entry = { ...arity, file, sig: cleaned.trim() };
19716
+ const existing = index.get(name);
19717
+ if (existing === undefined) index.set(name, entry);
19718
+ else if (existing === 'ambiguous') continue;
19719
+ else if (existing.min !== entry.min || existing.max !== entry.max || existing.variadic !== entry.variadic) {
19720
+ index.set(name, 'ambiguous');
19721
+ }
19722
+ }
19723
+ }
19724
+ return index;
19725
+ }
19726
+
19727
+ /**
19728
+ * Extract call sites with argument counts from answer code.
19729
+ * Dotted/property calls and keyword-preceded definitions are skipped for
19730
+ * precision; nested calls and comma-containing strings count correctly
19731
+ * because the scan is over masked text.
19732
+ * @param {string} code
19733
+ * @returns {{ name: string, args: number, line: number }[]}
19734
+ */
19735
+ function extractCallArgCounts(code) {
19736
+ const src = String(code || '');
19737
+ const masked = maskCode(src);
19738
+ const calls = [];
19739
+ const re = /([A-Za-z_$][\w$]*)\s*\(/g;
19740
+ let m;
19741
+ while ((m = re.exec(masked)) !== null) {
19742
+ const name = m[1];
19743
+ if (CTRL_KEYWORDS.has(name)) continue;
19744
+ let k = m.index - 1;
19745
+ while (k >= 0 && (masked[k] === ' ' || masked[k] === '\t')) k--;
19746
+ if (k >= 0 && (masked[k] === '.' || masked[k] === '$')) continue;
19747
+ const before = masked.slice(Math.max(0, m.index - 12), m.index);
19748
+ if (/(?:function|def|class|new)\s+$/.test(before)) continue;
19749
+ const openIdx = m.index + m[0].length - 1;
19750
+ const closeIdx = readBalanced(masked, openIdx);
19751
+ if (closeIdx === -1) continue;
19752
+ const inner = masked.slice(openIdx + 1, closeIdx);
19753
+ let args = 0;
19754
+ // Emptiness is judged on the ORIGINAL text — masking blanks string
19755
+ // contents, so `f("a,b")` would otherwise look like zero arguments.
19756
+ if (src.slice(openIdx + 1, closeIdx).trim()) {
19757
+ args = 1;
19758
+ let depth = 0;
19759
+ for (let i = 0; i < inner.length; i++) {
19760
+ const ch = inner[i];
19761
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
19762
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
19763
+ else if (ch === ',' && depth === 0) args++;
19764
+ }
19765
+ }
19766
+ calls.push({ name, args, line: src.slice(0, m.index).split('\n').length });
19767
+ }
19768
+ return calls;
19769
+ }
19770
+
19771
+ /**
19772
+ * Check one call against the arity index.
19773
+ * @returns {null | { min, max, variadic, file, sig }} the offended entry, or null when fine/unknowable
19774
+ */
19775
+ function checkArity(name, argCount, arityIndex) {
19776
+ const entry = arityIndex.get(name);
19777
+ if (!entry || entry === 'ambiguous') return null;
19778
+ if (entry.variadic) return argCount < entry.min ? entry : null;
19779
+ if (argCount < entry.min || argCount > entry.max) return entry;
19780
+ return null;
19781
+ }
19782
+
19783
+ module.exports = { parseParams, buildArityIndex, extractCallArgCounts, checkArity, cleanSig, EXACT_PARAM_EXTS };
19784
+
19785
+ };
19786
+
19582
19787
  // ── ./src/verify/closest-match ──
19583
19788
  __factories["./src/verify/closest-match"] = function(module, exports) {
19584
19789
 
@@ -19753,6 +19958,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19753
19958
  const parsers = __require('./src/verify/parsers');
19754
19959
  const { closestMatch, buildSymbolCandidates, formatSuggestion } = __require('./src/verify/closest-match');
19755
19960
  const { buildLibraryIndex } = __require('./src/verify/lib-index');
19961
+ const { buildArityIndex, extractCallArgCounts, checkArity } = __require('./src/verify/arity');
19756
19962
 
19757
19963
  // A path that looks like a test file (JS/TS spec/test, Python test_/_test, or
19758
19964
  // a tests/__tests__ directory). Used to flag fake-test-file separately.
@@ -19808,9 +20014,11 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19808
20014
  const set = new Set();
19809
20015
  let fileKeys = [];
19810
20016
  let symbolCandidates = [];
20017
+ let sigIndex = null;
19811
20018
  try {
19812
20019
  const { buildSigIndex } = __require('./src/retrieval/ranker');
19813
20020
  const idx = buildSigIndex(cwd);
20021
+ sigIndex = idx;
19814
20022
  fileKeys = [...idx.keys()];
19815
20023
  for (const sigs of idx.values()) {
19816
20024
  for (const sig of sigs) {
@@ -19821,7 +20029,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19821
20029
  }
19822
20030
  symbolCandidates = buildSymbolCandidates(idx);
19823
20031
  } catch (_) {}
19824
- return { set, fileKeys, symbolCandidates };
20032
+ return { set, fileKeys, symbolCandidates, sigIndex };
19825
20033
  }
19826
20034
 
19827
20035
  /** Load declared dependency names from package.json. */
@@ -19910,6 +20118,7 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19910
20118
  let fileBasenames = opts.fileBasenames;
19911
20119
  let symbolCandidates = opts.symbolCandidates || [];
19912
20120
  let fileCandidates = opts.fileCandidates || [];
20121
+ let arityIndex = opts.arityIndex || null;
19913
20122
  if (!symbolSet) {
19914
20123
  const built = buildSymbolSet(cwd);
19915
20124
  symbolSet = built.set;
@@ -19918,6 +20127,9 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
19918
20127
  ));
19919
20128
  symbolCandidates = built.symbolCandidates;
19920
20129
  fileCandidates = built.fileKeys;
20130
+ if (!arityIndex && built.sigIndex) {
20131
+ try { arityIndex = buildArityIndex(built.sigIndex); } catch (_) {}
20132
+ }
19921
20133
  }
19922
20134
  if (!fileBasenames) fileBasenames = new Set();
19923
20135
 
@@ -20036,6 +20248,32 @@ __factories["./src/verify/hallucination-guard"] = function(module, exports) {
20036
20248
  }
20037
20249
  }
20038
20250
 
20251
+ // 3b. arity-mismatch (D1, #529) — a call to a KNOWN repo function whose
20252
+ // argument count falls outside the signature's [min, max]. Conservative by
20253
+ // construction: only uniquely-resolved, top-level functions from
20254
+ // exact-param languages (JS/TS via the balanced scanner, Python via AST);
20255
+ // variadic signatures only flag too-few; dotted calls never flag.
20256
+ if (arityIndex && arityIndex.size > 0) {
20257
+ for (const block of parsers.extractCodeBlocks(answerText)) {
20258
+ if (block.lang && !/^(js|jsx|ts|tsx|javascript|typescript|python|py)$/i.test(block.lang)) continue;
20259
+ for (const call of extractCallArgCounts(block.content)) {
20260
+ if (!symbolSet.has(call.name)) continue; // unknown symbols stay fake-symbol territory
20261
+ const entry = checkArity(call.name, call.args, arityIndex);
20262
+ if (!entry) continue;
20263
+ const range = entry.variadic ? `at least ${entry.min}`
20264
+ : (entry.min === entry.max ? String(entry.max) : `${entry.min}–${entry.max}`);
20265
+ add({
20266
+ type: 'arity-mismatch',
20267
+ value: `${call.name}(${call.args} args)`,
20268
+ line: block.line + call.line - 1,
20269
+ message: `${call.name}() called with ${call.args} argument(s) — repo signature takes ${range}`,
20270
+ confidence: 'medium',
20271
+ suggestion: `${entry.sig} (${entry.file})`,
20272
+ });
20273
+ }
20274
+ }
20275
+ }
20276
+
20039
20277
  // 4. fake-npm-script
20040
20278
  if (hasPkg && scripts.size > 0) {
20041
20279
  for (const { name, line } of parsers.extractNpmScripts(answerText)) {
@@ -20997,7 +21235,7 @@ function __tryGit(args, opts = {}) {
20997
21235
  catch (_) { return ''; }
20998
21236
  }
20999
21237
 
21000
- const VERSION = '8.27.0';
21238
+ const VERSION = '8.28.1';
21001
21239
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
21002
21240
 
21003
21241
  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.1 | Benchmark: sigmap-v8.28-main (2026-08-22)
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-22)
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.1 | Benchmark: sigmap-v8.28-main (2026-08-22)
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-22)
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.1",
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.1",
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.1",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -240,23 +240,31 @@ function extractFileDeps(filePath, content, fileSet, cwd, ctx) {
240
240
  }
241
241
  }
242
242
 
243
- // Absolute imports: from package.module import ... (infer from project structure)
243
+ // Absolute imports: from package.module import ... (infer from project
244
+ // structure). The module is resolved against EVERY ancestor of the
245
+ // importing file up to the project root, nearest first — any of them can
246
+ // be the source root on sys.path (src/ layouts, pytest rootdir). The old
247
+ // dir + one-parent probe silently dropped edges for files nested two or
248
+ // more directories below the source root (#532), and a false "zero
249
+ // importers" from get_impact is exactly the signal that says a change is
250
+ // safe to make.
244
251
  const reAbs = /^[ \t]*from\s+([\w.]+)\s+import/gm;
245
252
  while ((m = reAbs.exec(content)) !== null) {
246
253
  const modulePath = m[1].replace(/\./g, '/');
247
- const candidates = [
248
- path.join(dir, modulePath + '.py'),
249
- path.join(dir, modulePath, '__init__.py'),
250
- path.resolve(dir, '..', modulePath + '.py'),
251
- path.resolve(dir, '..', modulePath, '__init__.py'),
252
- ];
253
- for (const c of candidates) {
254
- const normC = normalizePath(c);
255
- if (fileSet.has(normC)) {
256
- found.push(normC);
257
- break;
254
+ const normCwd = normalizePath(path.resolve(cwd));
255
+ let base = dir;
256
+ let hit = null;
257
+ for (let depth = 0; depth < 16 && !hit; depth++) {
258
+ for (const c of [path.join(base, modulePath + '.py'), path.join(base, modulePath, '__init__.py')]) {
259
+ const normC = normalizePath(c);
260
+ if (fileSet.has(normC)) { hit = normC; break; }
258
261
  }
262
+ if (normalizePath(base) === normCwd) break;
263
+ const parent = path.dirname(base);
264
+ if (parent === base) break;
265
+ base = parent;
259
266
  }
267
+ if (hit) found.push(hit);
260
268
  }
261
269
  }
262
270
 
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.1',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24
 
@@ -473,9 +473,22 @@ function _buildSigIndexFromCache(cwd) {
473
473
  * @returns {Map<string, string[]>}
474
474
  */
475
475
  function _enrichSigIndexFromStrategy(cwd, index) {
476
+ const fs = require('fs');
476
477
  const path = require('path');
477
- const coldPath = path.join(cwd, '.github', 'context-cold.md');
478
- _mergeSigIndex(index, _parseContextFile(coldPath));
478
+ // Merge every strategy split file: context-cold.md (hot-cold) AND each
479
+ // per-module context-<module>.md — the per-module strategy stores ALL
480
+ // signatures in these, leaving the primary file as a thin overview, so
481
+ // skipping them made ask/query_context see an empty index (#534).
482
+ // Sorted for deterministic merge order.
483
+ try {
484
+ const ghDir = path.join(cwd, '.github');
485
+ const splits = fs.readdirSync(ghDir)
486
+ .filter((f) => /^context-[\w.-]+\.md$/.test(f))
487
+ .sort();
488
+ for (const f of splits) {
489
+ _mergeSigIndex(index, _parseContextFile(path.join(ghDir, f)));
490
+ }
491
+ } catch (_) {}
479
492
  _mergeSigIndex(index, _buildSigIndexFromCache(cwd));
480
493
  return index;
481
494
  }
@@ -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)) {