#!/usr/bin/env node 'use strict'; // SigMap — standalone bundle (bundled modules generated by scripts/build-bundle.mjs) // Copy this single file to any project: node gen-context.js --setup // No src/ directory required. Node.js 18+ only. Zero npm dependencies. const __factories = {}; const __cache = {}; function __require(key) { if (Object.prototype.hasOwnProperty.call(__cache, key)) { return __cache[key].exports; } if (!Object.prototype.hasOwnProperty.call(__factories, key)) { throw new Error('[sigmap] bundled module not found: ' + key); } const module = { exports: {} }; __cache[key] = module; __factories[key](module, module.exports); return module.exports; } // ═══ SIGMAP BUNDLED MODULES — generated by scripts/build-bundle.mjs; do not edit by hand ═══ // ── ./packages/adapters/claude ── __factories["./packages/adapters/claude"] = function(module, exports) { /** * Claude adapter — appends to CLAUDE.md under a marker line. * Never overwrites human-written content above the marker. * * Contract: * format(context, opts?) → string * outputPath(cwd) → string * write(context, cwd, opts?) → void (handles append logic) */ const path = require('path'); const fs = require('fs'); const { replaceManagedSection, managedSectionLineStart } = __require('./src/util/managed-section'); const name = 'claude'; const ALLOWLIST_MARKER = ''; const ALLOWLIST_BLOCK = [ '## Bash allowlist', '', ALLOWLIST_MARKER, '', 'The following sigmap commands are pre-approved — Claude Code will not prompt for confirmation:', '', '```json', JSON.stringify({ permissions: { allow: [ 'Bash(sigmap ask*)', 'Bash(sigmap validate*)', 'Bash(sigmap judge*)', 'Bash(sigmap weights*)', 'Bash(sigmap history*)', 'Bash(sigmap --query*)', 'Bash(sigmap --diff*)', 'Bash(sigmap --health*)', 'Bash(sigmap --coverage*)', 'Bash(node gen-context.js*)', ], }, }, null, 2), '```', '', 'Add the `permissions.allow` array above to `.claude/settings.json` to activate.', '', ].join('\n'); /** * Format context suited for CLAUDE.md. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): a wall-clock stamp ahead of the body busts a // prefix cache on every run, so it trails the content with the volatile tail. if (opts.cacheLayout !== 'legacy') { return [ ``, meta, '', context, ``, ].join('\n'); } return [ ``, meta, '', context, ].join('\n'); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); // Host-toolchain label (#609): byte-stability holds per toolchain version. if (opts.toolchain) parts.push(`toolchain=${opts.toolchain}`); return ``; } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, 'CLAUDE.md'); } /** * Write signatures into CLAUDE.md using the append-under-marker strategy. * Human content above the marker is never touched. * @param {string} context - Raw signature context string * @param {string} cwd - Project root * @param {object} [opts] */ function write(context, cwd, opts = {}) { const filePath = outputPath(cwd); let existing = ''; if (fs.existsSync(filePath)) { existing = fs.readFileSync(filePath, 'utf8'); } const formatted = format(context, opts); // The marker is found by src/util/managed-section.js: a mention of it in prose // or a code block is never the marker, so human content is never discarded (#873). const placed = replaceManagedSection(existing, formatted); if (placed.warning) console.warn(`[sigmap] CLAUDE.md: ${placed.warning}`); let newContent = placed.content; // Inject ## Bash allowlist above the sig marker if not already present if (!newContent.includes(ALLOWLIST_MARKER)) { const sigMarkerPos = managedSectionLineStart(newContent); if (sigMarkerPos !== -1) { newContent = newContent.slice(0, sigMarkerPos) + ALLOWLIST_BLOCK + '\n' + newContent.slice(sigMarkerPos); } else { newContent = ALLOWLIST_BLOCK + '\n' + newContent; } } fs.writeFileSync(filePath, newContent, 'utf8'); } module.exports = { name, format, outputPath, write }; }; // ── ./packages/adapters/codex ── __factories["./packages/adapters/codex"] = function(module, exports) { /** * Codex adapter — writes OpenAI-style context to AGENTS.md. * * This adapter reuses the same prompt format as the OpenAI adapter, * but targets AGENTS.md so Codex-style agents can read repository guidance. * * Contract: * format(context, opts?) → string * outputPath(cwd) → string * write(context, cwd, opts?) → void */ const path = require('path'); const fs = require('fs'); const { replaceManagedSection } = __require('./src/util/managed-section'); const name = 'codex'; /** * Format context for AGENTS.md — clean markdown, no LLM preamble. * @param {string} context - Raw signature context string * @param {object} [opts] * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string' || !context.trim()) return ''; return `# Code signatures\n\n${context}`; } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, 'AGENTS.md'); } /** * Write signatures into AGENTS.md using append-under-marker. * If marker exists, content above marker is preserved. * If legacy generated content exists without marker, replace it cleanly. * @param {string} context - Raw signature context string * @param {string} cwd - Project root * @param {object} [opts] */ function write(context, cwd, opts = {}) { const filePath = outputPath(cwd); const placed = place(context, cwd, opts); if (placed.warning) console.warn(`[sigmap] ${path.relative(cwd, filePath)}: ${placed.warning}`); fs.writeFileSync(filePath, placed.content, 'utf8'); } /** * What AGENTS.md would contain after `write`, without writing it: the human lines * above the managed section, kept, and the formatted block below them. Lets a caller * measure the finished file against a host's limit (#995). * @param {string} context - Raw signature context string * @param {string} cwd - Project root * @param {object} [opts] * @returns {{ content: string, warning?: string }} */ function place(context, cwd, opts = {}) { const filePath = outputPath(cwd); let existing = ''; if (fs.existsSync(filePath)) { existing = fs.readFileSync(filePath, 'utf8'); } const formatted = format(context, opts); // A mention of the marker in prose or a code block is never the marker, and // only a file that is ENTIRELY generated is replaced whole (#873). return replaceManagedSection(existing, formatted, { legacy: true }); } module.exports = { name, format, outputPath, write, place }; }; // ── ./packages/adapters/copilot ── __factories["./packages/adapters/copilot"] = function(module, exports) { /** * Copilot adapter — writes to .github/copilot-instructions.md * GitHub Copilot reads this file automatically in every workspace. * * Contract: * format(context, opts?) → string * outputPath(cwd) → string */ const path = require('path'); const fs = require('fs'); const { replaceManagedSection } = __require('./src/util/managed-section'); const name = 'copilot'; /** * Format context for GitHub Copilot instructions. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): the stamp trails the content, not heads it. const stampAtTail = opts.cacheLayout !== 'legacy'; const header = [ ``, ...(stampAtTail ? [] : [``]), meta, ``, '', '# Code signatures', '', ].join('\n'); return header + context + (stampAtTail ? `\n` : ''); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); // Host-toolchain label (#609): byte-stability holds per toolchain version. if (opts.toolchain) parts.push(`toolchain=${opts.toolchain}`); return ``; } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, '.github', 'copilot-instructions.md'); } /** * Write signatures into copilot-instructions.md using append-under-marker. * If marker exists, content above marker is preserved. * If legacy generated content exists without marker, replace it cleanly. * @param {string} context - Raw signature context string * @param {string} cwd - Project root * @param {object} [opts] */ function write(context, cwd, opts = {}) { const filePath = outputPath(cwd); let existing = ''; if (fs.existsSync(filePath)) { existing = fs.readFileSync(filePath, 'utf8'); } const formatted = format(context, opts); // A mention of the marker in prose or a code block is never the marker, and // only a file that is ENTIRELY generated is replaced whole (#873). const placed = replaceManagedSection(existing, formatted, { legacy: true }); if (placed.warning) console.warn(`[sigmap] ${path.relative(cwd, filePath)}: ${placed.warning}`); const newContent = placed.content; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, newContent, 'utf8'); } module.exports = { name, format, outputPath, write }; }; // ── ./packages/adapters/cursor ── __factories["./packages/adapters/cursor"] = function(module, exports) { /** * Cursor adapter — writes to .cursorrules * Cursor reads .cursorrules automatically in every workspace. * * Contract: * format(context, opts?) → string * outputPath(cwd) → string */ const path = require('path'); const name = 'cursor'; /** * Format context for Cursor rules file. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): the stamp trails the content, not heads it. const stampAtTail = opts.cacheLayout !== 'legacy'; const header = [ `# Code signatures — generated by SigMap v${version}`, ...(stampAtTail ? [] : [`# Updated: ${timestamp}`]), `# ${meta}`, `# Regenerate: node gen-context.js`, '', ].join('\n'); return header + context + (stampAtTail ? `\n# Updated: ${timestamp}` : ''); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); return `sigmap: ${parts.join(' ')}`; } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, '.cursorrules'); } module.exports = { name, format, outputPath }; }; // ── ./packages/adapters/gemini ── __factories["./packages/adapters/gemini"] = function(module, exports) { /** * Gemini adapter — formats context as a Gemini system instruction. * Use the output as the `system_instruction` field in a Gemini API request. * * Example usage: * const { format } = require('sigmap/adapters/gemini'); * const instruction = format(context); * // Pass to: genAI.getGenerativeModel({ model: 'gemini-pro', systemInstruction: instruction }) * * Contract: * format(context, opts?) → string * outputPath(cwd) → string */ const path = require('path'); const fs = require('fs'); const { replaceManagedSection } = __require('./src/util/managed-section'); const name = 'gemini'; /** * Format context as a Gemini system instruction. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @param {string} [opts.projectName] - Optional project name * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const projectLine = opts.projectName ? `Project: ${opts.projectName}\n` : ''; const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): the date trails the content, not heads it. const stampAtTail = opts.cacheLayout !== 'legacy'; return [ `You are a coding assistant with complete knowledge of this codebase.`, stampAtTail ? `The following code signatures were extracted by SigMap v${version}.` : `The following code signatures were extracted by SigMap v${version} on ${timestamp}.`, ``, projectLine, `These signatures represent every public function, class, and type in the project.`, `Refer to them when answering questions about code structure, APIs, and implementation.`, `## Code Signatures`, ``, context, ...(stampAtTail ? [``] : []), ].join('\n'); } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, '.github', 'gemini-context.md'); } /** * Write signatures into gemini-context.md using append-under-marker. * If marker exists, content above marker is preserved. * If legacy generated content exists without marker, replace it cleanly. * @param {string} context - Raw signature context string * @param {string} cwd - Project root * @param {object} [opts] */ function write(context, cwd, opts = {}) { const filePath = outputPath(cwd); let existing = ''; if (fs.existsSync(filePath)) { existing = fs.readFileSync(filePath, 'utf8'); } const formatted = format(context, opts); // A mention of the marker in prose or a code block is never the marker, and // only a file that is ENTIRELY generated is replaced whole (#873). const placed = replaceManagedSection(existing, formatted, { legacy: true }); if (placed.warning) console.warn(`[sigmap] ${path.relative(cwd, filePath)}: ${placed.warning}`); const newContent = placed.content; fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, newContent, 'utf8'); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); return `sigmap: ${parts.join(' ')}`; } module.exports = { name, format, outputPath, write }; }; // ── ./packages/adapters/index ── __factories["./packages/adapters/index"] = function(module, exports) { /** * packages/adapters/index.js * Central registry for all SigMap output adapters. * * Usage: * const { getAdapter, listAdapters, adapt } = require('sigmap/adapters'); * const output = adapt(context, 'copilot', { version: '3.0.0' }); */ const path = require('path'); // Third-party adapters: 'willow' writes atoms to a Willow MCP knowledge store // instead of a flat file (see willow.js for configuration). const ADAPTER_NAMES = ['copilot', 'claude', 'cursor', 'windsurf', 'openai', 'gemini', 'codex', 'willow']; // Lazy-load adapters so unused ones don't pay any require() cost const _cache = {}; /** * Load and return an adapter module by name. * @param {string} name - Adapter name (copilot|claude|cursor|windsurf|openai|gemini|codex|willow) * @returns {{ name: string, format: Function, outputPath: Function }|null} */ function getAdapter(name) { if (!name || typeof name !== 'string') return null; const key = name.toLowerCase(); if (!ADAPTER_NAMES.includes(key)) return null; if (!_cache[key]) { try { _cache[key] = require(path.join(__dirname, key + '.js')); } catch (_) { return null; } } return _cache[key]; } /** * List all available adapter names. * @returns {string[]} */ function listAdapters() { return ADAPTER_NAMES.slice(); } /** * Format context using the named adapter. * @param {string} context - Raw signature context string * @param {string} adapterName - Adapter name * @param {object} [opts] - Options passed to adapter.format() * @returns {string} Formatted output string (empty string if adapter not found or context empty) */ function adapt(context, adapterName, opts = {}) { if (!context || typeof context !== 'string') return ''; const adapter = getAdapter(adapterName); if (!adapter || typeof adapter.format !== 'function') return ''; try { return adapter.format(context, opts); } catch (_) { return ''; } } /** * Map old `outputs` config values to new `adapters` names. * Provides backward compatibility for existing configurations. * @param {string[]} outputs - Legacy outputs array * @returns {string[]} Equivalent adapters array */ function outputsToAdapters(outputs) { if (!Array.isArray(outputs)) return ['copilot']; return outputs.map((o) => { // All current output names already match adapter names if (ADAPTER_NAMES.includes(o)) return o; return o; // pass through unknowns — getAdapter() will handle gracefully }); } module.exports = { getAdapter, listAdapters, adapt, outputsToAdapters }; }; // ── ./packages/adapters/llm-full ── __factories["./packages/adapters/llm-full"] = function(module, exports) { const path = require('path'); const fs = require('fs'); module.exports = { name: 'llm-full', format, outputPath, write }; function outputPath(cwd) { return path.join(cwd, 'llm-full.txt'); } function format(context, opts) { opts = opts || {}; // Stable-prefix layout (#683): the stamp trails the entries, not heads them. const stampAtTail = opts.cacheLayout !== 'legacy'; const stamp = `Generated: ${new Date().toISOString()} | SigMap v${opts.version || ''}`; const lines = [ `# ${context.projectName || 'Project'} — SigMap Context`, ...(stampAtTail ? [] : [stamp]), '', ]; for (const entry of (context.fileEntries || [])) { const rel = path.relative(opts.cwd || '', entry.filePath); lines.push(`## ${rel}`, '```', ...(entry.sigs || []), '```', ''); } if (stampAtTail) lines.push(stamp); return lines.join('\n'); } function write(context, cwd, opts) { opts = opts || {}; fs.writeFileSync(outputPath(cwd), format(context, { ...opts, cwd })); } }; // ── ./packages/adapters/openai ── __factories["./packages/adapters/openai"] = function(module, exports) { /** * OpenAI adapter — formats context as an OpenAI system message. * Use the output as the `content` field of a system role message. * * Example usage in code: * const { format } = require('sigmap/adapters/openai'); * const systemPrompt = format(context); * // Pass to: openai.chat.completions.create({ messages: [{ role: 'system', content: systemPrompt }] }) * * Contract: * format(context, opts?) → string * outputPath(cwd) → string */ const path = require('path'); const name = 'openai'; /** * Format context as an OpenAI system prompt. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @param {string} [opts.projectName] - Optional project name * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const projectLine = opts.projectName ? `Project: ${opts.projectName}\n` : ''; const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): the date trails the content, not heads it. const stampAtTail = opts.cacheLayout !== 'legacy'; return [ `You are a coding assistant with full knowledge of this codebase.`, stampAtTail ? `Below are the code signatures extracted by SigMap v${version}.` : `Below are the code signatures extracted by SigMap v${version} on ${timestamp}.`, ``, projectLine, `Use these signatures to answer questions about the code accurately.`, `When the user asks about a specific file or function, refer to the signatures below.`, `## Code Signatures`, ``, context, ...(stampAtTail ? [``] : []), ].join('\n'); } /** * Return the output file path for this adapter. * Writes a .openai-context.md file that can be loaded at runtime. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, '.github', 'openai-context.md'); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); return `sigmap: ${parts.join(' ')}`; } module.exports = { name, format, outputPath }; }; // ── ./packages/adapters/willow ── __factories["./packages/adapters/willow"] = function(module, exports) { /** * Willow adapter — writes SigMap context to Willow MCP knowledge store. * * Instead of writing a flat .willow-context.md file, this adapter sends * signature atoms to a Willow MCP server (https://github.com/willow-memory/willow-mcp) * via HTTP JSON-RPC (`POST …/tools/call`). Each indexed file becomes one * `knowledge_ingest` atom. Requires an HTTP MCP listener (not Cursor stdio). * * Contract: * format(context, opts?) → string (markdown for display/debug) * outputPath(cwd) → string (placeholder — no file written) * write(context, cwd, opts?) → Promise (POSTs to Willow MCP, must await) * * Configuration (env vars or opts): * WILLOW_MCP_URL — MCP server base URL (default: http://localhost:8000) * WILLOW_AGENT — agent namespace (default: sigmap) * WILLOW_TIMEOUT — fetch timeout in ms (default: 30000) * WILLOW_MAX_ATOM_SIZE — max atom size in bytes (default: 100000) * WILLOW_RETRIES — max retry attempts for transient failures (default: 3) */ const crypto = require('crypto'); const name = 'willow'; const DEFAULT_MCP_URL = 'http://localhost:8000'; const DEFAULT_TIMEOUT_MS = 30000; const DEFAULT_MAX_ATOM_SIZE = 100000; const DEFAULT_RETRIES = 3; /** * Format SigMap context as markdown for display or debug. * @param {string} context - Raw SigMap context string * @param {object} [opts] - Unused; reserved for future options * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const ts = new Date().toISOString(); return `\n\n${context}`; } /** * Return the placeholder output path (no file is written by this adapter). * @param {string} cwd - Working directory * @returns {string} */ function outputPath(cwd) { return '.willow-context.md'; } /** * Generate a cryptographically strong ID for an atom. * Uses SHA256 hash of the filepath to ensure uniqueness and prevent collisions. * @param {string} filepath - File path to hash * @returns {string} - sigmap-{32-char-hex} */ function generateAtomId(filepath) { const hash = crypto .createHash('sha256') .update(filepath) .digest('hex'); return `sigmap-${hash}`; } /** * Fetch with timeout support. * @param {string} url - URL to fetch * @param {object} opts - Fetch options * @param {number} timeoutMs - Timeout in milliseconds * @returns {Promise} */ async function fetchWithTimeout(url, opts, timeoutMs) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...opts, signal: controller.signal }); } finally { clearTimeout(timeoutId); } } /** * POST an atom to Willow with exponential backoff retry. * @param {object} atom - Atom to ingest * @param {string} mcpUrl - MCP server URL * @param {number} timeoutMs - Fetch timeout * @param {number} maxRetries - Max retry attempts * @returns {Promise} - True if succeeded, false if all retries exhausted */ async function postAtomWithRetry(atom, mcpUrl, timeoutMs, maxRetries) { let lastErr; for (let attempt = 0; attempt < maxRetries; attempt++) { try { const resp = await fetchWithTimeout( `${mcpUrl}/tools/call`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'knowledge_ingest', arguments: { app_id: atom.agent, content: atom.content, domain: atom.domain, source: atom.id, tags: ['sigmap', 'code', atom.project].filter(Boolean), }, }), }, timeoutMs, ); if (resp.ok) { return true; } if (resp.status >= 500) { lastErr = new Error(`HTTP ${resp.status}`); if (attempt < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt))); continue; } } else { process.stderr.write(`[willow-adapter] ${atom.id}: HTTP ${resp.status} (not retryable)\n`); return false; } } catch (err) { lastErr = err; if (attempt < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt))); continue; } } } process.stderr.write( `[willow-adapter] ${atom.id}: failed after ${maxRetries} attempts: ${lastErr?.message || 'unknown'}\n`, ); return false; } /** * POST each file section from SigMap context to the Willow MCP knowledge store. * Each `## filepath` section becomes one searchable knowledge atom. * Failures are per-atom and logged to stderr; function never throws. * IMPORTANT: This is async — caller MUST await write() before process exit. * * @param {string} context - Raw SigMap context string * @param {string} cwd - Working directory (used as project label) * @param {object} [opts] - Optional overrides: { mcpUrl, agent, timeoutMs, maxAtomSize, maxRetries } * @returns {Promise} */ async function write(context, cwd, opts = {}) { if (!context) return; const mcpUrl = opts.mcpUrl || process.env.WILLOW_MCP_URL || DEFAULT_MCP_URL; const agent = opts.agent || process.env.WILLOW_AGENT || 'sigmap'; const timeoutMs = opts.timeoutMs || parseInt(process.env.WILLOW_TIMEOUT, 10) || DEFAULT_TIMEOUT_MS; const maxAtomSize = opts.maxAtomSize || parseInt(process.env.WILLOW_MAX_ATOM_SIZE, 10) || DEFAULT_MAX_ATOM_SIZE; const maxRetries = opts.maxRetries || parseInt(process.env.WILLOW_RETRIES, 10) || DEFAULT_RETRIES; const sections = context.split(/\n(?=##\s)/); const atoms = sections .map((section) => { const titleMatch = section.match(/^##\s+(.+)/); if (!titleMatch) return null; const title = titleMatch[1].trim(); const contentSize = section.length; if (contentSize > maxAtomSize) { process.stderr.write(`[willow-adapter] ${title}: oversized (${contentSize} > ${maxAtomSize} bytes)\n`); return null; } return { id: generateAtomId(title), title, summary: `${title} (${contentSize} bytes)`, content: section.trim(), domain: 'code', source_type: 'sigmap', agent, project: cwd ? require('path').basename(cwd) : 'unknown', }; }) .filter(Boolean); if (!atoms.length) return; await Promise.all( atoms.map((atom) => postAtomWithRetry(atom, mcpUrl, timeoutMs, maxRetries).catch((err) => { process.stderr.write(`[willow-adapter] ${atom.id}: unexpected error: ${err.message}\n`); })), ); } module.exports = { name, format, outputPath, write }; }; // ── ./packages/adapters/windsurf ── __factories["./packages/adapters/windsurf"] = function(module, exports) { /** * Windsurf adapter — writes to .windsurfrules * Windsurf reads .windsurfrules automatically in every workspace. * * Contract: * format(context, opts?) → string * outputPath(cwd) → string */ const path = require('path'); const name = 'windsurf'; /** * Format context for Windsurf rules file. * @param {string} context - Raw signature context string * @param {object} [opts] * @param {string} [opts.version] - SigMap version string * @returns {string} */ function format(context, opts = {}) { if (!context || typeof context !== 'string') return ''; const version = opts.version || 'unknown'; const timestamp = new Date().toISOString(); const meta = _confidenceMeta(opts); // Stable-prefix layout (#683): the stamp trails the content, not heads it. const stampAtTail = opts.cacheLayout !== 'legacy'; const header = [ `# Code signatures — generated by SigMap v${version}`, ...(stampAtTail ? [] : [`# Updated: ${timestamp}`]), `# ${meta}`, `# Regenerate: node gen-context.js`, '', ].join('\n'); return header + context + (stampAtTail ? `\n# Updated: ${timestamp}` : ''); } function _confidenceMeta(opts) { const parts = [`version=${opts.version || 'unknown'}`]; if (opts.confidence) parts.push(`confidence=${opts.confidence}`); if (opts.coverage != null) parts.push(`coverage=${opts.coverage}%`); if (opts.dropped != null) parts.push(`dropped=${opts.dropped}`); if (opts.commit) parts.push(`commit=${opts.commit}`); return `sigmap: ${parts.join(' ')}`; } /** * Return the output file path for this adapter. * @param {string} cwd - Project root * @returns {string} */ function outputPath(cwd) { return path.join(cwd, '.windsurfrules'); } module.exports = { name, format, outputPath }; }; // ── ./src/analysis/coverage-score ── __factories["./src/analysis/coverage-score"] = function(module, exports) { /** * SigMap coverage scorer — v5.5.0 * * Measures what fraction of *code* files made it into the context output * after token-budget application. Non-code files (json, md, config) are * counted separately as `nonCodeSkipped` so the grade reflects real coverage. * * - Health score = context freshness / reduction quality / budget compliance * - Coverage score = how much of the codebase is represented in context * * Grade scale: A ≥ 90% | B ≥ 75% | C ≥ 50% | D < 50% * * @param {string} cwd * @param {Array<{filePath:string}>} fileEntries - files that made it into output * @param {{srcDirs:string[], exclude:string[]}} config * @returns {{ * score: number, * grade: 'A'|'B'|'C'|'D', * total: number, * included: number, * dropped: number, * nonCodeSkipped: number, * confidence: 'HIGH'|'MEDIUM'|'LOW', * perModule: Map, * }} */ const dirListing = __require('./src/util/dir-listing'); const CODE_EXTS = new Set([ '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.py', '.rb', '.go', '.rs', '.java', '.kt', '.cs', '.cpp', '.c', '.h', '.hpp', '.m', '.mm', '.swift', '.dart', '.scala', '.php', '.vue', '.svelte', '.css', '.scss', '.sql', '.graphql', '.proto', '.tf', '.lua', '.r', '.jl', '.ex', '.exs', '.sh', '.bash', '.zsh', '.ps1', '.psm1', '.psd1', ]); /** * The one coverage ratio (#848). * * `validate` kept its own `Math.round((covered / total) * 100)`, which agreed * with this module on every populated repo and disagreed on an empty one: the * hand-rolled version read 0% where every other surface read 100%. An empty * in-scope set has nothing left uncovered, so 100 is the convention — and it is * now stated in exactly one place. * * @param {number} included * @param {number} total * @returns {number} whole percent */ function coveragePct(included, total) { return total > 0 ? Math.round((included / total) * 100) : 100; } function coverageScore(cwd, fileEntries, config) { const fs = require('fs'); const path = require('path'); const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length > 0) ? config.srcDirs : ['src', 'app', 'lib']; const excludeSet = new Set([ 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__', '.next', 'coverage', 'target', 'vendor', '.context', ]); if (config && Array.isArray(config.exclude)) { for (const x of config.exclude) excludeSet.add(String(x)); } const includedSet = new Set((fileEntries || []).map(f => f.filePath)); // Walk srcDirs: separate code files from non-code files. srcDirs can overlap — // `--monorepo` hands every package ['src', 'lib', 'app', '.'], and `.` holds the // others — so a file is counted once, however many roots reach it (#918): one // file read "2 of 2 source files included". const allFiles = []; const allSource = []; const walked = new Set(); for (const relDir of srcDirs) { const absDir = path.resolve(cwd, relDir); if (!fs.existsSync(absDir)) continue; const found = []; _walk(absDir, excludeSet, found); for (const f of found) if (!walked.has(f)) { walked.add(f); allFiles.push(f); } } for (const f of allFiles) { if (CODE_EXTS.has(path.extname(f).toLowerCase())) allSource.push(f); } const nonCodeSkipped = allFiles.length - allSource.length; const total = allSource.length; const included = allSource.filter(f => includedSet.has(f)).length; const dropped = total - included; const pct = coveragePct(included, total); const grade = pct >= 90 ? 'A' : pct >= 75 ? 'B' : pct >= 50 ? 'C' : 'D'; const confidence = pct >= 90 ? 'HIGH' : pct >= 70 ? 'MEDIUM' : 'LOW'; // Per-module breakdown (one entry per srcDir) const perModule = new Map(); for (const relDir of srcDirs) { const absDir = path.resolve(cwd, relDir); const modFiles = allSource.filter(f => f.startsWith(absDir + path.sep) || f === absDir); const modIncl = modFiles.filter(f => includedSet.has(f)).length; const modPct = coveragePct(modIncl, modFiles.length); perModule.set(relDir, { total: modFiles.length, included: modIncl, pct: modPct }); } return { score: pct, grade, total, included, dropped, nonCodeSkipped, confidence, perModule }; } /** * Source files that exist in the repo but fall OUTSIDE every configured * srcDir (#805). * * `coverageScore` walks srcDirs only, so its denominator cannot see a file the * detector never selected — which is how a flat Go layout reported a plausible * "indexed 67% (2/3 files)" while ten of thirteen source files were invisible. * The number was not wrong about its own population; nothing told the user the * population was wrong. * * Deliberately a SEPARATE primitive rather than a new denominator inside * `coverageScore`: the named populations (#762) are pinned, and the fix here is * to disclose what is missing, not to redefine coverage. * * @param {string} cwd * @param {{srcDirs:string[], exclude:string[], maxDepth?:number}} config * Counts IMPLEMENTATION files only — tests, docs, CI, mocks and the * conventional tooling directories are legitimately outside srcDirs and are * reported separately as `skipped`. * * @returns {{ total: number, byExt: Array<{ext: string, count: number}>, * dirs: string[], skipped: number, inScope: number, share: number }} */ function outsideSrcDirs(cwd, config) { const fs = require('fs'); const path = require('path'); const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length > 0) ? config.srcDirs : ['src', 'app', 'lib']; // A srcDir of '.' covers the whole tree, so nothing can be outside it. if (srcDirs.some((d) => d === '.' || d === './')) return { total: 0, byExt: [], dirs: [], skipped: 0, inScope: 0, share: 0 }; const excludeSet = new Set([ 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__', '.next', 'coverage', 'target', 'vendor', '.context', ]); if (config && Array.isArray(config.exclude)) { for (const x of config.exclude) excludeSet.add(String(x)); } const srcAbs = srcDirs.map((d) => path.resolve(cwd, d)); const inSrc = (f) => srcAbs.some((a) => f === a || f.startsWith(a + path.sep)); const all = []; _walkOwned(cwd, excludeSet, all, 0); let inScope = 0; for (const a of srcAbs) { const found = []; try { if (fs.existsSync(a)) _walkOwned(a, excludeSet, found, 0); } catch (_) {} for (const f of found) if (CODE_EXTS.has(path.extname(f).toLowerCase())) inScope++; } // Only IMPLEMENTATION counts. Tests, docs, CI, mocks and the conventional // tooling directories are routinely and correctly outside srcDirs — counting // them turned this into a 297-file warning on SigMap's own repo, where the // real answer is the two root-level entrypoints. A check that cries wolf on a // correct configuration teaches the user to ignore it. let cls = null; try { cls = __require('./src/util/file-class'); } catch (_) {} const OUT_OF_SCOPE_DIRS = new Set([ 'test', 'tests', 'spec', 'specs', '__tests__', 'e2e', 'docs', 'doc', 'documentation', 'examples', 'example', 'samples', 'scripts', 'benchmarks', 'fixtures', 'mocks', '__mocks__', 'testdata', 'demo', 'demos', 'tools', 'migrations', ]); const byExt = new Map(); const dirs = new Set(); let total = 0; let skipped = 0; for (const f of all) { const ext = path.extname(f).toLowerCase(); if (!CODE_EXTS.has(ext)) continue; if (inSrc(f)) continue; const rel = path.relative(cwd, f).split(path.sep).join('/'); const topDir = rel.includes('/') ? rel.split('/')[0] : '.'; if (OUT_OF_SCOPE_DIRS.has(topDir)) { skipped++; continue; } if (cls && (cls.isTestFile(rel) || cls.isDocsFile(rel) || cls.isCiFile(rel) || cls.isMockFile(rel))) { skipped++; continue; } total++; byExt.set(ext, (byExt.get(ext) || 0) + 1); dirs.add(topDir); } return { total, byExt: [...byExt.entries()] .map(([ext, count]) => ({ ext, count })) .sort((a, b) => b.count - a.count || a.ext.localeCompare(b.ext)), dirs: [...dirs].sort(), skipped, inScope, // Share of the implementation that srcDirs is missing. A handful of root // entrypoints outside srcDirs is normal; a majority of the codebase is the // #805 failure. Callers grade on this rather than on the raw count. share: inScope + total > 0 ? total / (inScope + total) : 0, }; } /** * How much of the REPOSITORY the source roots cover: the one figure `generate`, `validate` and * `doctor` all print (#997). * * Each of them used to grade a population of its own, and every one of those denominators was * the detector's own scope. A run that indexed 10 of a repository's 309 files printed "100%" * (the 10 it had picked), and the run that indexed more read LOWER, because the grade divided by * what fits the context file. The denominator here is the repository: the code files under the * roots (tests inside them included) plus the implementation files outside them (tests, docs, CI, * mocks and the conventional tooling directories legitimately sit outside, and are not counted * against it; see `outsideSrcDirs`). * * @param {string} cwd * @param {{srcDirs:string[], exclude:string[]}} config * @returns {{ covered: number, outside: number, all: number, pct: number, dirs: string[], byDir: Array<{dir:string,count:number}> }} */ function repositoryCoverage(cwd, config) { const fs = require('fs'); const path = require('path'); cwd = path.resolve(cwd); // the walks compare absolute paths: a relative cwd made every file look outside const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length > 0) ? config.srcDirs : []; // A root of '.' is the whole repository: everything is covered, whatever it is called. if (srcDirs.some((d) => d === '.' || d === './')) { const excludeSet = new Set(['node_modules', '.git', 'dist', 'build', 'out', '__pycache__', '.next', 'coverage', 'target', 'vendor', '.context']); if (config && Array.isArray(config.exclude)) for (const x of config.exclude) excludeSet.add(String(x)); const all = []; _walkOwned(path.resolve(cwd), excludeSet, all, 0); const n = all.filter((f) => CODE_EXTS.has(path.extname(f).toLowerCase())).length; return { covered: n, outside: 0, all: n, pct: coveragePct(n, n), dirs: [], byDir: [] }; } const out = outsideSrcDirs(cwd, config); const all = out.inScope + out.total; // The outside implementation files by top-level directory, most first: what the warning names. const byDir = new Map(); if (out.total > 0) { const excludeSet = new Set(['node_modules', '.git', 'dist', 'build', 'out', '__pycache__', '.next', 'coverage', 'target', 'vendor', '.context']); if (config && Array.isArray(config.exclude)) for (const x of config.exclude) excludeSet.add(String(x)); const files = []; _walkOwned(path.resolve(cwd), excludeSet, files, 0); const srcAbs = srcDirs.map((d) => path.resolve(cwd, d)); let cls = null; try { cls = __require('./src/util/file-class'); } catch (_) { /* optional */ } const OUT = new Set(['test', 'tests', 'spec', 'specs', '__tests__', 'e2e', 'docs', 'doc', 'documentation', 'examples', 'example', 'samples', 'scripts', 'benchmarks', 'fixtures', 'mocks', '__mocks__', 'testdata', 'demo', 'demos', 'tools', 'migrations']); for (const file of files) { if (!CODE_EXTS.has(path.extname(file).toLowerCase())) continue; if (srcAbs.some((a) => file === a || file.startsWith(a + path.sep))) continue; const rel = path.relative(cwd, file).split(path.sep).join('/'); const top = rel.includes('/') ? rel.split('/')[0] : '.'; if (OUT.has(top)) continue; if (cls && (cls.isTestFile(rel) || cls.isDocsFile(rel) || cls.isCiFile(rel) || cls.isMockFile(rel))) continue; byDir.set(top, (byDir.get(top) || 0) + 1); } } const ranked = [...byDir.entries()].map(([dir, count]) => ({ dir, count })).sort((a, b) => b.count - a.count || a.dir.localeCompare(b.dir)); return { covered: out.inScope, outside: out.total, all, pct: coveragePct(out.inScope, all), dirs: ranked.map((r) => r.dir), byDir: ranked }; } /** The words every surface uses for the repository figure. */ function formatRepositoryCoverage(rc) { const n = (x) => x.toLocaleString('en-US'); return `${rc.pct}% of the repository's code (${n(rc.covered)} of ${n(rc.all)} files) is under the source roots`; } /** * Depth-bounded walk that stops at NESTED REPOSITORIES. * * A vendored or cloned repo is not this repo's source, and walking it is both * wrong and expensive: on SigMap's own tree `benchmarks/repos/` holds 43 cloned * repositories — 47,327 of the 53,013 files under cwd — so an unbounded walk * spent its entire time in directories whose files could never be the user's. * A `.git` entry is the marker, which also covers submodules and any vendored * checkout the exclude list does not happen to name. */ const OWNED_WALK_MAX_DEPTH = 10; function _walkOwned(dir, excludeSet, out, depth) { const fs = require('fs'); const path = require('path'); if (depth > OWNED_WALK_MAX_DEPTH) return; let entries; try { entries = dirListing.list(dir); } catch (_) { return; } if (depth > 0 && entries.some((e) => e.name === '.git')) return; // a repo of its own for (const e of entries) { if (excludeSet.has(e.name)) continue; const full = path.join(dir, e.name); if (e.isDirectory()) { _walkOwned(full, excludeSet, out, depth + 1); } else if (e.isFile()) { out.push(full); } } } function _walk(dir, excludeSet, out) { const fs = require('fs'); const path = require('path'); let entries; try { entries = dirListing.list(dir); } catch (_) { return; } for (const e of entries) { if (excludeSet.has(e.name)) continue; const full = path.join(dir, e.name); if (e.isDirectory()) { _walk(full, excludeSet, out); } else if (e.isFile()) { out.push(full); } } } /** * Named populations (#762). * * Four surfaces printed a coverage percentage for one repo and no two agreed: * `validate` 98% (175/179), `doctor` 100%, `--health` 100% (170/170) and * `--report` 54% (91/170). They were not in conflict — they were measuring * DIFFERENT populations through the same primitive, and none of them said * which. `doctor` was the sharpest case: it fed `coverageScore` the retrieval * index and then printed "of source files in context", contradicting the very * run that built that context. * * Differing numbers are fine. Unlabelled ones are not. Every surface now names * its population, so a reader can tell at a glance that 54% and 100% are * answers to two different questions. */ const POPULATIONS = { // Survived the token budget — what the agent actually sees. 'in-context': { label: 'in-context', noun: 'scoped source files' }, // Present in the retrieval index, including anything stored outside the // budgeted context file (sig-index, per-module splits, cold store). indexed: { label: 'indexed', noun: 'files' }, // Readable on disk under srcDirs — an access check, not a coverage claim. readable: { label: 'readable', noun: 'files in srcDirs' }, }; /** * Render a coverage figure with its population, numerator and denominator. * `indexed 98% (175/179 files)` — never a bare percentage. */ function formatCoverage(cov, population, opts = {}) { const pop = POPULATIONS[population]; if (!pop) throw new Error(`unknown coverage population: ${population}`); const included = opts.included != null ? opts.included : cov.included; const total = opts.total != null ? opts.total : cov.total; const grade = opts.grade === false ? '' : ` grade ${cov.grade}`; return `${pop.label} ${cov.score}% (${included}/${total} ${pop.noun})${grade}`; } /** * The `indexed` population: how much of the in-scope file list the retrieval * index actually holds (#848). * * The naive form — `index.size / fileList.length` — is not a coverage ratio at * all. The persisted index deliberately holds more than the current config * scopes (declared entrypoints, test roots, CI definitions, files a srcDir * change dropped), so the quotient runs past 100%: `validate` reported 218% * before #770 replaced it with the INTERSECTION, and `--ci` was still gating * releases on 241% until this became a shared function instead of a formula * each surface kept its own copy of. * * @param {Set|Iterable} indexedRel - index keys, repo-relative * @param {Iterable} inScopeRel - the in-scope file list, repo-relative * @returns {{score:number, included:number, total:number, grade:string}} */ function indexedCoverage(indexedRel, inScopeRel) { const indexed = indexedRel instanceof Set ? indexedRel : new Set(indexedRel); let included = 0; let total = 0; for (const f of inScopeRel) { total++; if (indexed.has(f)) included++; } const score = coveragePct(included, total); return { score, included, total, grade: score >= 90 ? 'A' : score >= 75 ? 'B' : score >= 50 ? 'C' : 'D' }; } /** * The files actually present in the generated context file — the `in-context` * population. Parsed from the `### ` section headings rather than the * retrieval index, because the index deliberately holds more than the budget * admitted, and conflating the two is what produced the contradiction above. */ function inContextFiles(cwd) { const fs = require('fs'); const path = require('path'); const candidates = [ path.join(cwd, '.github', 'copilot-instructions.md'), path.join(cwd, 'CLAUDE.md'), path.join(cwd, 'AGENTS.md'), path.join(cwd, 'GEMINI.md'), ]; const out = new Set(); for (const file of candidates) { let content; try { content = fs.readFileSync(file, 'utf8'); } catch (_) { continue; } const section = __require('./src/util/managed-section').findManagedSection(content); if (section) content = content.slice(section.index); for (const m of content.matchAll(/^### (.+)$/gm)) { const rel = m[1].trim(); if (!rel || rel.includes(' ')) continue; out.add(path.resolve(cwd, rel)); } if (out.size) break; // first adapter output that carries sections wins } return [...out].map((filePath) => ({ filePath })); } module.exports = { coverageScore, coveragePct, indexedCoverage, formatCoverage, inContextFiles, outsideSrcDirs, repositoryCoverage, formatRepositoryCoverage, POPULATIONS, CODE_EXTS }; }; // ── ./src/analysis/diagnostics ── __factories["./src/analysis/diagnostics"] = function(module, exports) { /** * Diagnostic utilities for understanding file inclusion/exclusion decisions. * * Provides detailed per-file diagnostics showing: * - Why files are included or excluded * - Ranking scores and signals * - Budget constraints and priorities */ const path = require('path'); function estimateTokens(text) { return Math.ceil(text.length / 4); } function formatFileDecision(entry, decision, reason, score = null) { const rel = path.relative(process.cwd(), entry.filePath); const tokens = estimateTokens(entry.sigs.join('\n')); let line = `${decision === 'included' ? '✓' : '✗'} ${rel}`; line += ` [${tokens} tokens]`; if (score !== null) line += ` [score: ${score.toFixed(2)}]`; if (reason) line += ` — ${reason}`; return line; } function computeFileMetrics(entry) { const loc = typeof entry.loc === 'number' ? entry.loc : (entry.content ? entry.content.split('\n').length : 1); const sigCount = entry.sigs ? entry.sigs.length : 0; const signalQuality = loc > 0 ? sigCount / loc : 0; const tokens = estimateTokens(entry.sigs.join('\n')); return { lineOfCode: loc, sigCount: sigCount, signalQuality: signalQuality.toFixed(3), tokens: tokens, relevance: (sigCount / Math.max(loc, 1)).toFixed(3), }; } function explainInclusion(fileEntries, budgetLimit) { const lines = []; lines.push('## File Inclusion Diagnostics\n'); lines.push(`Budget: ${budgetLimit} tokens`); lines.push(`Files: ${fileEntries.length} scanned\n`); let totalTokens = 0; const withMetrics = fileEntries.map((e) => { const metrics = computeFileMetrics(e); totalTokens += metrics.tokens; return { entry: e, metrics }; }); lines.push(`Total token requirement: ${totalTokens} tokens`); lines.push(`Budget headroom: ${budgetLimit * 0.9} tokens (90% of ${budgetLimit})\n`); if (totalTokens > budgetLimit * 0.9) { lines.push('⚠ Over budget — files will be dropped\n'); lines.push('### Per-file metrics:'); for (const { entry, metrics } of withMetrics) { const rel = path.relative(process.cwd(), entry.filePath); lines.push(`- ${rel}`); lines.push(` - Size: ${metrics.tokens} tokens, ${metrics.lineOfCode} lines`); lines.push(` - Sigs: ${metrics.sigCount}, quality: ${metrics.signalQuality}`); } } else { lines.push('✓ All files fit within budget\n'); } return lines.join('\n'); } function explainExclusion(dropped, reason) { return `Excluded ${dropped.length} files: ${reason}`; } module.exports = { formatFileDecision, computeFileMetrics, explainInclusion, explainExclusion, estimateTokens, }; }; // ── ./src/analysis/index-state ── __factories["./src/analysis/index-state"] = function(module, exports) { /** * One definition of what the signature index holds, and of how old it is. * * WHY THIS EXISTS * --------------- * `generate` writes the index over an AUGMENTED population: the configured * `srcDirs` walk, widened by the declared package entrypoints, every test root * and every CI definition — all three deliberate, all three shipped so * `sigmap ask` can reach code that lives outside `srcDirs` by construction. * * `validate` and `doctor` measured that same index against the UN-widened * `srcDirs` list, so on this repo 266 perfectly good entries (256 under * `test/`, 10 under `.github/`) read as "stale index entries … re-run sigmap", * which fixed nothing because nothing was broken. Meanwhile `doctor` counted * the same 266 as indexed coverage and called the index fresh, and `status` * — reading only the usage log — said the index had never been built. * * Three private definitions of one population and one timestamp. This module * owns both, so the four surfaces can only agree. It classifies entries the * index already holds rather than re-walking the tree: the fix is a population * widening, not an index prune, and index size must not move because of it. * * Zero-dependency, bundle-safe (fs + path only). */ const fs = require('fs'); const path = require('path'); /** * Directory roots whose contents `generate` indexes as tests. Imported by * `collectTestEntries` so the collector and the classifier cannot drift. */ const TEST_ROOTS = ['test', 'tests', '__tests__', 'spec', 'e2e']; /** * The test-root directories of a repo, spelled as they are ON DISK. * * `TEST_ROOTS` is lowercase, and `path.join(cwd, 'tests')` resolves a SwiftPM * `Tests/` on a case-insensitive filesystem (macOS) but not on a case-sensitive * one (Linux): the same checkout was indexed under the lowercased key * `tests/...` on one and not at all on the other (#893 §5). Matching the * directory NAME case-insensitively and keeping the on-disk spelling gives * one population and one key everywhere. * * @param {string} cwd * @returns {string[]} root-level directory names, `TEST_ROOTS` order first */ function testRootDirs(cwd) { let names; try { names = fs.readdirSync(cwd); } catch (_) { return []; } const out = []; for (const root of TEST_ROOTS) { for (const name of names.filter((n) => n.toLowerCase() === root).sort()) { let isDir = false; try { isDir = fs.statSync(path.join(cwd, name)).isDirectory(); } catch (_) { /* dangling link */ } if (isDir) out.push(name); } } return out; } /** * Directories holding CI / pipeline definitions. `.` covers the single-file * forms (.gitlab-ci.yml, Jenkinsfile, compose files, …). Imported by * `collectPipelineEntries` for the same reason as TEST_ROOTS. */ const CI_DIRS = [ '.', '.github/workflows', '.gitea/workflows', '.forgejo/workflows', '.circleci', '.woodpecker', ]; /** The index artifact `generate` writes before the token budget is applied. */ const INDEX_REL = '.context/sig-index.json'; /** Generated context files, in the order the resolvers prefer them. */ const ADAPTER_OUTPUTS = [ ['.github', 'copilot-instructions.md'], ['CLAUDE.md'], ['AGENTS.md'], ['.cursorrules'], ['.windsurfrules'], ['.github', 'openai-context.md'], ['.github', 'gemini-context.md'], ['llm-full.txt'], ['llm.txt'], ]; const EXCLUDE_DIRS = new Set([ 'node_modules', '.git', 'dist', 'build', 'out', '__pycache__', '.next', 'coverage', 'target', 'vendor', '.context', ]); function _norm(p) { return String(p || '').replace(/\\/g, '/').replace(/^\.\//, ''); } /** * Source files a project declares as its own entrypoints in package.json * (`main` and every `bin` target), repo-relative. * * @param {string} cwd * @returns {Set} */ function declaredEntrypointPaths(cwd) { const out = new Set(); try { const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); const refs = []; if (typeof pkg.main === 'string') refs.push(pkg.main); if (typeof pkg.bin === 'string') refs.push(pkg.bin); else if (pkg.bin && typeof pkg.bin === 'object') { refs.push(...Object.values(pkg.bin).filter((v) => typeof v === 'string')); } for (const ref of refs) { const rel = _norm(path.relative(cwd, path.resolve(cwd, ref))); if (rel && !rel.startsWith('..')) out.add(rel); } } catch (_) { /* no package.json → nothing declared */ } return out; } /** * Why a repo-relative path is in the index despite falling outside `srcDirs`, * or null when nothing justifies it. * * @param {string} rel - repo-relative, forward slashes * @param {{ entrypoints?: Set }} [ctx] * @returns {'test'|'ci'|'entrypoint'|null} */ function augmentedReason(rel, ctx = {}) { const r = _norm(rel); if (!r) return null; if (ctx.entrypoints && ctx.entrypoints.has(r)) return 'entrypoint'; const first = r.split('/')[0]; if (r.includes('/') && TEST_ROOTS.includes(first.toLowerCase())) return 'test'; const dir = r.includes('/') ? r.slice(0, r.lastIndexOf('/')) : '.'; if (CI_DIRS.includes(dir)) { // The extractor's own routing decides what counts as a pipeline file, so // this cannot drift from `langFor`. A root-level file only qualifies when // the extractor claims it — `.` would otherwise match the whole repo root. let platformFor = null; try { ({ platformFor } = __require('./src/extractors/pipeline')); } catch (_) {} if (platformFor && platformFor(r)) return 'ci'; } return null; } /** * Split the entries an index holds into the four classes that have different * remedies. * * `inScope` and `augmented` are both legitimate — together they are the * population `generate` writes. `missing` and `outOfScope` are the two real * stale classes, separated because a deleted file is cleared by re-running * `sigmap` while a file that merely left `srcDirs` is a config question. * * @param {string} cwd * @param {Iterable} indexedRel - repo-relative keys of the index * @param {{srcDirs?: string[]}} config * @returns {{ inScope: string[], augmented: string[], missing: string[], * outOfScope: string[], stale: number, byReason: object }} */ function classifyIndexEntries(cwd, indexedRel, config) { const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length) ? config.srcDirs.map((d) => _norm(d).replace(/\/+$/, '')) : ['src', 'app', 'lib']; const entrypoints = declaredEntrypointPaths(cwd); const inScope = []; const augmented = []; const missing = []; const outOfScope = []; const byReason = { test: 0, ci: 0, entrypoint: 0 }; for (const raw of indexedRel || []) { const rel = _norm(raw); if (!rel) continue; let exists = true; try { exists = fs.statSync(path.join(cwd, rel)).isFile(); } catch (_) { exists = false; } if (!exists) { missing.push(rel); continue; } if (srcDirs.some((d) => rel === d || rel.startsWith(`${d}/`))) { inScope.push(rel); continue; } const reason = augmentedReason(rel, { entrypoints }); if (reason) { augmented.push(rel); byReason[reason]++; continue; } outOfScope.push(rel); } return { inScope, augmented, missing, outOfScope, stale: missing.length + outOfScope.length, byReason, }; } /** * Human summary of the augmented entries, e.g. "256 test, 10 CI". * * @param {{ byReason: object }} classification * @returns {string} */ function formatAugmented(classification) { const by = (classification && classification.byReason) || {}; const label = { test: 'test', ci: 'CI', entrypoint: 'entrypoint' }; return Object.keys(label) .filter((k) => by[k] > 0) .map((k) => `${by[k]} ${label[k]}`) .join(', '); } /** * Remediation lines for whatever stale classes are actually present, each * naming a command or change that fixes THAT class. Empty when nothing is * stale — the previous single message advised re-running `sigmap` for entries * a re-run could never clear. * * @param {{ missing: string[], outOfScope: string[] }} classification * @returns {string[]} */ function staleRemedies(classification) { const out = []; const missing = (classification && classification.missing) || []; const outOfScope = (classification && classification.outOfScope) || []; if (missing.length) { out.push(`${missing.length} indexed file(s) no longer exist (e.g. ${missing[0]}) — run: sigmap (a full run prunes them)`); } if (outOfScope.length) { out.push(`${outOfScope.length} indexed file(s) are outside srcDirs with no test/CI/entrypoint role (e.g. ${outOfScope[0]}) — widen "srcDirs" in gen-context.config.json, or run: sigmap roots --fix`); } return out; } /** Newest mtime among the generated context files, or 0 when none exist. */ function _contextMtime(cwd) { let newest = 0; for (const parts of ADAPTER_OUTPUTS) { try { const m = fs.statSync(path.join(cwd, ...parts)).mtimeMs; if (m > newest) newest = m; } catch (_) {} } return newest; } /** * When the index was last built, and from which evidence. * * Ordered by how much the source actually knows: the usage log records the run * itself but only exists under `--track`; the index artifact records its own * `generated` stamp, file count and version; the context file knows only its * mtime. `status` reported `never` whenever the first was absent, which is the * default — hence the disclosed fallback rather than a silent one. * * @param {string} cwd * @returns {{ ts: string|null, source: string|null, files: number|null, * version: string|null }} */ function indexFreshness(cwd) { // 1. Usage log — the run itself, when tracking is on. try { const { readLog } = __require('./src/tracking/logger'); const log = readLog(cwd) || []; if (log.length) { const last = log[log.length - 1]; if (last && last.ts) { return { ts: last.ts, source: 'usage log', files: last.fileCount != null ? last.fileCount : null, version: last.version || null, }; } } } catch (_) {} // 2. The retrieval index — self-describing, written by every full run. try { const abs = path.join(cwd, INDEX_REL); const data = JSON.parse(fs.readFileSync(abs, 'utf8')); const ts = data && data.generated ? data.generated : new Date(fs.statSync(abs).mtimeMs).toISOString(); return { ts, source: INDEX_REL, files: data && data.files ? Object.keys(data.files).length : null, version: (data && data.sigmapVersion) || null, }; } catch (_) {} // 3. The generated context file — mtime only. const ctx = _contextMtime(cwd); if (ctx > 0) { return { ts: new Date(ctx).toISOString(), source: 'context file mtime', files: null, version: null }; } return { ts: null, source: null, files: null, version: null }; } /** * Count code files under `srcDirs` modified after `sinceMs`. * * Lifted out of `doctor` so `status` counts the same thing: the two reported * different freshness because they were reading different populations with * different timestamps, and sharing only the timestamp would have left half * the disagreement in place. * * @param {string} cwd * @param {{srcDirs?: string[], exclude?: string[]}} config * @param {number} sinceMs * @param {Map} [covered] - live overlay entries; a file whose * entry was stamped at or after its last write is already reflected in * queries, so it does not count as changed (#926) * @returns {number} */ function changedSince(cwd, config, sinceMs, covered) { const { CODE_EXTS } = __require('./src/analysis/coverage-score'); const exclude = new Set(EXCLUDE_DIRS); if (config && Array.isArray(config.exclude)) for (const x of config.exclude) exclude.add(String(x)); const srcDirs = (config && Array.isArray(config.srcDirs) && config.srcDirs.length) ? config.srcDirs : ['src', 'app', 'lib']; let changed = 0; let seen = 0; const walk = (dir, depth) => { if (depth > 8 || seen > 5000) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of entries) { if (exclude.has(e.name)) continue; const full = path.join(dir, e.name); if (e.isDirectory()) walk(full, depth + 1); else if (e.isFile() && CODE_EXTS.has(path.extname(e.name).toLowerCase())) { seen++; try { const m = fs.statSync(full).mtimeMs; if (m > sinceMs && !isCovered(covered, cwd, full, m)) changed++; } catch (_) {} } } }; for (const d of srcDirs) { const abs = path.isAbsolute(d) ? d : path.join(cwd, d); if (fs.existsSync(abs)) walk(abs, 0); } return changed; } /** * Whether a live overlay entry already describes `fullPath` as of `mtimeMs`. * An entry is stamped before the file is read, so an edit that lands during the * read leaves the file's mtime ahead of the stamp and it stays uncovered. */ function isCovered(covered, cwd, fullPath, mtimeMs) { if (!covered || covered.size === 0) return false; const rel = path.relative(cwd, fullPath).replace(/\\/g, '/'); const e = covered.get(rel); // `at` comes from Date.now() (whole ms) and an mtime carries a fraction, so a // write in the same millisecond as the stamp must still count as covered. // An additive entry adds one symbol to a file it knows nothing else about, so // it says nothing about the rest of the file and cannot vouch for it. return !!e && !e.additive && e.at >= Math.floor(mtimeMs); } /** * The live half of index freshness: what the overlay holds beyond the last full * run, and what the watcher last measured. Everything derivable is derived here * at read time; only the watcher's own measurements come from `.context/live.json`. * A figure nobody measured is `null` — never `0`. * * @param {string} cwd * @returns {{ overlayDepth: number, overlay: Map, watcher: {running:boolean, pid:number|null}|null, * lastLatencyMs: number|null, lastLatencyPath: string|null, lastPatchAt: string|null, * lastRegenAt: string|null, lastRegenMs: number|null, patches: number|null, * regens: number|null, settleMs: number|null, incremental: boolean|null, * measured: boolean }} */ function liveIndexState(cwd) { const overlay = __require('./src/cache/overlay'); const { readIndexStamp } = __require('./src/retrieval/sig-index-store'); const all = overlay.load(cwd); let baseMs = readIndexStamp(cwd); if (!Number.isFinite(baseMs)) baseMs = _contextMtime(cwd); const pending = overlay.pending(all, baseMs); const t = overlay.readLive(cwd); let watcher = null; if (t && Number.isFinite(t.pid)) { let running = false; try { process.kill(t.pid, 0); running = true; } catch (err) { running = err.code === 'EPERM'; } // A live pid is necessary, not sufficient: a crashed watcher's number can be // reused. A watcher that reports a heartbeat must have beaten recently (it // beats every 20 s); one that predates the heartbeat is judged by pid alone. if (running && t.heartbeatAt) { const age = Date.now() - Date.parse(t.heartbeatAt); if (Number.isFinite(age) && age > 90000) running = false; } watcher = { running, pid: running ? t.pid : null }; } const num = (v) => (Number.isFinite(v) ? v : null); return { overlayDepth: pending.size, overlay: all, watcher, lastLatencyMs: t ? num(t.lastLatencyMs) : null, lastLatencyPath: t && typeof t.lastLatencyPath === 'string' ? t.lastLatencyPath : null, lastPatchAt: t && t.lastPatchAt ? t.lastPatchAt : null, lastRegenAt: t && t.lastRegenAt ? t.lastRegenAt : null, lastRegenMs: t ? num(t.lastRegenMs) : null, patches: t ? num(t.patches) : null, regens: t ? num(t.regens) : null, settleMs: t ? num(t.settleMs) : null, incremental: t && typeof t.incremental === 'boolean' ? t.incremental : null, measured: !!t && Number.isFinite(t.lastLatencyMs), }; } module.exports = { TEST_ROOTS, testRootDirs, CI_DIRS, INDEX_REL, ADAPTER_OUTPUTS, declaredEntrypointPaths, augmentedReason, classifyIndexEntries, formatAugmented, staleRemedies, indexFreshness, changedSince, isCovered, liveIndexState, }; }; // ── ./src/analysis/test-coverage ── __factories["./src/analysis/test-coverage"] = function(module, exports) { /** * Test coverage by evidence: which test files exercise a given source file. * Shared by `--analyze` (its tested / untested column) and `plan` (the tests * to re-run after a change), so the two commands cannot disagree (#862). * * "Not covered" means no test file targets or loads the source file. A module * exercised only through the CLI is not detected, so the absence is not proof * that nothing tests it. * * A source file is covered when a test file * name — targets its stem (`findRelatedTests`: `foo.test.js`, * `test_foo.py`, `FooTest.java`), or * reference — loads it: a `require` / `import` that resolves to it, or * its path built with `path.join` / `path.resolve` * (`path.join(ROOT, 'src/x/y')`, `path.join(ROOT, 'src', 'x', 'y.js')`). * A path a test merely mentions — a fixture map key such as * `'src/auth.js': '…'` — is not a load. * * Name evidence alone misses every test named after a feature rather than a * file — `judge.test.js` exercising `judge-engine.js` — which is most of an * integration suite. Reference evidence is path-shaped, so it sees JS/TS * `require` / `import` and joined paths; a Python or JVM import by package * name is left to the naming rule. * * Each command used to keep its own heuristic, and they disagreed. `--analyze` * looked for the basename as a substring of the entries directly under `test/`, * so `fix` matched the directory `fixtures` and nothing one level down was * seen. `plan` asked whether a function name appeared as a token in any test. * Neither could say which test it meant. */ const fs = require('fs'); const path = require('path'); const { relatedTestsIndex } = __require('./src/evidence/pack'); const { testRootDirs } = __require('./src/analysis/index-state'); const { CODE_EXTS } = __require('./src/analysis/coverage-score'); const { isTestFile, isMockFile } = __require('./src/util/file-class'); // Scaffolding under a test root: inputs and expected outputs, not tests. Matched // as a prefix, so `fixtures-adversarial/` is recognised as well as `fixtures/`. const NOT_TESTS_RE = /^(node_modules|_{0,2}(fixtures?|testdata|snapshots?|expected)(_{0,2}|[-_.].*))$/i; const MAX_DEPTH = 8; const MAX_BYTES = 512 * 1024; // A path a test loads: the argument of require / import / from. const LOAD_RE = /\b(?:require|import)\s*\(\s*(['"`])([^'"`\n]+)\1|\b(?:from|import)\s+(['"`])([^'"`\n]+)\3/g; // A path it builds: `path.join(ROOT, 'src/x/y')` or `path.join(ROOT, 'src', 'x', 'y.js')`. const JOIN_CALL_RE = /\b(?:join|resolve)\(([^()]*)\)/g; const SEGMENT_ARG_RE = /^(['"`])([\w@./-]+)\1$/; const _posix = (p) => String(p).replace(/\\/g, '/'); // Repo-relative, minus the code extension: the key both sides meet on. function _key(rel) { const ext = path.posix.extname(rel); return ext && CODE_EXTS.has(ext.toLowerCase()) ? rel.slice(0, -ext.length) : rel; } // What one test file loads or builds, exactly as its source spells it. function _pathsIn(src) { const out = []; let m; LOAD_RE.lastIndex = 0; while ((m = LOAD_RE.exec(src)) !== null) { const lit = m[2] || m[4]; if (lit.includes('/')) out.push(lit); } JOIN_CALL_RE.lastIndex = 0; while ((m = JOIN_CALL_RE.exec(src)) !== null) { let run = []; const flush = () => { if (run.length > 1 || (run.length === 1 && run[0].includes('/'))) out.push(run.join('/')); run = []; }; for (const arg of m[1].split(',')) { const lit = arg.trim().match(SEGMENT_ARG_RE); if (lit) run.push(lit[2]); else flush(); } flush(); } return out; } function _isTest(rel) { return CODE_EXTS.has(path.posix.extname(rel).toLowerCase()) && !isMockFile(rel); } function _walk(dir, rel, depth, exclude, out) { if (depth > MAX_DEPTH) return; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; } for (const e of entries) { if (e.name.startsWith('.') || NOT_TESTS_RE.test(e.name) || exclude.has(e.name)) continue; const childRel = `${rel}/${e.name}`; if (e.isDirectory()) _walk(path.join(dir, e.name), childRel, depth + 1, exclude, out); else if (e.isFile() && _isTest(childRel)) out.push(childRel); } } /** * Index the repo's test files once, then look up the tests of each source file. * * @param {string} cwd * @param {object} [opts] * @param {string[]} [opts.files] - files already known (repo-relative or * absolute); the test files among them are added, so tests that sit * beside their source are seen without a second walk * @param {string[]} [opts.exclude] - directory names to skip * @returns {{ testFiles: string[], testsFor: (relPath: string) => string[] }} */ function buildTestCoverageIndex(cwd, opts = {}) { const exclude = new Set(opts.exclude || []); const found = []; for (const root of testRootDirs(cwd)) _walk(path.join(cwd, root), root, 0, exclude, found); for (const f of opts.files || []) { const rel = _posix(path.isAbsolute(f) ? path.relative(cwd, f) : f); if (isTestFile(rel) && _isTest(rel) && !rel.split('/').some((seg) => NOT_TESTS_RE.test(seg))) found.push(rel); } const testFiles = [...new Set(found)].sort(); const byName = relatedTestsIndex(testFiles); const byReference = new Map(); const referencedBy = new Map(); for (const test of testFiles) { let src; try { const abs = path.join(cwd, test); if (fs.statSync(abs).size > MAX_BYTES) continue; src = fs.readFileSync(abs, 'utf8'); } catch (_) { continue; } const dir = path.posix.dirname(test); for (const lit of _pathsIn(src)) { const rel = path.posix.normalize(lit.startsWith('.') ? path.posix.join(dir, lit) : lit); if (rel.startsWith('..')) continue; // outside the repo const key = _key(rel); if (!byReference.has(key)) byReference.set(key, new Set()); byReference.get(key).add(test); if (!referencedBy.has(test)) referencedBy.set(test, new Set()); referencedBy.get(test).add(key); } } // Two source files can share a stem (`security/patterns.js`, // `extractors/patterns.js`), and the naming rule gives `patterns.test.js` to // both. When that test loads one of them and not the other, it is the test of // the one it loads. function loadsNamesake(test, key) { const base = path.posix.basename(key).toLowerCase(); for (const k of referencedBy.get(test) || []) { if (k !== key && path.posix.basename(k).toLowerCase() === base) return true; } return false; } function testsFor(relPath) { const rel = _posix(relPath); if (isTestFile(rel)) return []; const key = _key(rel); const out = new Set(byName(rel).filter((t) => !loadsNamesake(t, key))); const keys = path.posix.basename(key) === 'index' ? [key, path.posix.dirname(key)] : [key]; for (const k of keys) for (const t of byReference.get(k) || []) out.add(t); return [...out].sort(); } return { testFiles, testsFor }; } module.exports = { buildTestCoverageIndex }; }; // ── ./src/cache/entry ── __factories["./src/cache/entry"] = function(module, exports) { /** * The index form of one file's signatures — the stages every writer shares (#926). * * A full `generate`, the watcher's per-file patch, the read-time `freshen` and the * MCP notify hooks all put a file's signatures into the index. They used to * disagree: the hooks and `freshen` stored the raw extraction, so an edit that * went through them replaced a redacted base entry with one carrying the secret, * and dropped the module-doc line. Now the stages live here, once: * * extract → cap at the index limit → redact secrets → module-doc line / terse * * The CLI core's `detectAndExtract` stays the extractor for generate and the * watcher (it owns the opt-in exactness tiers and the generic fallback); the * stages after extraction are these. * * Zero-dependency, bundle-safe. */ const fs = require('fs'); const path = require('path'); /** * Redact secrets from a file's signatures. Fails CLOSED: a scanner that cannot * run throws, and the caller records nothing rather than storing a raw entry. * * @param {string[]} sigs * @param {string} filePath * @param {{secretScan?: boolean}} config * @returns {{ sigs: string[], redacted: boolean }} */ function redactSecrets(sigs, filePath, config) { if (!config || !config.secretScan) return { sigs, redacted: false }; const { scan } = __require('./src/security/scanner'); const result = scan(sigs, filePath); return { sigs: result.safe, redacted: !!result.redacted }; } /** * The file as the retrieval index stores it: a leading module-doc line (the * file's stated purpose, which is the vocabulary a behavioural query uses) and, * with `terse`, the compact encoding. Index-only — the prompt artifact is * token-budgeted, the index is not. * * @param {string} filePath * @param {string} content * @param {string[]} sigs * @param {{terse?: boolean}} config * @param {string} [precomputedDoc] - the file's module-doc line when already known (the extraction cache * holds it), so the file need not be read; '' means the file has none * @returns {string[]} */ function indexSigsFor(filePath, content, sigs, config, precomputedDoc) { let out = sigs; try { // TRIED AND REJECTED: also indexing every per-symbol doc sentence // untruncated (src/retrieval/doc-text.js). 39% of extractor doc hints are // cut at 60 chars, so recovering them looked like free vocabulary. It is // not: train hit@5 fell 75.6% -> 73.3% at every docWeight from 0.2 to 1.0, // and the mined corpus never moved off 62.5%. The MODULE HEADER is the // high-signal prose — it states the file's purpose. Per-symbol sentences // describe internal helpers, so they broaden what each file matches // without making any file a better answer. const { moduleDocSig } = __require('./src/retrieval/module-doc'); const doc = typeof precomputedDoc === 'string' ? precomputedDoc : moduleDocSig(content, filePath); // The header is prose lifted from the file, so it is scanned like a signature: // a key pasted into a header comment must not reach the index. A scanner that // cannot run throws into the catch below and the line is simply omitted. if (doc) out = [redactSecrets([doc], filePath, config).sigs[0], ...sigs]; } catch (_) { /* enrichment is best-effort */ } if (config && config.terse) { try { const { encodeTerseSigs } = __require('./src/format/terse'); out = encodeTerseSigs(sigs); } catch (_) { /* terse unavailable → index full signatures */ } } return out; } /** * The few settings the stages above read, from the project's own config file * over the shipped defaults. Reading the raw file (not the full loader) keeps * this cheap enough for a hook that runs on every agent write. * * @param {string} cwd * @returns {{ secretScan: boolean, terse: boolean, maxSigsPerFile: number, maxIndexSigsPerFile: number }} */ function entryConfig(cwd) { const { DEFAULTS } = __require('./src/config/defaults'); let raw = {}; try { const parsed = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8')); if (parsed && typeof parsed === 'object') raw = parsed; } catch (_) { /* no project config → shipped defaults */ } // A local `extends` supplies the base. A URL is not followed here: a hook that // runs on every agent write never goes to the network. if (typeof raw.extends === 'string' && !/^https?:\/\//.test(raw.extends)) { try { raw = Object.assign({}, __require('./src/config/loader').loadBaseConfig(raw.extends, cwd), raw); } catch (_) { /* unreadable base → own keys only */ } } const pick = (k) => (raw[k] !== undefined ? raw[k] : DEFAULTS[k]); return { secretScan: !!pick('secretScan'), terse: !!pick('terse'), maxSigsPerFile: Number.isFinite(pick('maxSigsPerFile')) ? pick('maxSigsPerFile') : 25, maxIndexSigsPerFile: Number.isFinite(pick('maxIndexSigsPerFile')) ? pick('maxIndexSigsPerFile') : 200, }; } /** * What a full extraction is asked for, whatever the limits: the extractors stop at 200 * signatures a file on their own, and this bounds the few that do not. It is above both * limits so that `capForIndex` is the one place a file is cut, and can say how much. */ const EXTRACT_CEILING = 1000; /** * Matches the extractors' own disclosure line at the end of a file's list, `… +N more *