pan-wizard 3.4.1 → 3.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -8
- package/agents/pan-distiller.md +82 -0
- package/agents/pan-optimizer.md +242 -0
- package/bin/install.js +50 -1
- package/commands/pan/focus-auto.md +150 -3
- package/commands/pan/focus-exec.md +11 -0
- package/commands/pan/focus-scan.md +6 -0
- package/commands/pan/git.md +223 -0
- package/commands/pan/learn.md +61 -0
- package/commands/pan/milestone-done.md +9 -0
- package/commands/pan/optimize.md +86 -0
- package/hooks/dist/pan-trace-logger.js +197 -0
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +1 -0
- package/pan-wizard-core/bin/lib/constants.cjs +5 -1
- package/pan-wizard-core/bin/lib/distill.cjs +510 -0
- package/pan-wizard-core/bin/lib/focus.cjs +8 -1
- package/pan-wizard-core/bin/lib/git.cjs +407 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +653 -0
- package/pan-wizard-core/bin/pan-tools.cjs +78 -0
- package/pan-wizard-core/workflows/exec-phase.md +97 -0
- package/pan-wizard-core/workflows/learn.md +91 -0
- package/pan-wizard-core/workflows/optimize.md +139 -0
- package/pan-wizard-core/workflows/plan-phase.md +27 -0
- package/pan-wizard-core/workflows/quick.md +7 -0
- package/pan-wizard-core/workflows/verify-phase.md +16 -0
- package/scripts/build-hooks.js +2 -1
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { output, error, safeReadFile } = require('./core.cjs');
|
|
6
|
+
|
|
7
|
+
const PLANNING_DIR = '.planning';
|
|
8
|
+
const MEMORY_DIR = path.join(PLANNING_DIR, 'memory');
|
|
9
|
+
const PATTERNS_FILE = 'distill-patterns.md';
|
|
10
|
+
|
|
11
|
+
const SAFETY_TIERS = { SAFE: 'safe', REVIEW: 'review_required', RISKY: 'risky' };
|
|
12
|
+
const DEFAULT_BLOAT_THRESHOLD = 2.0;
|
|
13
|
+
const MAX_FUNCTION_LOC = 50;
|
|
14
|
+
const MAX_PARAM_COUNT = 4;
|
|
15
|
+
const MAX_NESTING_DEPTH = 3;
|
|
16
|
+
const MIN_REPEATED_LINES = 5;
|
|
17
|
+
const PATTERN_TTL_DAYS = 90;
|
|
18
|
+
const MAX_PATTERNS_KEPT = 100;
|
|
19
|
+
|
|
20
|
+
const SCANNABLE_EXTS = ['.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx', '.py'];
|
|
21
|
+
|
|
22
|
+
// ─── Pass 1: Deterministic static analysis ───────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function findPhantomTryCatch(content, filePath) {
|
|
25
|
+
const findings = [];
|
|
26
|
+
const re = /try\s*\{\s*(?:return\s+)?(JSON\.parse|JSON\.stringify|Number|String|Boolean|parseInt|parseFloat)\([^)]*\)\s*;?\s*\}\s*catch/g;
|
|
27
|
+
let match;
|
|
28
|
+
while ((match = re.exec(content)) !== null) {
|
|
29
|
+
const line = content.slice(0, match.index).split('\n').length;
|
|
30
|
+
findings.push({
|
|
31
|
+
pattern: 'phantom_try_catch',
|
|
32
|
+
file: filePath,
|
|
33
|
+
line,
|
|
34
|
+
span: match[0].slice(0, 80),
|
|
35
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
36
|
+
loc_saved: 4,
|
|
37
|
+
confidence: 0.9,
|
|
38
|
+
message: `Phantom try/catch around ${match[1]}() — does not throw in this form`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return findings;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findUnusedImports(content, filePath) {
|
|
45
|
+
const findings = [];
|
|
46
|
+
const importRe = /^(?:const|let|var)\s+\{?\s*([\w,\s]+)\s*\}?\s*=\s*require\(['"]([^'"]+)['"]\)/gm;
|
|
47
|
+
const esImportRe = /^import\s+(?:\{?\s*([\w,\s]+)\s*\}?\s*from\s+)?['"]([^'"]+)['"]/gm;
|
|
48
|
+
const collected = [];
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = importRe.exec(content)) !== null) collected.push({ names: m[1], line: content.slice(0, m.index).split('\n').length });
|
|
51
|
+
while ((m = esImportRe.exec(content)) !== null) {
|
|
52
|
+
if (m[1]) collected.push({ names: m[1], line: content.slice(0, m.index).split('\n').length });
|
|
53
|
+
}
|
|
54
|
+
// Strip string literals so 'fs' inside require('fs') doesn't count as a use of fs
|
|
55
|
+
const stripped = content.replace(/(['"])(?:\\.|(?!\1).)*\1/g, '""');
|
|
56
|
+
for (const imp of collected) {
|
|
57
|
+
const names = imp.names.split(',').map(s => s.trim()).filter(Boolean);
|
|
58
|
+
for (const name of names) {
|
|
59
|
+
const cleanName = name.replace(/\s+as\s+\w+/, '').trim();
|
|
60
|
+
if (!cleanName) continue;
|
|
61
|
+
const usageRe = new RegExp('\\b' + cleanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
62
|
+
const uses = (stripped.match(usageRe) || []).length;
|
|
63
|
+
if (uses <= 1) {
|
|
64
|
+
findings.push({
|
|
65
|
+
pattern: 'unused_import',
|
|
66
|
+
file: filePath,
|
|
67
|
+
line: imp.line,
|
|
68
|
+
span: cleanName,
|
|
69
|
+
tier: SAFETY_TIERS.SAFE,
|
|
70
|
+
loc_saved: 1,
|
|
71
|
+
confidence: 0.95,
|
|
72
|
+
message: `Unused import: ${cleanName}`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function findMagicNumbers(content, filePath) {
|
|
81
|
+
const findings = [];
|
|
82
|
+
const numberRe = /(?<!\w)(\d{3,})(?!\w)/g;
|
|
83
|
+
const occurrences = {};
|
|
84
|
+
let m;
|
|
85
|
+
while ((m = numberRe.exec(content)) !== null) {
|
|
86
|
+
const num = m[1];
|
|
87
|
+
if (num === '100' || num === '1000') continue;
|
|
88
|
+
if (!occurrences[num]) occurrences[num] = [];
|
|
89
|
+
occurrences[num].push(content.slice(0, m.index).split('\n').length);
|
|
90
|
+
}
|
|
91
|
+
for (const [num, lines] of Object.entries(occurrences)) {
|
|
92
|
+
if (lines.length >= 3) {
|
|
93
|
+
findings.push({
|
|
94
|
+
pattern: 'magic_number',
|
|
95
|
+
file: filePath,
|
|
96
|
+
line: lines[0],
|
|
97
|
+
span: num,
|
|
98
|
+
tier: SAFETY_TIERS.SAFE,
|
|
99
|
+
loc_saved: 0,
|
|
100
|
+
confidence: 0.85,
|
|
101
|
+
message: `Magic number ${num} used ${lines.length}x — extract to named constant`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return findings;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findLongFunctions(content, filePath) {
|
|
109
|
+
const findings = [];
|
|
110
|
+
const fnRe = /(?:^|\n)\s*(?:async\s+)?function\s+(\w+)\s*\([^)]*\)\s*\{/g;
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = fnRe.exec(content)) !== null) {
|
|
113
|
+
const startLine = content.slice(0, m.index).split('\n').length;
|
|
114
|
+
let depth = 0;
|
|
115
|
+
let i = m.index + m[0].length - 1;
|
|
116
|
+
let lineCount = 0;
|
|
117
|
+
for (; i < content.length; i++) {
|
|
118
|
+
if (content[i] === '{') depth++;
|
|
119
|
+
else if (content[i] === '}') { depth--; if (depth === 0) break; }
|
|
120
|
+
else if (content[i] === '\n') lineCount++;
|
|
121
|
+
}
|
|
122
|
+
if (lineCount > MAX_FUNCTION_LOC) {
|
|
123
|
+
findings.push({
|
|
124
|
+
pattern: 'long_function',
|
|
125
|
+
file: filePath,
|
|
126
|
+
line: startLine,
|
|
127
|
+
span: m[1],
|
|
128
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
129
|
+
loc_saved: Math.floor(lineCount * 0.3),
|
|
130
|
+
confidence: 0.75,
|
|
131
|
+
message: `Function ${m[1]}() is ${lineCount} LOC (limit ${MAX_FUNCTION_LOC}) — decompose`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findWideParamLists(content, filePath) {
|
|
139
|
+
const findings = [];
|
|
140
|
+
const fnRe = /(?:^|\n)\s*(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g;
|
|
141
|
+
let m;
|
|
142
|
+
while ((m = fnRe.exec(content)) !== null) {
|
|
143
|
+
const params = m[2].split(',').map(s => s.trim()).filter(Boolean);
|
|
144
|
+
if (params.length > MAX_PARAM_COUNT) {
|
|
145
|
+
findings.push({
|
|
146
|
+
pattern: 'wide_params',
|
|
147
|
+
file: filePath,
|
|
148
|
+
line: content.slice(0, m.index).split('\n').length,
|
|
149
|
+
span: m[1],
|
|
150
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
151
|
+
loc_saved: 0,
|
|
152
|
+
confidence: 0.85,
|
|
153
|
+
message: `Function ${m[1]}() has ${params.length} params (limit ${MAX_PARAM_COUNT}) — use opts object`,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return findings;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Pass 2: AST-style structural analysis ───────────────────────────────────
|
|
161
|
+
|
|
162
|
+
function findSingleInstanceFactories(content, filePath) {
|
|
163
|
+
const findings = [];
|
|
164
|
+
const classRe = /class\s+(\w+Factory)\s*\{[^}]*\}/g;
|
|
165
|
+
let m;
|
|
166
|
+
while ((m = classRe.exec(content)) !== null) {
|
|
167
|
+
const className = m[1];
|
|
168
|
+
const usageRe = new RegExp('new\\s+' + className + '\\b', 'g');
|
|
169
|
+
const instances = (content.match(usageRe) || []).length;
|
|
170
|
+
if (instances <= 1) {
|
|
171
|
+
findings.push({
|
|
172
|
+
pattern: 'single_instance_factory',
|
|
173
|
+
file: filePath,
|
|
174
|
+
line: content.slice(0, m.index).split('\n').length,
|
|
175
|
+
span: className,
|
|
176
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
177
|
+
loc_saved: 5,
|
|
178
|
+
confidence: 0.7,
|
|
179
|
+
message: `${className} is instantiated ${instances}x — replace with module function`,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return findings;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function findDeepNesting(content, filePath) {
|
|
187
|
+
const findings = [];
|
|
188
|
+
const lines = content.split('\n');
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
const indent = lines[i].match(/^(\s*)/)[1].length;
|
|
191
|
+
const depth = Math.floor(indent / 2);
|
|
192
|
+
if (depth > MAX_NESTING_DEPTH && lines[i].trim().match(/^(if|for|while|switch)\b/)) {
|
|
193
|
+
findings.push({
|
|
194
|
+
pattern: 'deep_nesting',
|
|
195
|
+
file: filePath,
|
|
196
|
+
line: i + 1,
|
|
197
|
+
span: lines[i].trim().slice(0, 50),
|
|
198
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
199
|
+
loc_saved: 2,
|
|
200
|
+
confidence: 0.7,
|
|
201
|
+
message: `Nesting depth ${depth} (limit ${MAX_NESTING_DEPTH}) — extract to function or use early return`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return findings;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ─── Pass 3: Graph-based cross-file analysis ─────────────────────────────────
|
|
209
|
+
|
|
210
|
+
function findRepeatedBlocks(filesContent) {
|
|
211
|
+
const findings = [];
|
|
212
|
+
const blockMap = {};
|
|
213
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
214
|
+
const lines = content.split('\n');
|
|
215
|
+
for (let i = 0; i + MIN_REPEATED_LINES <= lines.length; i++) {
|
|
216
|
+
const block = lines.slice(i, i + MIN_REPEATED_LINES).map(l => l.trim()).join('\n');
|
|
217
|
+
if (block.length < 50 || block.includes('//')) continue;
|
|
218
|
+
if (!blockMap[block]) blockMap[block] = [];
|
|
219
|
+
blockMap[block].push({ file: filePath, line: i + 1 });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const [block, locations] of Object.entries(blockMap)) {
|
|
223
|
+
const uniqueFiles = new Set(locations.map(l => l.file));
|
|
224
|
+
if (locations.length >= 2 && uniqueFiles.size >= 2) {
|
|
225
|
+
findings.push({
|
|
226
|
+
pattern: 'repeated_block',
|
|
227
|
+
file: locations[0].file,
|
|
228
|
+
line: locations[0].line,
|
|
229
|
+
span: block.slice(0, 80),
|
|
230
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
231
|
+
loc_saved: MIN_REPEATED_LINES * (locations.length - 1),
|
|
232
|
+
confidence: 0.8,
|
|
233
|
+
locations,
|
|
234
|
+
message: `Block of ${MIN_REPEATED_LINES} lines repeated ${locations.length}x across ${uniqueFiles.size} files — extract helper`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return findings;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function findUnreferencedExports(filesContent) {
|
|
242
|
+
const findings = [];
|
|
243
|
+
const exports = {};
|
|
244
|
+
const allContent = Object.values(filesContent).join('\n');
|
|
245
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
246
|
+
const exportRe = /(?:^|\n)\s*(?:module\.exports\s*=\s*\{[^}]*?(\w+)|exports\.(\w+)\s*=|export\s+(?:const|function|class)\s+(\w+))/g;
|
|
247
|
+
let m;
|
|
248
|
+
while ((m = exportRe.exec(content)) !== null) {
|
|
249
|
+
const name = m[1] || m[2] || m[3];
|
|
250
|
+
if (!name) continue;
|
|
251
|
+
exports[name] = { file: filePath, line: content.slice(0, m.index).split('\n').length };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
for (const [name, loc] of Object.entries(exports)) {
|
|
255
|
+
const usageRe = new RegExp('\\b' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'g');
|
|
256
|
+
const matches = (allContent.match(usageRe) || []).length;
|
|
257
|
+
if (matches <= 1) {
|
|
258
|
+
findings.push({
|
|
259
|
+
pattern: 'unreferenced_export',
|
|
260
|
+
file: loc.file,
|
|
261
|
+
line: loc.line,
|
|
262
|
+
span: name,
|
|
263
|
+
tier: SAFETY_TIERS.REVIEW,
|
|
264
|
+
loc_saved: 3,
|
|
265
|
+
confidence: 0.85,
|
|
266
|
+
message: `Export ${name} is not referenced outside its file`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return findings;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── File scanning ────────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
function gatherFiles(cwd, opts) {
|
|
276
|
+
const ignore = (opts && opts.ignore) || ['node_modules', '.git', 'dist', 'build', '.planning', '.claude', '.codex', '.gemini', '.opencode', '.github'];
|
|
277
|
+
const files = [];
|
|
278
|
+
function walk(dir) {
|
|
279
|
+
let entries;
|
|
280
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
281
|
+
for (const entry of entries) {
|
|
282
|
+
if (ignore.includes(entry.name)) continue;
|
|
283
|
+
const full = path.join(dir, entry.name);
|
|
284
|
+
if (entry.isDirectory()) walk(full);
|
|
285
|
+
else if (entry.isFile() && SCANNABLE_EXTS.includes(path.extname(entry.name))) {
|
|
286
|
+
files.push(full);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
walk(cwd);
|
|
291
|
+
return files;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function loadFiles(filePaths, cwd) {
|
|
295
|
+
const out = {};
|
|
296
|
+
for (const f of filePaths) {
|
|
297
|
+
const content = safeReadFile(f);
|
|
298
|
+
if (content) out[path.relative(cwd, f).replace(/\\/g, '/')] = content;
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Main scan: runs all 5 passes ─────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
function distillScan(cwd, opts) {
|
|
306
|
+
const filePaths = gatherFiles(cwd, opts);
|
|
307
|
+
const filesContent = loadFiles(filePaths, cwd);
|
|
308
|
+
const allFindings = [];
|
|
309
|
+
|
|
310
|
+
for (const [filePath, content] of Object.entries(filesContent)) {
|
|
311
|
+
allFindings.push(...findPhantomTryCatch(content, filePath));
|
|
312
|
+
allFindings.push(...findUnusedImports(content, filePath));
|
|
313
|
+
allFindings.push(...findMagicNumbers(content, filePath));
|
|
314
|
+
allFindings.push(...findLongFunctions(content, filePath));
|
|
315
|
+
allFindings.push(...findWideParamLists(content, filePath));
|
|
316
|
+
allFindings.push(...findSingleInstanceFactories(content, filePath));
|
|
317
|
+
allFindings.push(...findDeepNesting(content, filePath));
|
|
318
|
+
}
|
|
319
|
+
allFindings.push(...findRepeatedBlocks(filesContent));
|
|
320
|
+
allFindings.push(...findUnreferencedExports(filesContent));
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
findings: allFindings,
|
|
324
|
+
files_scanned: filePaths.length,
|
|
325
|
+
by_pattern: groupByPattern(allFindings),
|
|
326
|
+
by_tier: groupByTier(allFindings),
|
|
327
|
+
total_loc_saved: allFindings.reduce((s, f) => s + (f.loc_saved || 0), 0),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function groupByPattern(findings) {
|
|
332
|
+
const map = {};
|
|
333
|
+
for (const f of findings) map[f.pattern] = (map[f.pattern] || 0) + 1;
|
|
334
|
+
return map;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function groupByTier(findings) {
|
|
338
|
+
const map = { safe: 0, review_required: 0, risky: 0 };
|
|
339
|
+
for (const f of findings) map[f.tier] = (map[f.tier] || 0) + 1;
|
|
340
|
+
return map;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Bloat budget ────────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
function computeBloatBudget(touchedLoc, findings, threshold) {
|
|
346
|
+
const t = threshold || DEFAULT_BLOAT_THRESHOLD;
|
|
347
|
+
const removableLoc = findings.reduce((s, f) => s + (f.loc_saved || 0), 0);
|
|
348
|
+
const essentialLoc = Math.max(1, touchedLoc - removableLoc);
|
|
349
|
+
const ratio = touchedLoc / essentialLoc;
|
|
350
|
+
return {
|
|
351
|
+
touched_loc: touchedLoc,
|
|
352
|
+
essential_loc: essentialLoc,
|
|
353
|
+
removable_loc: removableLoc,
|
|
354
|
+
ratio: Number(ratio.toFixed(2)),
|
|
355
|
+
threshold: t,
|
|
356
|
+
over_budget: ratio > t,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── Pass 5: Cross-session memory ────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function readPatternsMemory(cwd) {
|
|
363
|
+
const filePath = path.join(cwd, MEMORY_DIR, PATTERNS_FILE);
|
|
364
|
+
const content = safeReadFile(filePath);
|
|
365
|
+
if (!content) return { patterns: [], file: filePath };
|
|
366
|
+
const patterns = [];
|
|
367
|
+
const re = /^- \[(\d{4}-\d{2}-\d{2})\] `([^`]+)` in `([^`]+)`(?:\s*—\s*(.*))?$/gm;
|
|
368
|
+
let m;
|
|
369
|
+
while ((m = re.exec(content)) !== null) {
|
|
370
|
+
patterns.push({ date: m[1], pattern: m[2], file: m[3], note: m[4] || '' });
|
|
371
|
+
}
|
|
372
|
+
return { patterns, file: filePath };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function detectRegressedPatterns(currentFindings, memory) {
|
|
376
|
+
const regressed = [];
|
|
377
|
+
for (const f of currentFindings) {
|
|
378
|
+
const prior = memory.patterns.find(p => p.pattern === f.pattern && p.file === f.file);
|
|
379
|
+
if (prior) {
|
|
380
|
+
regressed.push({
|
|
381
|
+
...f,
|
|
382
|
+
regressed: true,
|
|
383
|
+
previously_resolved: prior.date,
|
|
384
|
+
message: f.message + ` (REGRESSED — last resolved ${prior.date})`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return regressed;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function writePatternsMemory(cwd, findings, opts) {
|
|
392
|
+
const dir = path.join(cwd, MEMORY_DIR);
|
|
393
|
+
try { fs.mkdirSync(dir, { recursive: true }); } catch {}
|
|
394
|
+
const filePath = path.join(dir, PATTERNS_FILE);
|
|
395
|
+
const existing = readPatternsMemory(cwd).patterns;
|
|
396
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
397
|
+
const cutoff = new Date(Date.now() - PATTERN_TTL_DAYS * 86400000).toISOString().slice(0, 10);
|
|
398
|
+
|
|
399
|
+
const fresh = existing.filter(p => p.date >= cutoff);
|
|
400
|
+
const newEntries = findings.map(f => ({
|
|
401
|
+
date: today,
|
|
402
|
+
pattern: f.pattern,
|
|
403
|
+
file: f.file,
|
|
404
|
+
note: f.message ? f.message.slice(0, 60) : '',
|
|
405
|
+
}));
|
|
406
|
+
|
|
407
|
+
const dedup = {};
|
|
408
|
+
for (const p of [...fresh, ...newEntries]) {
|
|
409
|
+
dedup[`${p.pattern}:${p.file}`] = p;
|
|
410
|
+
}
|
|
411
|
+
const all = Object.values(dedup).slice(-MAX_PATTERNS_KEPT);
|
|
412
|
+
|
|
413
|
+
const lines = [
|
|
414
|
+
'---',
|
|
415
|
+
'name: distill-patterns',
|
|
416
|
+
'description: Cross-session memory of AI-bloat patterns detected and resolved',
|
|
417
|
+
'type: project',
|
|
418
|
+
'---',
|
|
419
|
+
'',
|
|
420
|
+
'## Pattern History',
|
|
421
|
+
'',
|
|
422
|
+
...all.map(p => `- [${p.date}] \`${p.pattern}\` in \`${p.file}\`${p.note ? ' — ' + p.note : ''}`),
|
|
423
|
+
'',
|
|
424
|
+
];
|
|
425
|
+
try {
|
|
426
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
|
|
427
|
+
return { written: true, file: filePath, count: all.length };
|
|
428
|
+
} catch (e) {
|
|
429
|
+
return { written: false, error: e.message };
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ─── CLI dispatchers ──────────────────────────────────────────────────────────
|
|
434
|
+
|
|
435
|
+
function cmdDistillScan(cwd, opts, raw) {
|
|
436
|
+
const result = distillScan(cwd, opts);
|
|
437
|
+
output(result, raw, `${result.findings.length} finding(s) across ${result.files_scanned} file(s)`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function cmdDistillAnalyze(cwd, opts, raw) {
|
|
441
|
+
const scan = distillScan(cwd, opts);
|
|
442
|
+
const memory = readPatternsMemory(cwd);
|
|
443
|
+
const regressed = detectRegressedPatterns(scan.findings, memory);
|
|
444
|
+
const touched = (opts && opts.touchedLoc) ? parseInt(opts.touchedLoc, 10) : scan.findings.length * 10;
|
|
445
|
+
const budget = computeBloatBudget(touched, scan.findings, opts && opts.bloatThreshold);
|
|
446
|
+
output({
|
|
447
|
+
...scan,
|
|
448
|
+
regressed_count: regressed.length,
|
|
449
|
+
regressed,
|
|
450
|
+
bloat_budget: budget,
|
|
451
|
+
memory_file: memory.file,
|
|
452
|
+
prior_patterns: memory.patterns.length,
|
|
453
|
+
}, raw, `${scan.findings.length} findings | bloat ratio: ${budget.ratio}x | regressed: ${regressed.length}`);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function cmdDistillReport(cwd, opts, raw) {
|
|
457
|
+
const scan = distillScan(cwd, opts);
|
|
458
|
+
const writeResult = writePatternsMemory(cwd, scan.findings, opts);
|
|
459
|
+
output({
|
|
460
|
+
findings_count: scan.findings.length,
|
|
461
|
+
by_tier: scan.by_tier,
|
|
462
|
+
by_pattern: scan.by_pattern,
|
|
463
|
+
memory: writeResult,
|
|
464
|
+
}, raw, writeResult.written ? `Wrote ${writeResult.count} patterns to ${writeResult.file}` : 'Memory write failed');
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function cmdDistill(cwd, subcommand, args, raw) {
|
|
468
|
+
const getOpt = (flag, def) => {
|
|
469
|
+
const i = args.indexOf(flag);
|
|
470
|
+
return i !== -1 && i + 1 < args.length ? args[i + 1] : def;
|
|
471
|
+
};
|
|
472
|
+
const opts = {
|
|
473
|
+
bloatThreshold: getOpt('--bloat-threshold', null),
|
|
474
|
+
touchedLoc: getOpt('--touched-loc', null),
|
|
475
|
+
};
|
|
476
|
+
switch (subcommand) {
|
|
477
|
+
case 'scan': return cmdDistillScan(cwd, opts, raw);
|
|
478
|
+
case 'analyze': return cmdDistillAnalyze(cwd, opts, raw);
|
|
479
|
+
case 'report': return cmdDistillReport(cwd, opts, raw);
|
|
480
|
+
default: error('Unknown distill subcommand: ' + subcommand + '. Available: scan, analyze, report');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
module.exports = {
|
|
485
|
+
cmdDistill,
|
|
486
|
+
cmdDistillScan,
|
|
487
|
+
cmdDistillAnalyze,
|
|
488
|
+
cmdDistillReport,
|
|
489
|
+
distillScan,
|
|
490
|
+
computeBloatBudget,
|
|
491
|
+
readPatternsMemory,
|
|
492
|
+
writePatternsMemory,
|
|
493
|
+
detectRegressedPatterns,
|
|
494
|
+
findPhantomTryCatch,
|
|
495
|
+
findUnusedImports,
|
|
496
|
+
findMagicNumbers,
|
|
497
|
+
findLongFunctions,
|
|
498
|
+
findWideParamLists,
|
|
499
|
+
findSingleInstanceFactories,
|
|
500
|
+
findDeepNesting,
|
|
501
|
+
findRepeatedBlocks,
|
|
502
|
+
findUnreferencedExports,
|
|
503
|
+
SAFETY_TIERS,
|
|
504
|
+
DEFAULT_BLOAT_THRESHOLD,
|
|
505
|
+
MAX_FUNCTION_LOC,
|
|
506
|
+
MAX_PARAM_COUNT,
|
|
507
|
+
MAX_NESTING_DEPTH,
|
|
508
|
+
MIN_REPEATED_LINES,
|
|
509
|
+
PATTERN_TTL_DAYS,
|
|
510
|
+
};
|
|
@@ -783,7 +783,13 @@ function determineStopReason(cycle, run) {
|
|
|
783
783
|
if (cycle.tests_after < cycle.tests_before) return 'regression';
|
|
784
784
|
if (run.totals.points_used >= run.total_budget) return 'budget_cap';
|
|
785
785
|
if (run.totals.cycles_completed >= run.max_cycles) return 'max_cycles';
|
|
786
|
-
if (cycle.items_completed === 0)
|
|
786
|
+
if (cycle.items_completed === 0) {
|
|
787
|
+
// Security category gets a descriptive stop reason rather than generic zero_completed
|
|
788
|
+
if (run.category === 'security') return 'security_complete';
|
|
789
|
+
// Distill category gets a descriptive stop reason — codebase fully distilled
|
|
790
|
+
if (run.category === 'distill') return 'distill_complete';
|
|
791
|
+
return 'zero_completed';
|
|
792
|
+
}
|
|
787
793
|
|
|
788
794
|
// Prompts category: stop when all prompts are completed
|
|
789
795
|
if (run.category === 'prompts' && cycle.prompts_remaining === 0) {
|
|
@@ -848,6 +854,7 @@ function focusAutoInit(cwd, raw, getVal, hasFlag) {
|
|
|
848
854
|
max_cycles: maxCycles,
|
|
849
855
|
total_budget: totalBudget,
|
|
850
856
|
priority_range: category ? CATEGORY_PRIORITY_RANGE[category] : { min: 0, max: 6 },
|
|
857
|
+
deep_review_enabled: hasFlag('--deep-review'),
|
|
851
858
|
tests_baseline: null,
|
|
852
859
|
cycles: [],
|
|
853
860
|
totals: { cycles_completed: 0, items_completed: 0, items_failed: 0, points_used: 0, tests_current: 0 },
|