sigmap 7.10.0 → 7.12.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 +18 -0
- package/gen-context.js +618 -273
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/core/package.json +1 -1
- package/src/mcp/server.js +1 -1
- package/src/plan/verify-plan.js +108 -0
- package/src/review/review-pr.js +106 -0
package/gen-context.js
CHANGED
|
@@ -25,222 +25,245 @@ function __require(key) {
|
|
|
25
25
|
// ── ./src/conventions/conflicts ──
|
|
26
26
|
// ── ./src/conventions/inject ──
|
|
27
27
|
// ── ./src/scaffold/propose ──
|
|
28
|
-
|
|
28
|
+
// ── ./src/plan/verify-plan ──
|
|
29
|
+
// ── ./src/cache/freshen ──
|
|
30
|
+
// ── ./src/review/review-pr ──
|
|
31
|
+
__factories["./src/review/review-pr"] = function(module, exports) {
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
|
-
*
|
|
34
|
+
* review-pr (IMPL.md §6 step 4 — last guard stage of the `create` pipeline).
|
|
32
35
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
|
|
38
|
-
* conventions primitives.
|
|
36
|
+
* Audits a diff for drift and side effects after a PR is opened: scope drift,
|
|
37
|
+
* edits to high-fan-in "god-node" files, source changes without matching tests,
|
|
38
|
+
* and changes to security-sensitive files. Pure (takes a changed-file list),
|
|
39
|
+
* zero-dependency, bundle-safe; reuses the impact graph for blast radius.
|
|
39
40
|
*/
|
|
40
41
|
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const { analyzeImpact } = __require('./src/graph/impact');
|
|
44
|
+
|
|
45
|
+
const SECURITY_PATTERNS = [
|
|
46
|
+
/(^|\/)\.env(\.|$)/i,
|
|
47
|
+
/(^|\/)(secrets?|credentials?)(\/|\.|$)/i,
|
|
48
|
+
/(^|\/)auth(\/|\.|-)/i,
|
|
49
|
+
/(^|\/)package(-lock)?\.json$/,
|
|
50
|
+
/(^|\/)(yarn\.lock|pnpm-lock\.yaml)$/,
|
|
51
|
+
/(^|\/)\.github\/workflows\//,
|
|
52
|
+
/(^|\/)Dockerfile/i,
|
|
53
|
+
/\.(pem|key|crt|p12)$/i,
|
|
54
|
+
];
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (pct >= 0.7) return 'mostly';
|
|
51
|
-
return 'inconsistent';
|
|
52
|
-
}
|
|
56
|
+
const SRC_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php']);
|
|
57
|
+
const GOD_NODE_THRESHOLD = 15; // transitive dependents → high-fan-in "god node"
|
|
58
|
+
const SCOPE_DIR_THRESHOLD = 5; // distinct top-level dirs → scope drift
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const s = String(name || '').trim();
|
|
57
|
-
const slash = s.lastIndexOf('/');
|
|
58
|
-
const base = slash >= 0 ? s.slice(slash + 1) : s;
|
|
59
|
-
const dot = base.indexOf('.');
|
|
60
|
-
return dot > 0 ? base.slice(0, dot) : base;
|
|
60
|
+
function isTestFile(p) {
|
|
61
|
+
return /\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.(py|go)$|(^|\/)(tests?|__tests__|spec)\//.test(p);
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
function _testFile(styledStem, framework, ext) {
|
|
65
|
-
if (framework === 'pytest' || framework === 'unittest') {
|
|
66
|
-
return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
|
|
67
|
-
}
|
|
68
|
-
return `${styledStem}.test.${ext}`;
|
|
63
|
+
function isSource(p) {
|
|
64
|
+
return SRC_EXTS.has(path.extname(p).toLowerCase()) && !isTestFile(p);
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
/**
|
|
72
|
-
*
|
|
73
|
-
* @param {string}
|
|
74
|
-
* @param {
|
|
68
|
+
* Audit a changed-file list.
|
|
69
|
+
* @param {Array<{path:string,status:string}>|string[]} changedFiles
|
|
70
|
+
* @param {string} cwd
|
|
75
71
|
* @param {object} [opts]
|
|
76
|
-
* @param {number} [opts.
|
|
77
|
-
* @param {
|
|
78
|
-
*
|
|
79
|
-
* @param {string} [opts.ext='js'] file extension for the proposed files
|
|
80
|
-
* @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
|
|
81
|
-
* confidence:number, threshold:number, hardFloor:number, forced:boolean,
|
|
82
|
-
* warning:string|null, reason:string, proposal:object|null, conflicts:object }}
|
|
72
|
+
* @param {number} [opts.godNodeThreshold=15]
|
|
73
|
+
* @param {number} [opts.scopeThreshold=5]
|
|
74
|
+
* @returns {{ findings: object[], blast: object[], summary: object }}
|
|
83
75
|
*/
|
|
84
|
-
function
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
};
|
|
76
|
+
function reviewPr(changedFiles, cwd, opts = {}) {
|
|
77
|
+
const godThreshold = opts.godNodeThreshold != null ? opts.godNodeThreshold : GOD_NODE_THRESHOLD;
|
|
78
|
+
const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : SCOPE_DIR_THRESHOLD;
|
|
79
|
+
const files = (changedFiles || []).map((f) => (typeof f === 'string' ? { path: f, status: 'M' } : f));
|
|
80
|
+
|
|
81
|
+
const findings = [];
|
|
82
|
+
const paths = files.map((f) => f.path);
|
|
83
|
+
const live = files.filter((f) => f.status !== 'D');
|
|
84
|
+
const srcChanged = live.filter((f) => isSource(f.path)).map((f) => f.path);
|
|
85
|
+
const testChanged = paths.filter(isTestFile);
|
|
86
|
+
|
|
87
|
+
// 1. Missing tests: a changed source file with no matching changed test file.
|
|
88
|
+
for (const s of srcChanged) {
|
|
89
|
+
const stem = path.basename(s).replace(/\.[^.]+$/, '');
|
|
90
|
+
const covered = testChanged.some((t) => path.basename(t).includes(stem));
|
|
91
|
+
if (!covered) findings.push({ type: 'missing-tests', file: s, severity: 'warn' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Security-sensitive files.
|
|
95
|
+
for (const f of live) {
|
|
96
|
+
if (SECURITY_PATTERNS.some((re) => re.test(f.path))) {
|
|
97
|
+
findings.push({ type: 'security-file', file: f.path, severity: 'warn' });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 3. God-node edits (high blast radius).
|
|
102
|
+
const blast = [];
|
|
103
|
+
if (srcChanged.length) {
|
|
104
|
+
let impacts = [];
|
|
105
|
+
try { impacts = analyzeImpact(srcChanged, cwd, {}); } catch (_) { impacts = []; }
|
|
106
|
+
for (const { file, impact } of impacts) {
|
|
107
|
+
blast.push({ file, totalImpact: impact.totalImpact });
|
|
108
|
+
if (impact.totalImpact > godThreshold) {
|
|
109
|
+
findings.push({ type: 'god-node', file, count: impact.totalImpact, severity: 'warn' });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
blast.sort((a, b) => b.totalImpact - a.totalImpact);
|
|
114
113
|
}
|
|
115
114
|
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
exportStyle: exportStyle.dominant || 'named',
|
|
122
|
-
testFile: _testFile(styledStem, conventions.testFramework, ext),
|
|
123
|
-
testFramework: conventions.testFramework || null,
|
|
124
|
-
};
|
|
115
|
+
// 4. Scope drift: distinct top-level directories touched.
|
|
116
|
+
const dirs = [...new Set(paths.map((p) => (p.includes('/') ? p.split('/')[0] : '.')))];
|
|
117
|
+
if (dirs.length > scopeThreshold) {
|
|
118
|
+
findings.push({ type: 'scope-drift', dirs, count: dirs.length, threshold: scopeThreshold, severity: 'warn' });
|
|
119
|
+
}
|
|
125
120
|
|
|
121
|
+
const byType = findings.reduce((a, f) => { a[f.type] = (a[f.type] || 0) + 1; return a; }, {});
|
|
126
122
|
return {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
:
|
|
134
|
-
|
|
135
|
-
|
|
123
|
+
findings,
|
|
124
|
+
blast,
|
|
125
|
+
summary: {
|
|
126
|
+
filesChanged: files.length,
|
|
127
|
+
sourceChanged: srcChanged.length,
|
|
128
|
+
testsChanged: testChanged.length,
|
|
129
|
+
findings: findings.length,
|
|
130
|
+
byType,
|
|
131
|
+
ok: findings.length === 0,
|
|
132
|
+
},
|
|
136
133
|
};
|
|
137
134
|
}
|
|
138
135
|
|
|
139
|
-
module.exports = {
|
|
136
|
+
module.exports = { reviewPr, SECURITY_PATTERNS, GOD_NODE_THRESHOLD, SCOPE_DIR_THRESHOLD };
|
|
140
137
|
|
|
141
138
|
};
|
|
142
139
|
|
|
143
|
-
__factories["./src/
|
|
140
|
+
__factories["./src/cache/freshen"] = function(module, exports) {
|
|
144
141
|
|
|
145
142
|
/**
|
|
146
|
-
*
|
|
143
|
+
* Read-time self-heal (IMPL.md Layer 1, "safety net" tier).
|
|
147
144
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
145
|
+
* Keeps the sig-cache in line with the current source tree so the index reflects
|
|
146
|
+
* on-disk reality even when no write hook was called. Re-extracts files modified
|
|
147
|
+
* since the context file was generated (bounded to actual session edits, not the
|
|
148
|
+
* whole tree), drops cache entries for deleted files, and persists. buildSigIndex
|
|
149
|
+
* already merges the cache, so the next read is fresh.
|
|
150
|
+
*
|
|
151
|
+
* Throttled per cwd. Skips entirely when there is no generated index to heal
|
|
152
|
+
* (a cold repo should run `generate` or use the notify hooks).
|
|
153
|
+
*
|
|
154
|
+
* Zero-dependency, bundle-safe (fs + dispatch + sig-cache).
|
|
153
155
|
*/
|
|
154
156
|
|
|
155
|
-
const
|
|
156
|
-
const
|
|
157
|
+
const fs = require('fs');
|
|
158
|
+
const path = require('path');
|
|
159
|
+
const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
|
|
160
|
+
const { extractFile, langFor } = __require('./src/extractors/dispatch');
|
|
157
161
|
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const DEFAULT_SRC_DIRS = ['src', 'app', 'lib', 'packages', 'services', 'api'];
|
|
163
|
+
const DEFAULT_EXCLUDE = [
|
|
164
|
+
'node_modules', '.git', 'dist', 'build', 'out', '__pycache__',
|
|
165
|
+
'.next', 'coverage', 'target', 'vendor', '.context',
|
|
166
|
+
];
|
|
167
|
+
const CONTEXT_PATHS = [
|
|
168
|
+
['.github', 'copilot-instructions.md'],
|
|
169
|
+
['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
|
|
170
|
+
];
|
|
171
|
+
const THROTTLE_MS = 1500;
|
|
172
|
+
const _lastRun = new Map();
|
|
163
173
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
function _readConfig(cwd) {
|
|
175
|
+
try {
|
|
176
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(cwd, 'gen-context.config.json'), 'utf8'));
|
|
177
|
+
return cfg && typeof cfg === 'object' ? cfg : {};
|
|
178
|
+
} catch (_) { return {}; }
|
|
179
|
+
}
|
|
168
180
|
|
|
169
|
-
|
|
181
|
+
function _pkgVersion(cwd) {
|
|
182
|
+
try { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')).version || '0.0.0'; }
|
|
183
|
+
catch (_) { return '0.0.0'; }
|
|
184
|
+
}
|
|
170
185
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
186
|
+
/** Newest mtime among existing generated context files, or 0 if none. */
|
|
187
|
+
function _contextMtime(cwd) {
|
|
188
|
+
let newest = 0;
|
|
189
|
+
for (const parts of CONTEXT_PATHS) {
|
|
190
|
+
try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
|
|
191
|
+
}
|
|
192
|
+
return newest;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _walk(dir, exclude, out, depth, maxDepth) {
|
|
196
|
+
if (depth > maxDepth) return;
|
|
197
|
+
let entries;
|
|
198
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (_) { return; }
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
if (exclude.has(e.name)) continue;
|
|
201
|
+
const full = path.join(dir, e.name);
|
|
202
|
+
if (e.isDirectory()) _walk(full, exclude, out, depth + 1, maxDepth);
|
|
203
|
+
else if (e.isFile() && langFor(e.name)) out.push(full);
|
|
178
204
|
}
|
|
179
|
-
return line;
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
/**
|
|
183
|
-
*
|
|
184
|
-
* @param {
|
|
185
|
-
* @param {
|
|
186
|
-
* @returns {
|
|
208
|
+
* Re-extract source files changed since the last generate; drop deleted files.
|
|
209
|
+
* @param {string} cwd
|
|
210
|
+
* @param {{force?:boolean, now?:number}} [opts]
|
|
211
|
+
* @returns {number} cache entries touched
|
|
187
212
|
*/
|
|
188
|
-
function
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (l) lines.push(l);
|
|
193
|
-
}
|
|
194
|
-
if (result && result.testFramework) {
|
|
195
|
-
lines.push(`- **Test framework:** ${result.testFramework}.`);
|
|
213
|
+
function freshen(cwd, opts = {}) {
|
|
214
|
+
const now = opts.now != null ? opts.now : Date.now();
|
|
215
|
+
if (!opts.force) {
|
|
216
|
+
if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
|
|
196
217
|
}
|
|
218
|
+
_lastRun.set(cwd, now);
|
|
197
219
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
220
|
+
try {
|
|
221
|
+
const version = _pkgVersion(cwd);
|
|
222
|
+
const cache = loadCache(cwd, version);
|
|
223
|
+
const ctxMtime = _contextMtime(cwd);
|
|
224
|
+
// Nothing to heal: no generated context AND no live cache overlay.
|
|
225
|
+
if (ctxMtime === 0 && cache.size === 0) return 0;
|
|
201
226
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
'',
|
|
207
|
-
'Match these when writing or editing code (TS/JS/Python):',
|
|
208
|
-
'',
|
|
209
|
-
...body,
|
|
210
|
-
'',
|
|
211
|
-
`<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
|
|
212
|
-
END,
|
|
213
|
-
].join('\n');
|
|
214
|
-
}
|
|
227
|
+
const cfg = _readConfig(cwd);
|
|
228
|
+
const srcDirs = Array.isArray(cfg.srcDirs) && cfg.srcDirs.length ? cfg.srcDirs : DEFAULT_SRC_DIRS;
|
|
229
|
+
const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
|
|
230
|
+
const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
|
|
215
231
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
* @param {string} existing current file content ('' if the file is new)
|
|
221
|
-
* @param {string} block the block returned by `renderConventionsBlock`
|
|
222
|
-
* @returns {string}
|
|
223
|
-
*/
|
|
224
|
-
function injectConventions(existing, block) {
|
|
225
|
-
const src = String(existing || '');
|
|
226
|
-
const startIdx = src.indexOf(START);
|
|
227
|
-
if (startIdx !== -1) {
|
|
228
|
-
const endIdx = src.indexOf(END, startIdx);
|
|
229
|
-
if (endIdx !== -1) {
|
|
230
|
-
const before = src.slice(0, startIdx);
|
|
231
|
-
const after = src.slice(endIdx + END.length);
|
|
232
|
-
return before + block + after;
|
|
232
|
+
const files = [];
|
|
233
|
+
for (const d of srcDirs) {
|
|
234
|
+
const abs = path.isAbsolute(d) ? d : path.join(cwd, d);
|
|
235
|
+
if (fs.existsSync(abs)) _walk(abs, exclude, files, 0, maxDepth);
|
|
233
236
|
}
|
|
237
|
+
|
|
238
|
+
// Candidates = files modified since the context was generated, or not yet cached.
|
|
239
|
+
const candidates = files.filter((f) => {
|
|
240
|
+
try { return fs.statSync(f).mtimeMs > ctxMtime || !cache.has(f); } catch (_) { return false; }
|
|
241
|
+
});
|
|
242
|
+
const { changed } = getChangedFiles(candidates, cache);
|
|
243
|
+
|
|
244
|
+
let touched = 0;
|
|
245
|
+
for (const f of changed) {
|
|
246
|
+
try {
|
|
247
|
+
const sigs = extractFile(f, fs.readFileSync(f, 'utf8'));
|
|
248
|
+
cache.set(f, { mtime: fs.statSync(f).mtimeMs, sigs });
|
|
249
|
+
touched++;
|
|
250
|
+
} catch (_) {}
|
|
251
|
+
}
|
|
252
|
+
// Note: deletions are NOT swept here — a cache entry may be a `notify`
|
|
253
|
+
// overlay for a file not yet on disk. Explicit removal is `notify_file_deleted`.
|
|
254
|
+
|
|
255
|
+
if (touched > 0) saveCache(cwd, version, cache);
|
|
256
|
+
return touched;
|
|
257
|
+
} catch (_) {
|
|
258
|
+
return 0;
|
|
234
259
|
}
|
|
235
|
-
if (src.trim() === '') return block + '\n';
|
|
236
|
-
const sep = src.endsWith('\n') ? '\n' : '\n\n';
|
|
237
|
-
return src + sep + block + '\n';
|
|
238
260
|
}
|
|
239
261
|
|
|
240
|
-
module.exports = {
|
|
262
|
+
module.exports = { freshen };
|
|
241
263
|
|
|
242
264
|
};
|
|
243
265
|
|
|
266
|
+
// ── ./src/conventions/conflicts ──
|
|
244
267
|
__factories["./src/conventions/conflicts"] = function(module, exports) {
|
|
245
268
|
|
|
246
269
|
/**
|
|
@@ -355,6 +378,7 @@ __factories["./src/conventions/conflicts"] = function(module, exports) {
|
|
|
355
378
|
|
|
356
379
|
};
|
|
357
380
|
|
|
381
|
+
// ── ./src/conventions/extract ──
|
|
358
382
|
__factories["./src/conventions/extract"] = function(module, exports) {
|
|
359
383
|
|
|
360
384
|
/**
|
|
@@ -399,24 +423,42 @@ __factories["./src/conventions/extract"] = function(module, exports) {
|
|
|
399
423
|
return 'other';
|
|
400
424
|
}
|
|
401
425
|
|
|
426
|
+
const MAX_EXAMPLES = 3;
|
|
427
|
+
|
|
402
428
|
/**
|
|
403
429
|
* Score a set of categorical observations into a dominant convention plus its
|
|
404
430
|
* consistency tier. The reusable primitive (IMPL.md §5.2).
|
|
405
431
|
* @param {string[]} labels observed category for each sample (e.g. naming styles)
|
|
432
|
+
* @param {string[]} [refs] optional identifier (e.g. file name) parallel to
|
|
433
|
+
* `labels`; when given, each variant carries up to 3 `examples`.
|
|
406
434
|
* @returns {{ dominant: string|null, dominantPct: number, total: number,
|
|
407
|
-
* variants: Array<{label:string, count:number, pct:number}>,
|
|
435
|
+
* variants: Array<{label:string, count:number, pct:number, examples?:string[]}>,
|
|
408
436
|
* tier: 'consistent'|'mostly'|'inconsistent'|'unknown' }}
|
|
409
437
|
*/
|
|
410
|
-
function scoreConvention(labels) {
|
|
411
|
-
const
|
|
412
|
-
const
|
|
438
|
+
function scoreConvention(labels, refs) {
|
|
439
|
+
const all = labels || [];
|
|
440
|
+
const counts = new Map();
|
|
441
|
+
const examples = new Map();
|
|
442
|
+
let total = 0;
|
|
443
|
+
for (let i = 0; i < all.length; i++) {
|
|
444
|
+
const l = all[i];
|
|
445
|
+
if (l == null || l === 'other') continue;
|
|
446
|
+
total++;
|
|
447
|
+
counts.set(l, (counts.get(l) || 0) + 1);
|
|
448
|
+
if (refs && refs[i] != null) {
|
|
449
|
+
const ex = examples.get(l) || [];
|
|
450
|
+
if (ex.length < MAX_EXAMPLES) { ex.push(refs[i]); examples.set(l, ex); }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
413
453
|
if (total === 0) {
|
|
414
454
|
return { dominant: null, dominantPct: 0, total: 0, variants: [], tier: 'unknown' };
|
|
415
455
|
}
|
|
416
|
-
const counts = new Map();
|
|
417
|
-
for (const l of list) counts.set(l, (counts.get(l) || 0) + 1);
|
|
418
456
|
const variants = [...counts.entries()]
|
|
419
|
-
.map(([label, count]) =>
|
|
457
|
+
.map(([label, count]) => {
|
|
458
|
+
const v = { label, count, pct: count / total };
|
|
459
|
+
if (refs) v.examples = examples.get(label) || [];
|
|
460
|
+
return v;
|
|
461
|
+
})
|
|
420
462
|
.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
|
|
421
463
|
const top = variants[0];
|
|
422
464
|
let tier = 'inconsistent';
|
|
@@ -488,157 +530,365 @@ __factories["./src/conventions/extract"] = function(module, exports) {
|
|
|
488
530
|
function extractConventions(cwd, files) {
|
|
489
531
|
const scoped = (files || []).filter((f) => SCOPED_EXTS.has(path.extname(f).toLowerCase()));
|
|
490
532
|
const namingLabels = [];
|
|
533
|
+
const namingRefs = [];
|
|
491
534
|
const exportLabels = [];
|
|
535
|
+
const exportRefs = [];
|
|
492
536
|
for (const f of scoped) {
|
|
493
537
|
const base = path.basename(f);
|
|
494
538
|
// Skip test files for the naming convention (they have their own naming).
|
|
495
539
|
if (!/\.(test|spec)\.[jt]sx?$|(^|\/)test_|_test\.py$/.test(f)) {
|
|
496
540
|
namingLabels.push(classifyNaming(base));
|
|
541
|
+
namingRefs.push(base);
|
|
497
542
|
}
|
|
498
543
|
if (JS_TS_EXTS.has(path.extname(f).toLowerCase())) {
|
|
499
544
|
let src = '';
|
|
500
545
|
try { src = fs.readFileSync(f, 'utf8'); } catch (_) {}
|
|
501
546
|
exportLabels.push(_jsExportStyle(src));
|
|
547
|
+
exportRefs.push(base);
|
|
502
548
|
}
|
|
503
549
|
}
|
|
504
550
|
return {
|
|
505
|
-
fileNaming: scoreConvention(namingLabels),
|
|
506
|
-
exportStyle: scoreConvention(exportLabels),
|
|
551
|
+
fileNaming: scoreConvention(namingLabels, namingRefs),
|
|
552
|
+
exportStyle: scoreConvention(exportLabels, exportRefs),
|
|
507
553
|
testFramework: _detectTestFramework(cwd, scoped),
|
|
508
554
|
scope: ['typescript', 'javascript', 'python'],
|
|
509
555
|
scannedFiles: scoped.length,
|
|
510
556
|
};
|
|
511
557
|
}
|
|
512
558
|
|
|
513
|
-
module.exports = { classifyNaming, scoreConvention, extractConventions };
|
|
559
|
+
module.exports = { classifyNaming, scoreConvention, extractConventions };
|
|
560
|
+
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
// ── ./src/conventions/inject ──
|
|
564
|
+
__factories["./src/conventions/inject"] = function(module, exports) {
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* CLAUDE.md convention injection (IMPL.md §7 Phase 2 / §8 step 5).
|
|
568
|
+
*
|
|
569
|
+
* Renders the conventions detected by `extractConventions` into a
|
|
570
|
+
* marker-delimited markdown block and injects it into CLAUDE.md so an agent
|
|
571
|
+
* reading the file plans grounded in the repo's house style. Idempotent and
|
|
572
|
+
* marker-scoped — it never touches human content or the `## Auto-generated
|
|
573
|
+
* signatures` block. Pure string transforms; zero-dependency, bundle-safe.
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
const START = '<!-- sigmap-conventions:start -->';
|
|
577
|
+
const END = '<!-- sigmap-conventions:end -->';
|
|
578
|
+
|
|
579
|
+
const TIER_NOTE = {
|
|
580
|
+
consistent: 'consistent — match it',
|
|
581
|
+
mostly: 'dominant, with some drift',
|
|
582
|
+
inconsistent: 'no clear convention — check neighboring files',
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const NAMES = {
|
|
586
|
+
fileNaming: 'File naming',
|
|
587
|
+
exportStyle: 'Export style',
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const _pct = (n) => `${Math.round(n * 100)}%`;
|
|
591
|
+
|
|
592
|
+
function _conventionLine(label, conv) {
|
|
593
|
+
if (!conv || conv.total === 0 || !conv.dominant) return null;
|
|
594
|
+
const note = TIER_NOTE[conv.tier] || conv.tier;
|
|
595
|
+
let line = `- **${label}:** ${conv.dominant} (${_pct(conv.dominantPct)} — ${note}).`;
|
|
596
|
+
const others = conv.variants.slice(1).filter((v) => v.pct > 0);
|
|
597
|
+
if (others.length) {
|
|
598
|
+
line += ` Variants: ${others.map((v) => `${v.label} ${_pct(v.pct)}`).join(', ')}.`;
|
|
599
|
+
}
|
|
600
|
+
return line;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Render the conventions block (including its start/end markers).
|
|
605
|
+
* @param {object} result an `extractConventions` result
|
|
606
|
+
* @param {string} [version] SigMap version for the footer
|
|
607
|
+
* @returns {string}
|
|
608
|
+
*/
|
|
609
|
+
function renderConventionsBlock(result, version) {
|
|
610
|
+
const lines = [];
|
|
611
|
+
for (const key of ['fileNaming', 'exportStyle']) {
|
|
612
|
+
const l = _conventionLine(NAMES[key], result && result[key]);
|
|
613
|
+
if (l) lines.push(l);
|
|
614
|
+
}
|
|
615
|
+
if (result && result.testFramework) {
|
|
616
|
+
lines.push(`- **Test framework:** ${result.testFramework}.`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const body = lines.length
|
|
620
|
+
? lines
|
|
621
|
+
: ['- No conventions detected yet (run `sigmap conventions` on a TS/JS/Python repo).'];
|
|
622
|
+
|
|
623
|
+
const ver = version ? ` v${version}` : '';
|
|
624
|
+
return [
|
|
625
|
+
START,
|
|
626
|
+
'## Conventions (auto-detected by SigMap)',
|
|
627
|
+
'',
|
|
628
|
+
'Match these when writing or editing code (TS/JS/Python):',
|
|
629
|
+
'',
|
|
630
|
+
...body,
|
|
631
|
+
'',
|
|
632
|
+
`<sub>Generated by SigMap${ver} · run \`sigmap conventions --inject\` to refresh.</sub>`,
|
|
633
|
+
END,
|
|
634
|
+
].join('\n');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Inject (or replace) the conventions block in existing CLAUDE.md content.
|
|
639
|
+
* Replaces an existing marked block in place; appends one when absent.
|
|
640
|
+
* Idempotent — never touches content outside the markers.
|
|
641
|
+
* @param {string} existing current file content ('' if the file is new)
|
|
642
|
+
* @param {string} block the block returned by `renderConventionsBlock`
|
|
643
|
+
* @returns {string}
|
|
644
|
+
*/
|
|
645
|
+
function injectConventions(existing, block) {
|
|
646
|
+
const src = String(existing || '');
|
|
647
|
+
const startIdx = src.indexOf(START);
|
|
648
|
+
if (startIdx !== -1) {
|
|
649
|
+
const endIdx = src.indexOf(END, startIdx);
|
|
650
|
+
if (endIdx !== -1) {
|
|
651
|
+
const before = src.slice(0, startIdx);
|
|
652
|
+
const after = src.slice(endIdx + END.length);
|
|
653
|
+
return before + block + after;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
if (src.trim() === '') return block + '\n';
|
|
657
|
+
const sep = src.endsWith('\n') ? '\n' : '\n\n';
|
|
658
|
+
return src + sep + block + '\n';
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
module.exports = { renderConventionsBlock, injectConventions, START, END };
|
|
514
662
|
|
|
515
663
|
};
|
|
516
664
|
|
|
517
|
-
|
|
665
|
+
// ── ./src/scaffold/propose ──
|
|
666
|
+
__factories["./src/scaffold/propose"] = function(module, exports) {
|
|
518
667
|
|
|
519
668
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* Keeps the sig-cache in line with the current source tree so the index reflects
|
|
523
|
-
* on-disk reality even when no write hook was called. Re-extracts files modified
|
|
524
|
-
* since the context file was generated (bounded to actual session edits, not the
|
|
525
|
-
* whole tree), drops cache entries for deleted files, and persists. buildSigIndex
|
|
526
|
-
* already merges the cache, so the next read is fresh.
|
|
527
|
-
*
|
|
528
|
-
* Throttled per cwd. Skips entirely when there is no generated index to heal
|
|
529
|
-
* (a cold repo should run `generate` or use the notify hooks).
|
|
669
|
+
* Scaffold proposal with a confidence floor (IMPL.md §5 — Cause 3).
|
|
530
670
|
*
|
|
531
|
-
*
|
|
671
|
+
* Proposes a convention-matched structure for a new module — filename in the
|
|
672
|
+
* repo's dominant naming style, the export style to use, and a matching test
|
|
673
|
+
* file — but only when the conventions are consistent enough. Below a hard
|
|
674
|
+
* floor it refuses and surfaces the conflict, because a wrong proposal
|
|
675
|
+
* systematizes bad code. Pure, zero-dependency, bundle-safe; reuses the
|
|
676
|
+
* conventions primitives.
|
|
532
677
|
*/
|
|
533
678
|
|
|
534
|
-
const
|
|
535
|
-
const path = require('path');
|
|
536
|
-
const { loadCache, saveCache, getChangedFiles } = __require('./src/cache/sig-cache');
|
|
537
|
-
const { extractFile, langFor } = __require('./src/extractors/dispatch');
|
|
679
|
+
const { toNamingStyle, analyzeConflicts } = __require('./src/conventions/conflicts');
|
|
538
680
|
|
|
539
|
-
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
'.next', 'coverage', 'target', 'vendor', '.context',
|
|
543
|
-
];
|
|
544
|
-
const CONTEXT_PATHS = [
|
|
545
|
-
['.github', 'copilot-instructions.md'],
|
|
546
|
-
['CLAUDE.md'], ['AGENTS.md'], ['.github', 'context-cold.md'],
|
|
547
|
-
];
|
|
548
|
-
const THROTTLE_MS = 1500;
|
|
549
|
-
const _lastRun = new Map();
|
|
681
|
+
// Soft threshold is configurable; the hard floor is not (IMPL.md §5.1).
|
|
682
|
+
const DEFAULT_THRESHOLD = 0.7;
|
|
683
|
+
const HARD_FLOOR = 0.5;
|
|
550
684
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
685
|
+
/** Tier for a consistency score (matches the conventions tiers). */
|
|
686
|
+
function _tier(pct) {
|
|
687
|
+
if (pct >= 0.9) return 'consistent';
|
|
688
|
+
if (pct >= 0.7) return 'mostly';
|
|
689
|
+
return 'inconsistent';
|
|
556
690
|
}
|
|
557
691
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
692
|
+
/** Strip any extension/compound suffix from a requested name → bare stem. */
|
|
693
|
+
function _stem(name) {
|
|
694
|
+
const s = String(name || '').trim();
|
|
695
|
+
const slash = s.lastIndexOf('/');
|
|
696
|
+
const base = slash >= 0 ? s.slice(slash + 1) : s;
|
|
697
|
+
const dot = base.indexOf('.');
|
|
698
|
+
return dot > 0 ? base.slice(0, dot) : base;
|
|
561
699
|
}
|
|
562
700
|
|
|
563
|
-
/**
|
|
564
|
-
function
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
try { newest = Math.max(newest, fs.statSync(path.join(cwd, ...parts)).mtimeMs); } catch (_) {}
|
|
701
|
+
/** Test file path for a styled stem given the detected framework + ext. */
|
|
702
|
+
function _testFile(styledStem, framework, ext) {
|
|
703
|
+
if (framework === 'pytest' || framework === 'unittest') {
|
|
704
|
+
return `test_${toNamingStyle(styledStem, 'snake_case')}.py`;
|
|
568
705
|
}
|
|
569
|
-
return
|
|
706
|
+
return `${styledStem}.test.${ext}`;
|
|
570
707
|
}
|
|
571
708
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
709
|
+
/**
|
|
710
|
+
* Propose a convention-matched scaffold, gated by a confidence floor.
|
|
711
|
+
* @param {string} name desired module name (any casing; extension ignored)
|
|
712
|
+
* @param {object} conventions an `extractConventions` result
|
|
713
|
+
* @param {object} [opts]
|
|
714
|
+
* @param {number} [opts.threshold=0.7] soft threshold (clamped to ≥ hard floor)
|
|
715
|
+
* @param {boolean} [opts.force=false] allow proposing below the soft threshold
|
|
716
|
+
* (never below the hard floor)
|
|
717
|
+
* @param {string} [opts.ext='js'] file extension for the proposed files
|
|
718
|
+
* @returns {{ ok:boolean, refused:boolean, name:string, tier:string,
|
|
719
|
+
* confidence:number, threshold:number, hardFloor:number, forced:boolean,
|
|
720
|
+
* warning:string|null, reason:string, proposal:object|null, conflicts:object }}
|
|
721
|
+
*/
|
|
722
|
+
function proposeScaffold(name, conventions, opts = {}) {
|
|
723
|
+
const threshold = Math.max(HARD_FLOOR, opts.threshold != null ? opts.threshold : DEFAULT_THRESHOLD);
|
|
724
|
+
const force = !!opts.force;
|
|
725
|
+
const ext = opts.ext || 'js';
|
|
726
|
+
const fileNaming = (conventions && conventions.fileNaming) || { dominant: null, dominantPct: 0, total: 0 };
|
|
727
|
+
const exportStyle = (conventions && conventions.exportStyle) || { dominant: null };
|
|
728
|
+
const confidence = fileNaming.dominantPct || 0;
|
|
729
|
+
const tier = fileNaming.total > 0 ? _tier(confidence) : 'unknown';
|
|
730
|
+
const conflicts = analyzeConflicts(conventions || {});
|
|
731
|
+
|
|
732
|
+
const base = {
|
|
733
|
+
ok: false, refused: true, name: String(name || ''), tier, confidence,
|
|
734
|
+
threshold, hardFloor: HARD_FLOOR, forced: false, warning: null,
|
|
735
|
+
reason: '', proposal: null, conflicts,
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
if (!fileNaming.dominant || fileNaming.total === 0) {
|
|
739
|
+
return { ...base, reason: 'no file-naming convention detected — cannot propose a name' };
|
|
740
|
+
}
|
|
741
|
+
if (confidence < HARD_FLOOR) {
|
|
742
|
+
return {
|
|
743
|
+
...base,
|
|
744
|
+
reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the hard floor ${(HARD_FLOOR * 100).toFixed(0)}% — refusing (not overridable)`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (confidence < threshold && !force) {
|
|
748
|
+
return {
|
|
749
|
+
...base,
|
|
750
|
+
reason: `file-naming consistency ${(confidence * 100).toFixed(0)}% is below the threshold ${(threshold * 100).toFixed(0)}% — refusing (use --force to override above the ${(HARD_FLOOR * 100).toFixed(0)}% floor)`,
|
|
751
|
+
};
|
|
581
752
|
}
|
|
753
|
+
|
|
754
|
+
const styledStem = toNamingStyle(_stem(name), fileNaming.dominant);
|
|
755
|
+
const forced = confidence < threshold && force;
|
|
756
|
+
const proposal = {
|
|
757
|
+
filename: `${styledStem}.${ext}`,
|
|
758
|
+
namingStyle: fileNaming.dominant,
|
|
759
|
+
exportStyle: exportStyle.dominant || 'named',
|
|
760
|
+
testFile: _testFile(styledStem, conventions.testFramework, ext),
|
|
761
|
+
testFramework: conventions.testFramework || null,
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
...base,
|
|
766
|
+
ok: true,
|
|
767
|
+
refused: false,
|
|
768
|
+
forced,
|
|
769
|
+
warning: forced
|
|
770
|
+
? `proposed below the ${(threshold * 100).toFixed(0)}% threshold (--force); conventions are only ${tier}`
|
|
771
|
+
: null,
|
|
772
|
+
reason: forced ? 'forced proposal above the hard floor' : `conventions are ${tier} — proposing`,
|
|
773
|
+
proposal,
|
|
774
|
+
};
|
|
582
775
|
}
|
|
583
776
|
|
|
777
|
+
module.exports = { proposeScaffold, DEFAULT_THRESHOLD, HARD_FLOOR };
|
|
778
|
+
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
__factories["./src/plan/verify-plan"] = function(module, exports) {
|
|
782
|
+
|
|
584
783
|
/**
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
784
|
+
* verify-plan (IMPL.md §6.1 — Gap 2, step 2 of the `create` pipeline).
|
|
785
|
+
*
|
|
786
|
+
* Checks a plan (markdown) against the LIVE index before execution: do the
|
|
787
|
+
* referenced files and symbols exist, is the blast radius acceptable, is the
|
|
788
|
+
* scope in bounds? Catches Cause 1+2 at plan time — cheaper than after the
|
|
789
|
+
* code is written. Reuses the verify primitives + the impact graph.
|
|
790
|
+
* Zero-dependency, bundle-safe.
|
|
589
791
|
*/
|
|
590
|
-
function freshen(cwd, opts = {}) {
|
|
591
|
-
const now = opts.now != null ? opts.now : Date.now();
|
|
592
|
-
if (!opts.force) {
|
|
593
|
-
if (now - (_lastRun.get(cwd) || 0) < THROTTLE_MS) return 0;
|
|
594
|
-
}
|
|
595
|
-
_lastRun.set(cwd, now);
|
|
596
792
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
793
|
+
const fs = require('fs');
|
|
794
|
+
const path = require('path');
|
|
795
|
+
const { extractFilePaths, extractSymbols } = __require('./src/verify/parsers');
|
|
796
|
+
const { buildSymbolSet } = __require('./src/verify/hallucination-guard');
|
|
797
|
+
const { closestMatch } = __require('./src/verify/closest-match');
|
|
798
|
+
const { analyzeImpact } = __require('./src/graph/impact');
|
|
603
799
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
const exclude = new Set([...DEFAULT_EXCLUDE, ...(Array.isArray(cfg.exclude) ? cfg.exclude : [])]);
|
|
607
|
-
const maxDepth = Number.isFinite(cfg.maxDepth) ? cfg.maxDepth : 8;
|
|
800
|
+
const DEFAULT_BLAST_THRESHOLD = 20; // transitive+direct dependents → "high blast radius"
|
|
801
|
+
const DEFAULT_SCOPE_THRESHOLD = 10; // distinct referenced files → "broad scope"
|
|
608
802
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
}
|
|
803
|
+
/** Resolve a referenced path against cwd (handles a leading "./"). */
|
|
804
|
+
function _fileExists(cwd, ref) {
|
|
805
|
+
const clean = ref.replace(/^\.\//, '');
|
|
806
|
+
for (const c of [path.resolve(cwd, clean), path.resolve(cwd, ref)]) {
|
|
807
|
+
try { if (fs.existsSync(c)) return true; } catch (_) {}
|
|
808
|
+
}
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
614
811
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
812
|
+
/**
|
|
813
|
+
* Verify a plan against the live index.
|
|
814
|
+
* @param {string} planText the plan as markdown
|
|
815
|
+
* @param {string} cwd repo root
|
|
816
|
+
* @param {object} [opts]
|
|
817
|
+
* @param {number} [opts.blastThreshold=20]
|
|
818
|
+
* @param {number} [opts.scopeThreshold=10]
|
|
819
|
+
* @param {(ref:string)=>boolean} [opts.fileExists] override for testing
|
|
820
|
+
* @returns {{ issues: object[], blast: object[], scope: object, summary: object }}
|
|
821
|
+
*/
|
|
822
|
+
function verifyPlan(planText, cwd, opts = {}) {
|
|
823
|
+
const blastThreshold = opts.blastThreshold != null ? opts.blastThreshold : DEFAULT_BLAST_THRESHOLD;
|
|
824
|
+
const scopeThreshold = opts.scopeThreshold != null ? opts.scopeThreshold : DEFAULT_SCOPE_THRESHOLD;
|
|
825
|
+
const fileExists = opts.fileExists || ((ref) => _fileExists(cwd, ref));
|
|
826
|
+
|
|
827
|
+
const text = String(planText || '');
|
|
828
|
+
const filesRef = extractFilePaths(text); // [{ path, line }]
|
|
829
|
+
const symbolsRef = extractSymbols(text); // [{ name, line }]
|
|
830
|
+
const { set: symbolSet, symbolCandidates } = buildSymbolSet(cwd);
|
|
831
|
+
|
|
832
|
+
const issues = [];
|
|
833
|
+
|
|
834
|
+
// 1. Referenced files must exist.
|
|
835
|
+
const existingFiles = [];
|
|
836
|
+
for (const f of filesRef) {
|
|
837
|
+
if (fileExists(f.path)) existingFiles.push(f.path);
|
|
838
|
+
else issues.push({ type: 'missing-file', ref: f.path, line: f.line, severity: 'error' });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// 2. Referenced symbols must exist in the live index (suggest a near match).
|
|
842
|
+
for (const s of symbolsRef) {
|
|
843
|
+
if (symbolSet.has(s.name)) continue;
|
|
844
|
+
const match = closestMatch(s.name, symbolCandidates);
|
|
845
|
+
issues.push({
|
|
846
|
+
type: 'unknown-symbol', ref: s.name, line: s.line, severity: 'error',
|
|
847
|
+
suggestion: match ? match.name : null,
|
|
618
848
|
});
|
|
619
|
-
|
|
849
|
+
}
|
|
620
850
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
851
|
+
// 3. Blast radius for each existing referenced file (one graph build).
|
|
852
|
+
const blast = [];
|
|
853
|
+
if (existingFiles.length) {
|
|
854
|
+
let impacts = [];
|
|
855
|
+
try { impacts = analyzeImpact(existingFiles, cwd, {}); } catch (_) { impacts = []; }
|
|
856
|
+
for (const { file, impact } of impacts) {
|
|
857
|
+
const entry = { file, totalImpact: impact.totalImpact, tests: impact.tests.length };
|
|
858
|
+
blast.push(entry);
|
|
859
|
+
if (impact.totalImpact > blastThreshold) {
|
|
860
|
+
issues.push({ type: 'high-blast-radius', ref: file, count: impact.totalImpact, severity: 'warn' });
|
|
861
|
+
}
|
|
628
862
|
}
|
|
629
|
-
|
|
630
|
-
|
|
863
|
+
blast.sort((a, b) => b.totalImpact - a.totalImpact);
|
|
864
|
+
}
|
|
631
865
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
866
|
+
// 4. Scope.
|
|
867
|
+
const scope = { files: filesRef.length, symbols: symbolsRef.length, threshold: scopeThreshold };
|
|
868
|
+
if (filesRef.length > scopeThreshold) {
|
|
869
|
+
issues.push({ type: 'broad-scope', count: filesRef.length, threshold: scopeThreshold, severity: 'warn' });
|
|
636
870
|
}
|
|
871
|
+
|
|
872
|
+
const errors = issues.filter((i) => i.severity === 'error').length;
|
|
873
|
+
const warnings = issues.filter((i) => i.severity === 'warn').length;
|
|
874
|
+
return {
|
|
875
|
+
issues,
|
|
876
|
+
blast,
|
|
877
|
+
scope,
|
|
878
|
+
summary: {
|
|
879
|
+
filesReferenced: filesRef.length,
|
|
880
|
+
symbolsReferenced: symbolsRef.length,
|
|
881
|
+
errors,
|
|
882
|
+
warnings,
|
|
883
|
+
ok: errors === 0,
|
|
884
|
+
},
|
|
885
|
+
};
|
|
637
886
|
}
|
|
638
887
|
|
|
639
|
-
module.exports = {
|
|
888
|
+
module.exports = { verifyPlan, DEFAULT_BLAST_THRESHOLD, DEFAULT_SCOPE_THRESHOLD };
|
|
640
889
|
|
|
641
890
|
};
|
|
891
|
+
|
|
642
892
|
// ── ./src/extractors/dispatch ──
|
|
643
893
|
__factories["./src/extractors/dispatch"] = function(module, exports) {
|
|
644
894
|
|
|
@@ -7119,7 +7369,7 @@ __factories["./src/mcp/server"] = function(module, exports) {
|
|
|
7119
7369
|
|
|
7120
7370
|
const SERVER_INFO = {
|
|
7121
7371
|
name: 'sigmap',
|
|
7122
|
-
version: '7.
|
|
7372
|
+
version: '7.12.0',
|
|
7123
7373
|
description: 'SigMap MCP server — code signatures on demand',
|
|
7124
7374
|
};
|
|
7125
7375
|
|
|
@@ -12797,7 +13047,7 @@ function __tryGit(args, opts = {}) {
|
|
|
12797
13047
|
catch (_) { return ''; }
|
|
12798
13048
|
}
|
|
12799
13049
|
|
|
12800
|
-
const VERSION = '7.
|
|
13050
|
+
const VERSION = '7.12.0';
|
|
12801
13051
|
const MARKER = '\n\n## Auto-generated signatures\n<!-- Updated by gen-context.js -->\n';
|
|
12802
13052
|
|
|
12803
13053
|
function requireSourceOrBundled(key) {
|
|
@@ -16151,6 +16401,47 @@ function main() {
|
|
|
16151
16401
|
}
|
|
16152
16402
|
|
|
16153
16403
|
// `sigmap verify-ai-output <answer.md>` — Hallucination Guard (deterministic core)
|
|
16404
|
+
// Gap 2 (step 2): `sigmap verify-plan <plan.md>` — check a plan against the
|
|
16405
|
+
// live index before execution (files/symbols exist, blast radius, scope).
|
|
16406
|
+
if (args[0] === 'verify-plan') {
|
|
16407
|
+
const target = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
16408
|
+
const jsonOut = args.includes('--json');
|
|
16409
|
+
if (!target) {
|
|
16410
|
+
console.error('[sigmap] Usage: sigmap verify-plan <plan.md|-> [--json]');
|
|
16411
|
+
process.exit(1);
|
|
16412
|
+
}
|
|
16413
|
+
let planText = '';
|
|
16414
|
+
try {
|
|
16415
|
+
planText = fs.readFileSync(target === '-' ? 0 : path.resolve(cwd, target), 'utf8');
|
|
16416
|
+
} catch (e) {
|
|
16417
|
+
console.error(`[sigmap] cannot read plan: ${e.message}`);
|
|
16418
|
+
process.exit(1);
|
|
16419
|
+
}
|
|
16420
|
+
const { verifyPlan } = requireSourceOrBundled('./src/plan/verify-plan');
|
|
16421
|
+
const result = verifyPlan(planText, cwd);
|
|
16422
|
+
|
|
16423
|
+
if (jsonOut) {
|
|
16424
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
16425
|
+
process.exit(result.summary.ok ? 0 : 1);
|
|
16426
|
+
}
|
|
16427
|
+
|
|
16428
|
+
const s = result.summary;
|
|
16429
|
+
console.log(`[sigmap] verify-plan — ${s.filesReferenced} file(s), ${s.symbolsReferenced} symbol(s) referenced`);
|
|
16430
|
+
if (result.issues.length === 0) {
|
|
16431
|
+
console.log(' ✓ plan checks out — all references exist, blast radius and scope in bounds');
|
|
16432
|
+
process.exit(0);
|
|
16433
|
+
}
|
|
16434
|
+
for (const i of result.issues) {
|
|
16435
|
+
const mark = i.severity === 'error' ? '✗' : '⚠';
|
|
16436
|
+
if (i.type === 'missing-file') console.log(` ${mark} missing file: ${i.ref} (line ${i.line})`);
|
|
16437
|
+
else if (i.type === 'unknown-symbol') console.log(` ${mark} unknown symbol: ${i.ref}()${i.suggestion ? ` — did you mean ${i.suggestion}()?` : ''} (line ${i.line})`);
|
|
16438
|
+
else if (i.type === 'high-blast-radius') console.log(` ${mark} high blast radius: ${i.ref} → ${i.count} dependents`);
|
|
16439
|
+
else if (i.type === 'broad-scope') console.log(` ${mark} broad scope: ${i.count} files (threshold ${i.threshold})`);
|
|
16440
|
+
}
|
|
16441
|
+
console.log(`\n ${s.errors} error(s), ${s.warnings} warning(s)`);
|
|
16442
|
+
process.exit(s.ok ? 0 : 1);
|
|
16443
|
+
}
|
|
16444
|
+
|
|
16154
16445
|
if (args[0] === 'verify-ai-output') {
|
|
16155
16446
|
const target = args[1];
|
|
16156
16447
|
const jsonOut = args.includes('--json');
|
|
@@ -16208,6 +16499,60 @@ function main() {
|
|
|
16208
16499
|
process.exit(1);
|
|
16209
16500
|
}
|
|
16210
16501
|
|
|
16502
|
+
// Gap 2 (step 4): `sigmap review-pr` — diff audit (scope drift, god-nodes,
|
|
16503
|
+
// missing tests, security-sensitive files). Last guard stage of `create`.
|
|
16504
|
+
if (args[0] === 'review-pr') {
|
|
16505
|
+
const jsonOut = args.includes('--json');
|
|
16506
|
+
const staged = args.includes('--staged');
|
|
16507
|
+
const baseIdx = args.indexOf('--base');
|
|
16508
|
+
const baseArg = baseIdx !== -1 && args[baseIdx + 1] && !args[baseIdx + 1].startsWith('--') ? args[baseIdx + 1] : null;
|
|
16509
|
+
|
|
16510
|
+
let nameStatus = '';
|
|
16511
|
+
if (staged) {
|
|
16512
|
+
nameStatus = __tryGit(['diff', '--cached', '--name-status'], { cwd });
|
|
16513
|
+
} else {
|
|
16514
|
+
let base = baseArg;
|
|
16515
|
+
if (!base) {
|
|
16516
|
+
for (const ref of ['main', 'develop']) {
|
|
16517
|
+
if (__tryGit(['rev-parse', '--verify', '--quiet', ref], { cwd })) { base = ref; break; }
|
|
16518
|
+
}
|
|
16519
|
+
}
|
|
16520
|
+
const mb = base ? (__tryGit(['merge-base', 'HEAD', base], { cwd }) || base) : 'HEAD~1';
|
|
16521
|
+
nameStatus = __tryGit(['diff', '--name-status', `${mb}..HEAD`], { cwd });
|
|
16522
|
+
}
|
|
16523
|
+
|
|
16524
|
+
const changedFiles = nameStatus.split('\n').filter(Boolean).map((line) => {
|
|
16525
|
+
const parts = line.split('\t');
|
|
16526
|
+
const status = parts[0][0]; // M/A/D/R…
|
|
16527
|
+
const file = parts[parts.length - 1]; // rename → new path
|
|
16528
|
+
return { path: file, status };
|
|
16529
|
+
});
|
|
16530
|
+
|
|
16531
|
+
const { reviewPr } = requireSourceOrBundled('./src/review/review-pr');
|
|
16532
|
+
const result = reviewPr(changedFiles, cwd, {});
|
|
16533
|
+
|
|
16534
|
+
if (jsonOut) {
|
|
16535
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
16536
|
+
process.exit(result.summary.ok ? 0 : 1);
|
|
16537
|
+
}
|
|
16538
|
+
|
|
16539
|
+
const s = result.summary;
|
|
16540
|
+
console.log(`[sigmap] review-pr — ${s.filesChanged} file(s) changed (${s.sourceChanged} source, ${s.testsChanged} test)`);
|
|
16541
|
+
if (s.findings === 0) {
|
|
16542
|
+
console.log(' ✓ no findings — scope, tests, blast radius, and sensitive files all clear');
|
|
16543
|
+
process.exit(0);
|
|
16544
|
+
}
|
|
16545
|
+
const label = { 'missing-tests': 'missing tests', 'security-file': 'security file', 'god-node': 'god node', 'scope-drift': 'scope drift' };
|
|
16546
|
+
for (const f of result.findings) {
|
|
16547
|
+
if (f.type === 'missing-tests') console.log(` ⚠ ${label[f.type]}: ${f.file} changed with no matching test`);
|
|
16548
|
+
else if (f.type === 'security-file') console.log(` ⚠ ${label[f.type]}: ${f.file}`);
|
|
16549
|
+
else if (f.type === 'god-node') console.log(` ⚠ ${label[f.type]}: ${f.file} → ${f.count} dependents`);
|
|
16550
|
+
else if (f.type === 'scope-drift') console.log(` ⚠ ${label[f.type]}: ${f.count} top-level dirs (${f.dirs.join(', ')})`);
|
|
16551
|
+
}
|
|
16552
|
+
console.log(`\n ${s.findings} finding(s)`);
|
|
16553
|
+
process.exit(1);
|
|
16554
|
+
}
|
|
16555
|
+
|
|
16211
16556
|
// Feature 1: `sigmap explain <file>` — why a file is included or excluded
|
|
16212
16557
|
if (args[0] === 'explain' || args.includes('--explain')) {
|
|
16213
16558
|
const target = args[0] === 'explain'
|