sigmap 4.2.0 → 5.0.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,30 @@ Format: [Semantic Versioning](https://semver.org/)
10
10
 
11
11
  ---
12
12
 
13
+ ## [5.0.0] — 2026-04-16
14
+
15
+ ### Added
16
+
17
+ - **`sigmap judge --response <file> --context <file>`** — rule-based groundedness scoring engine (`src/judge/judge-engine.js`). Computes a 0–1 score from token overlap between an LLM response and its source context. Exits 0 when verdict is `pass`, exits 1 on `fail`. Supports `--json` (emits `{ score, verdict, reasons }`) and `--threshold` override.
18
+ - **Config `extends`** — `gen-context.config.json` now accepts an `"extends"` key pointing to a local JSON file path or HTTPS URL. The base config is deep-merged (DEFAULTS → base → local), with HTTPS responses cached for 1 hour in `.context/config-cache/`.
19
+ - **`sigmap history [--last N] [--json]`** — displays last N usage log entries as a table with a Unicode sparkline (▁▂▃▄▅▆▇█) for the token trend. Reads from `.context/usage.ndjson` (requires `tracking: true` in config).
20
+
21
+ ---
22
+
23
+ ## [4.3.0] — 2026-04-16
24
+
25
+ ### Added
26
+
27
+ - **`sigmap validate`** — validates config (srcDirs exist, exclude patterns, maxTokens range), computes coverage as sig-index size / total source files, warns when coverage < 70%, exits 1 on hard errors. Optional `--query "<q>"` checks that PascalCase/camelCase symbols in the query appear in top-5 ranked context. Supports `--json`.
28
+ - **`sigmap --ci [--min-coverage N] [--json]`** — GitHub Actions exit gate: exits 0 when coverage ≥ threshold (default 80%), exits 1 otherwise. Uses sig-index vs source file count for a budget-aware coverage metric. Ready for `npx sigmap --ci` in CI workflows.
29
+ - **`extractQuerySymbols(query)`** — internal helper that extracts PascalCase and camelCase identifiers from a query string for symbol-level coverage checks in `sigmap validate`.
30
+
31
+ ### Changed
32
+
33
+ - **`sigmap ask`** — now emits a stderr warning when coverage < 70%, pointing users to `sigmap validate` for diagnosis.
34
+
35
+ ---
36
+
13
37
  ## [4.2.0] — 2026-04-16
14
38
 
15
39
  ### Added
package/gen-context.js CHANGED
@@ -221,6 +221,47 @@ __factories["./src/config/loader"] = function(module, exports) {
221
221
  });
222
222
  }
223
223
 
224
+ const BASE_CONFIG_TTL_MS = 60 * 60 * 1000;
225
+
226
+ function loadBaseConfig(extendsVal, cwd) {
227
+ if (!extendsVal || typeof extendsVal !== 'string') return {};
228
+ if (extendsVal.startsWith('https://') || extendsVal.startsWith('http://')) {
229
+ const cacheDir = path.join(cwd, '.context', 'config-cache');
230
+ const cacheKey = Buffer.from(extendsVal).toString('base64').replace(/[^a-zA-Z0-9_-]/g, '_');
231
+ const cachePath = path.join(cacheDir, `${cacheKey}.json`);
232
+ if (fs.existsSync(cachePath)) {
233
+ const age = Date.now() - fs.statSync(cachePath).mtimeMs;
234
+ if (age < BASE_CONFIG_TTL_MS) {
235
+ try { return JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch (_) {}
236
+ }
237
+ }
238
+ try {
239
+ const { execSync } = require('child_process');
240
+ const proto = extendsVal.startsWith('https') ? 'https' : 'http';
241
+ const out = execSync(
242
+ `node -e "const h=require('${proto}');let d='';h.get(${JSON.stringify(extendsVal)},r=>{r.on('data',c=>d+=c);r.on('end',()=>process.stdout.write(d))}).on('error',()=>process.exit(1))"`,
243
+ { timeout: 10000, encoding: 'utf8' }
244
+ );
245
+ const parsed = JSON.parse(out);
246
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
247
+ fs.writeFileSync(cachePath, JSON.stringify(parsed), 'utf8');
248
+ return parsed;
249
+ } catch (err) {
250
+ process.stderr.write(`[sigmap] config extends: could not fetch ${extendsVal}: ${err.message}\n`);
251
+ if (fs.existsSync(cachePath)) {
252
+ try { return JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch (_) {}
253
+ }
254
+ return {};
255
+ }
256
+ }
257
+ const absPath = path.resolve(cwd, extendsVal);
258
+ try { return JSON.parse(fs.readFileSync(absPath, 'utf8')); }
259
+ catch (err) {
260
+ process.stderr.write(`[sigmap] config extends: could not load ${absPath}: ${err.message}\n`);
261
+ return {};
262
+ }
263
+ }
264
+
224
265
  /**
225
266
  * Load and merge configuration for a given working directory.
226
267
  *
@@ -250,18 +291,31 @@ __factories["./src/config/loader"] = function(module, exports) {
250
291
 
251
292
  // Warn on unknown keys (helps catch typos)
252
293
  for (const key of Object.keys(userConfig)) {
253
- if (key.startsWith('_')) continue; // allow _comment etc.
294
+ if (key.startsWith('_') || key === 'extends') continue;
254
295
  if (!KNOWN_KEYS.has(key)) {
255
296
  console.warn(`[sigmap] unknown config key: "${key}" (ignored)`);
256
297
  }
257
298
  }
258
299
 
259
- // Deep merge: top-level known keys from user override defaults
260
- // For object values (e.g. mcp), merge one level deep
300
+ // Deep merge: DEFAULTS base (extends) user config
301
+ const baseConfig = loadBaseConfig(userConfig.extends, cwd);
261
302
  const merged = deepClone(DEFAULTS);
303
+
304
+ for (const key of Object.keys(baseConfig)) {
305
+ if (key.startsWith('_') || key === 'extends') continue;
306
+ if (!KNOWN_KEYS.has(key)) continue;
307
+ const val = baseConfig[key];
308
+ if (val !== null && typeof val === 'object' && !Array.isArray(val) &&
309
+ typeof merged[key] === 'object' && !Array.isArray(merged[key])) {
310
+ merged[key] = Object.assign({}, merged[key], val);
311
+ } else {
312
+ merged[key] = val;
313
+ }
314
+ }
315
+
262
316
  for (const key of Object.keys(userConfig)) {
263
- if (key.startsWith('_')) continue;
264
- if (!KNOWN_KEYS.has(key)) continue; // skip unknown keys
317
+ if (key.startsWith('_') || key === 'extends') continue;
318
+ if (!KNOWN_KEYS.has(key)) continue;
265
319
  const val = userConfig[key];
266
320
  if (val !== null && typeof val === 'object' && !Array.isArray(val) &&
267
321
  typeof merged[key] === 'object' && !Array.isArray(merged[key])) {
@@ -292,7 +346,7 @@ __factories["./src/config/loader"] = function(module, exports) {
292
346
  return JSON.parse(JSON.stringify(obj));
293
347
  }
294
348
 
295
- module.exports = { loadConfig, detectAutoSrcDirs };
349
+ module.exports = { loadConfig, loadBaseConfig, detectAutoSrcDirs };
296
350
 
297
351
  };
298
352
 
@@ -4654,7 +4708,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
4654
4708
 
4655
4709
  const SERVER_INFO = {
4656
4710
  name: 'sigmap',
4657
- version: '4.2.0',
4711
+ version: '5.0.0',
4658
4712
  description: 'SigMap MCP server — code signatures on demand',
4659
4713
  };
4660
4714
 
@@ -5252,6 +5306,61 @@ __factories["./src/security/scanner"] = function(module, exports) {
5252
5306
 
5253
5307
  };
5254
5308
 
5309
+ // ── ./src/judge/judge-engine ──
5310
+ __factories["./src/judge/judge-engine"] = function(module, exports) {
5311
+ 'use strict';
5312
+
5313
+ const STOP = new Set([
5314
+ 'the','a','an','in','on','at','to','of','for','and','or','but',
5315
+ 'is','are','was','were','be','been','being','have','has','had',
5316
+ 'do','does','did','will','would','could','should','may','might',
5317
+ 'shall','can','not','with','from','by','as','this','that','it',
5318
+ ]);
5319
+
5320
+ function tokenize(text) {
5321
+ return (text || '').toLowerCase().match(/\b[a-z][a-z0-9_]{2,}\b/g) || [];
5322
+ }
5323
+
5324
+ function groundedness(response, context) {
5325
+ if (!response || !context) return 0;
5326
+ const ctxTokens = new Set(tokenize(context).filter((t) => !STOP.has(t)));
5327
+ if (ctxTokens.size === 0) return 0;
5328
+ const respTokens = tokenize(response).filter((t) => !STOP.has(t));
5329
+ if (respTokens.length === 0) return 0;
5330
+ const matched = respTokens.filter((t) => ctxTokens.has(t));
5331
+ return parseFloat((matched.length / respTokens.length).toFixed(3));
5332
+ }
5333
+
5334
+ const GENERIC_MARKERS = [
5335
+ 'however, based on my knowledge',
5336
+ 'generally speaking',
5337
+ 'in general',
5338
+ 'typically,',
5339
+ 'usually,',
5340
+ 'as a general rule',
5341
+ ];
5342
+
5343
+ function judge(response, context, opts) {
5344
+ opts = opts || {};
5345
+ const score = groundedness(response, context);
5346
+ const threshold = opts.threshold !== undefined ? opts.threshold : 0.25;
5347
+ const reasons = [];
5348
+ if (score < threshold) {
5349
+ reasons.push(`score ${score} is below threshold ${threshold} — response may not be grounded in context`);
5350
+ }
5351
+ if (response) {
5352
+ const lower = response.toLowerCase();
5353
+ for (const m of GENERIC_MARKERS) {
5354
+ if (lower.includes(m)) reasons.push(`response contains generic phrase: "${m}"`);
5355
+ }
5356
+ }
5357
+ const verdict = score >= threshold && reasons.length === 0 ? 'pass' : 'fail';
5358
+ return { score, verdict, reasons };
5359
+ }
5360
+
5361
+ module.exports = { groundedness, judge };
5362
+ };
5363
+
5255
5364
  // ── ./src/tracking/logger ──
5256
5365
  __factories["./src/tracking/logger"] = function(module, exports) {
5257
5366
 
@@ -6262,7 +6371,7 @@ const path = require('path');
6262
6371
  const os = require('os');
6263
6372
  const { execSync } = require('child_process');
6264
6373
 
6265
- const VERSION = '4.2.0';
6374
+ const VERSION = '5.0.0';
6266
6375
  const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
6267
6376
 
6268
6377
  function requireSourceOrBundled(key) {
@@ -8004,6 +8113,10 @@ function getIntentWeights(intent) {
8004
8113
  return base;
8005
8114
  }
8006
8115
 
8116
+ function extractQuerySymbols(query) {
8117
+ return (query.match(/\b[A-Z][a-zA-Z]+|[a-z]+(?:[A-Z][a-z]+)+\b/g) || []);
8118
+ }
8119
+
8007
8120
  function main() {
8008
8121
  const args = process.argv.slice(2);
8009
8122
 
@@ -8132,6 +8245,9 @@ function main() {
8132
8245
  riskLevel, contextPath: path.relative(cwd, outPath),
8133
8246
  }) + '\n');
8134
8247
  } else {
8248
+ if (coveragePct < 70) {
8249
+ process.stderr.write(`[sigmap] ⚠ coverage ${coveragePct}% — consider running: sigmap validate\n`);
8250
+ }
8135
8251
  const bar = '─'.repeat(44);
8136
8252
  console.log([
8137
8253
  bar,
@@ -8247,6 +8363,165 @@ function main() {
8247
8363
  process.exit(0);
8248
8364
  }
8249
8365
 
8366
+ // v4.3: `sigmap validate` — config + coverage + optional query symbol check
8367
+ if (args[0] === 'validate') {
8368
+ const issues = [];
8369
+ const warnings = [];
8370
+
8371
+ // Config checks
8372
+ for (const d of (config.srcDirs || [])) {
8373
+ if (!fs.existsSync(path.join(cwd, d)))
8374
+ issues.push(`srcDir '${d}' does not exist`);
8375
+ }
8376
+ if ((config.exclude || []).some((p) => p === 'src/**'))
8377
+ issues.push(`exclude pattern 'src/**' will exclude all source files`);
8378
+ if ((config.maxTokens || 0) < 1000)
8379
+ warnings.push(`maxTokens ${config.maxTokens} is very low — consider ≥ 4000`);
8380
+ if ((config.maxTokens || 0) > 50000)
8381
+ warnings.push(`maxTokens ${config.maxTokens} is very high — may exceed LLM context windows`);
8382
+
8383
+ // Coverage check: files actually in context vs total source files
8384
+ const { buildSigIndex: valBuildSigIndex } = requireSourceOrBundled('./src/retrieval/ranker');
8385
+ const valSigIndex = valBuildSigIndex(cwd);
8386
+ const valTotal = buildFileList(cwd, config).length;
8387
+ const coveragePct = valTotal > 0 ? Math.round((valSigIndex.size / valTotal) * 100) : 0;
8388
+ if (coveragePct < 70)
8389
+ warnings.push(`coverage ${coveragePct}% is below recommended 70% — increase maxTokens or expand srcDirs`);
8390
+
8391
+ // Optional query symbol check
8392
+ const valQueryIdx = args.indexOf('--query');
8393
+ if (valQueryIdx !== -1) {
8394
+ const q = (args[valQueryIdx + 1] || '').trim();
8395
+ if (q && !q.startsWith('--')) {
8396
+ try {
8397
+ const { rank, buildSigIndex } = requireSourceOrBundled('./src/retrieval/ranker');
8398
+ const ranked = rank(q, buildSigIndex(cwd), { topK: 5 });
8399
+ const symbols = extractQuerySymbols(q);
8400
+ const missing = symbols.filter((sym) =>
8401
+ !ranked.some((r) => r.sigs && r.sigs.some((s) => s.toLowerCase().includes(sym.toLowerCase())))
8402
+ );
8403
+ if (missing.length > 0)
8404
+ warnings.push(`query "${q}" references symbols not in top-5 context: ${missing.join(', ')}`);
8405
+ else if (symbols.length > 0)
8406
+ console.log(`[sigmap] ✓ query coverage OK — all ${symbols.length} symbols found`);
8407
+ } catch (_) {}
8408
+ }
8409
+ }
8410
+
8411
+ if (args.includes('--json')) {
8412
+ process.stdout.write(JSON.stringify({ valid: issues.length === 0, issues, warnings, coverage: coveragePct }) + '\n');
8413
+ } else {
8414
+ for (const w of warnings) console.warn(`[sigmap] ⚠ ${w}`);
8415
+ if (issues.length === 0) {
8416
+ console.log(`[sigmap] ✓ config valid coverage: ${coveragePct}%`);
8417
+ } else {
8418
+ for (const iss of issues) console.error(`[sigmap] ✗ ${iss}`);
8419
+ process.exit(1);
8420
+ }
8421
+ }
8422
+ process.exit(0);
8423
+ }
8424
+
8425
+ // v5.0: `sigmap judge --response <file> --context <file>` — groundedness scoring
8426
+ if (args[0] === 'judge') {
8427
+ const respIdx = args.indexOf('--response');
8428
+ const ctxIdx = args.indexOf('--context');
8429
+
8430
+ if (respIdx < 0 || ctxIdx < 0) {
8431
+ console.error('[sigmap] Usage: sigmap judge --response <file> --context <file> [--json] [--threshold 0.25]');
8432
+ process.exit(1);
8433
+ }
8434
+
8435
+ const respFile = (args[respIdx + 1] || '').trim();
8436
+ const ctxFile = (args[ctxIdx + 1] || '').trim();
8437
+
8438
+ if (!respFile || respFile.startsWith('--') || !ctxFile || ctxFile.startsWith('--')) {
8439
+ console.error('[sigmap] --response and --context require file paths');
8440
+ process.exit(1);
8441
+ }
8442
+
8443
+ let responseText = '', contextText = '';
8444
+ try { responseText = fs.readFileSync(path.resolve(cwd, respFile), 'utf8'); }
8445
+ catch (e) { console.error(`[sigmap] cannot read --response file: ${e.message}`); process.exit(1); }
8446
+ try { contextText = fs.readFileSync(path.resolve(cwd, ctxFile), 'utf8'); }
8447
+ catch (e) { console.error(`[sigmap] cannot read --context file: ${e.message}`); process.exit(1); }
8448
+
8449
+ const thrIdx = args.indexOf('--threshold');
8450
+ const judgeOpts = thrIdx >= 0 ? { threshold: parseFloat(args[thrIdx + 1]) || 0.25 } : {};
8451
+
8452
+ const { judge: runJudge } = requireSourceOrBundled('./src/judge/judge-engine');
8453
+ const result = runJudge(responseText, contextText, judgeOpts);
8454
+
8455
+ if (args.includes('--json')) {
8456
+ process.stdout.write(JSON.stringify(result) + '\n');
8457
+ } else {
8458
+ const bar = '─'.repeat(44);
8459
+ console.log([
8460
+ bar,
8461
+ ` sigmap judge`,
8462
+ ` Score : ${result.score}`,
8463
+ ` Verdict : ${result.verdict}`,
8464
+ result.reasons.length ? ` Reasons :\n ${result.reasons.join('\n ')}` : ` Reasons : none`,
8465
+ bar,
8466
+ ].join('\n'));
8467
+ }
8468
+ process.exit(result.verdict === 'pass' ? 0 : 1);
8469
+ }
8470
+
8471
+ // v5.0: `sigmap history` — show last N usage log entries with sparkline
8472
+ if (args[0] === 'history') {
8473
+ const { readLog } = requireSourceOrBundled('./src/tracking/logger');
8474
+ const entries = readLog(cwd);
8475
+
8476
+ const nIdx = args.indexOf('--last');
8477
+ const n = nIdx >= 0 ? (parseInt(args[nIdx + 1], 10) || 10) : 10;
8478
+ const last = entries.slice(-n);
8479
+
8480
+ if (args.includes('--json')) {
8481
+ process.stdout.write(JSON.stringify(last) + '\n');
8482
+ process.exit(0);
8483
+ }
8484
+
8485
+ if (last.length === 0) {
8486
+ console.log('[sigmap] No history found. Run sigmap to generate entries (enable tracking: true in config).');
8487
+ process.exit(0);
8488
+ }
8489
+
8490
+ const SPARK_CHARS = '▁▂▃▄▅▆▇█';
8491
+ function sparkline(values) {
8492
+ if (values.length === 0) return '';
8493
+ const min = Math.min(...values);
8494
+ const max = Math.max(...values);
8495
+ const range = max - min || 1;
8496
+ return values.map((v) => {
8497
+ const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1));
8498
+ return SPARK_CHARS[idx];
8499
+ }).join('');
8500
+ }
8501
+
8502
+ const tokens = last.map((e) => e.finalTokens || 0);
8503
+ const spark = sparkline(tokens);
8504
+
8505
+ const bar = '─'.repeat(62);
8506
+ console.log(bar);
8507
+ console.log(` sigmap history (last ${last.length} runs)`);
8508
+ console.log(bar);
8509
+ console.log(` ${'Date'.padEnd(24)} ${'Files'.padStart(5)} ${'Tokens'.padStart(7)} ${'Reduction'.padStart(9)} ${'Budget?'.padStart(7)}`);
8510
+ console.log(` ${'─'.repeat(24)} ${'─'.repeat(5)} ${'─'.repeat(7)} ${'─'.repeat(9)} ${'─'.repeat(7)}`);
8511
+ for (const e of last) {
8512
+ const date = (e.ts || '').slice(0, 19).replace('T', ' ');
8513
+ const files = String(e.fileCount || 0).padStart(5);
8514
+ const tok = String(e.finalTokens || 0).padStart(7);
8515
+ const red = `${e.reductionPct || 0}%`.padStart(9);
8516
+ const over = (e.overBudget ? ' ⚠ yes' : ' no').padStart(7);
8517
+ console.log(` ${date.padEnd(24)} ${files} ${tok} ${red} ${over}`);
8518
+ }
8519
+ console.log(bar);
8520
+ console.log(` Token trend: ${spark}`);
8521
+ console.log(bar);
8522
+ process.exit(0);
8523
+ }
8524
+
8250
8525
  // Feature 6: `sigmap sync` — write all outputs + llms.txt + print compact diff
8251
8526
  if (args[0] === 'sync') {
8252
8527
  try {
@@ -8912,6 +9187,30 @@ function main() {
8912
9187
  process.exit(0);
8913
9188
  }
8914
9189
 
9190
+ // v4.3: `--ci [--min-coverage N] [--json]` — GitHub Actions exit gate
9191
+ if (args.includes('--ci')) {
9192
+ const minCovIdx = args.indexOf('--min-coverage');
9193
+ const minCoverage = minCovIdx !== -1 ? Math.max(0, Math.min(100, parseInt(args[minCovIdx + 1], 10) || 80)) : 80;
9194
+
9195
+ // Coverage = files actually in context / total source files
9196
+ const { buildSigIndex: ciBuildSigIndex } = requireSourceOrBundled('./src/retrieval/ranker');
9197
+ const ciSigIndex = ciBuildSigIndex(cwd);
9198
+ const ciTotal = buildFileList(cwd, config).length;
9199
+ const coveragePct = ciTotal > 0 ? Math.round((ciSigIndex.size / ciTotal) * 100) : 0;
9200
+
9201
+ const pass = coveragePct >= minCoverage;
9202
+
9203
+ if (args.includes('--json')) {
9204
+ process.stdout.write(JSON.stringify({ pass, coverage: coveragePct, threshold: minCoverage }) + '\n');
9205
+ } else if (pass) {
9206
+ console.log(`[sigmap] ✓ CI gate passed — coverage ${coveragePct}% ≥ ${minCoverage}%`);
9207
+ } else {
9208
+ console.error(`[sigmap] ✗ CI gate FAILED — coverage ${coveragePct}% < ${minCoverage}%`);
9209
+ console.error(` Fix: increase maxTokens or expand srcDirs in gen-context.config.json`);
9210
+ }
9211
+ process.exit(pass ? 0 : 1);
9212
+ }
9213
+
8915
9214
  // Default: generate once
8916
9215
  runGenerate(cwd, config, false);
8917
9216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap",
3
- "version": "4.2.0",
3
+ "version": "5.0.0",
4
4
  "description": "Zero-dependency AI context engine — 97% token reduction. No npm install. Runs on Node 18+.",
5
5
  "main": "gen-context.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sigmap-cli",
3
- "version": "4.2.0",
3
+ "version": "5.0.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": "4.2.0",
3
+ "version": "5.0.0",
4
4
  "description": "SigMap core library — zero-dependency code signature extraction, retrieval, and security scanning",
5
5
  "main": "index.js",
6
6
  "keywords": [
@@ -4,6 +4,65 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { DEFAULTS } = require('./defaults');
6
6
 
7
+ const BASE_CONFIG_TTL_MS = 60 * 60 * 1000; // 1 hour
8
+
9
+ function loadBaseConfig(extendsVal, cwd) {
10
+ if (!extendsVal || typeof extendsVal !== 'string') return {};
11
+
12
+ if (extendsVal.startsWith('https://') || extendsVal.startsWith('http://')) {
13
+ const cacheDir = path.join(cwd, '.context', 'config-cache');
14
+ const cacheKey = Buffer.from(extendsVal).toString('base64url').replace(/[^a-zA-Z0-9_-]/g, '_');
15
+ const cachePath = path.join(cacheDir, `${cacheKey}.json`);
16
+
17
+ if (fs.existsSync(cachePath)) {
18
+ const age = Date.now() - fs.statSync(cachePath).mtimeMs;
19
+ if (age < BASE_CONFIG_TTL_MS) {
20
+ try { return JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch (_) {}
21
+ }
22
+ }
23
+
24
+ try {
25
+ const https = require('https');
26
+ const http = require('http');
27
+ const mod = extendsVal.startsWith('https://') ? https : http;
28
+ const raw = (() => {
29
+ let data = '';
30
+ return new Promise((resolve, reject) => {
31
+ mod.get(extendsVal, (res) => {
32
+ res.on('data', (c) => { data += c; });
33
+ res.on('end', () => resolve(data));
34
+ }).on('error', reject);
35
+ });
36
+ })();
37
+ // sync fallback: use execSync with node -e
38
+ const { execSync } = require('child_process');
39
+ const out = execSync(
40
+ `node -e "const h=require('${extendsVal.startsWith('https') ? 'https' : 'http'}');let d='';h.get(${JSON.stringify(extendsVal)},r=>{r.on('data',c=>d+=c);r.on('end',()=>process.stdout.write(d))}).on('error',()=>process.exit(1))"`,
41
+ { timeout: 10000, encoding: 'utf8' }
42
+ );
43
+ const parsed = JSON.parse(out);
44
+ if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
45
+ fs.writeFileSync(cachePath, JSON.stringify(parsed), 'utf8');
46
+ return parsed;
47
+ } catch (err) {
48
+ process.stderr.write(`[sigmap] config extends: could not fetch ${extendsVal}: ${err.message}\n`);
49
+ if (fs.existsSync(cachePath)) {
50
+ try { return JSON.parse(fs.readFileSync(cachePath, 'utf8')); } catch (_) {}
51
+ }
52
+ return {};
53
+ }
54
+ }
55
+
56
+ // Local file path
57
+ const absPath = path.resolve(cwd, extendsVal);
58
+ try {
59
+ return JSON.parse(fs.readFileSync(absPath, 'utf8'));
60
+ } catch (err) {
61
+ process.stderr.write(`[sigmap] config extends: could not load ${absPath}: ${err.message}\n`);
62
+ return {};
63
+ }
64
+ }
65
+
7
66
  // Keys that are valid in gen-context.config.json
8
67
  const KNOWN_KEYS = new Set(Object.keys(DEFAULTS));
9
68
 
@@ -173,17 +232,30 @@ function loadConfig(cwd) {
173
232
 
174
233
  // Warn on unknown keys (helps catch typos)
175
234
  for (const key of Object.keys(userConfig)) {
176
- if (key.startsWith('_')) continue; // allow _comment etc.
235
+ if (key.startsWith('_') || key === 'extends') continue;
177
236
  if (!KNOWN_KEYS.has(key)) {
178
237
  console.warn(`[sigmap] unknown config key: "${key}" (ignored)`);
179
238
  }
180
239
  }
181
240
 
182
- // Deep merge: top-level known keys from user override defaults
183
- // For object values (e.g. mcp), merge one level deep
241
+ // Deep merge: DEFAULTS base (extends) user config
242
+ const baseConfig = loadBaseConfig(userConfig.extends, cwd);
184
243
  const merged = deepClone(DEFAULTS);
244
+
245
+ for (const key of Object.keys(baseConfig)) {
246
+ if (key.startsWith('_') || key === 'extends') continue;
247
+ if (!KNOWN_KEYS.has(key)) continue;
248
+ const val = baseConfig[key];
249
+ if (val !== null && typeof val === 'object' && !Array.isArray(val) &&
250
+ typeof merged[key] === 'object' && !Array.isArray(merged[key])) {
251
+ merged[key] = Object.assign({}, merged[key], val);
252
+ } else {
253
+ merged[key] = val;
254
+ }
255
+ }
256
+
185
257
  for (const key of Object.keys(userConfig)) {
186
- if (key.startsWith('_')) continue;
258
+ if (key.startsWith('_') || key === 'extends') continue;
187
259
  if (!KNOWN_KEYS.has(key)) continue; // skip unknown keys
188
260
  const val = userConfig[key];
189
261
  if (val !== null && typeof val === 'object' && !Array.isArray(val) &&
@@ -214,4 +286,4 @@ function deepClone(obj) {
214
286
  return JSON.parse(JSON.stringify(obj));
215
287
  }
216
288
 
217
- module.exports = { loadConfig };
289
+ module.exports = { loadConfig, loadBaseConfig };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const STOP = new Set([
4
+ 'the','a','an','in','on','at','to','of','for','and','or','but',
5
+ 'is','are','was','were','be','been','being','have','has','had',
6
+ 'do','does','did','will','would','could','should','may','might',
7
+ 'shall','can','not','with','from','by','as','this','that','it',
8
+ ]);
9
+
10
+ function tokenize(text) {
11
+ return (text || '').toLowerCase().match(/\b[a-z][a-z0-9_]{2,}\b/g) || [];
12
+ }
13
+
14
+ function groundedness(response, context) {
15
+ if (!response || !context) return 0;
16
+ const ctxTokens = new Set(tokenize(context).filter((t) => !STOP.has(t)));
17
+ if (ctxTokens.size === 0) return 0;
18
+ const respTokens = tokenize(response).filter((t) => !STOP.has(t));
19
+ if (respTokens.length === 0) return 0;
20
+ const matched = respTokens.filter((t) => ctxTokens.has(t));
21
+ return parseFloat((matched.length / respTokens.length).toFixed(3));
22
+ }
23
+
24
+ const GENERIC_MARKERS = [
25
+ 'however, based on my knowledge',
26
+ 'generally speaking',
27
+ 'in general',
28
+ 'typically,',
29
+ 'usually,',
30
+ 'as a general rule',
31
+ ];
32
+
33
+ function judge(response, context, opts = {}) {
34
+ const score = groundedness(response, context);
35
+ const threshold = opts.threshold !== undefined ? opts.threshold : 0.25;
36
+ const reasons = [];
37
+
38
+ if (score < threshold) {
39
+ reasons.push(`score ${score} is below threshold ${threshold} — response may not be grounded in context`);
40
+ }
41
+
42
+ if (response) {
43
+ const lower = response.toLowerCase();
44
+ for (const m of GENERIC_MARKERS) {
45
+ if (lower.includes(m)) {
46
+ reasons.push(`response contains generic phrase: "${m}"`);
47
+ }
48
+ }
49
+ }
50
+
51
+ const verdict = score >= threshold && reasons.length === 0 ? 'pass' : 'fail';
52
+ return { score, verdict, reasons };
53
+ }
54
+
55
+ module.exports = { groundedness, judge };
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: '4.2.0',
21
+ version: '5.0.0',
22
22
  description: 'SigMap MCP server — code signatures on demand',
23
23
  };
24
24