@stupify/cli 0.0.6 → 0.0.8

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/dist/analysis.js CHANGED
@@ -71,10 +71,14 @@ function uncheckedSearchMatches(value, contexts) {
71
71
  targetId,
72
72
  patternId: context.checkId,
73
73
  reason: match.reason ?? "",
74
- proof: match.proof ?? "",
74
+ proof: sourcePointer(context),
75
75
  }];
76
76
  });
77
77
  }
78
+ function sourcePointer(context) {
79
+ const file = context.filePath ?? "(unknown)";
80
+ return `${file}::${context.entityKind || "entity"}::${context.entityName || context.entityId}`;
81
+ }
78
82
  async function runJsonPrompt(model, prompt, schema, temperature) {
79
83
  return cachedJson("model-json", fingerprint({
80
84
  version: 1,
package/dist/checks.js CHANGED
@@ -4,6 +4,7 @@ export const defaultChecks = [
4
4
  id: checkId("duplicated_schema"),
5
5
  name: "Duplicated schema",
6
6
  question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
7
+ why: "Duplicated shapes make it easier for AI-assisted changes to drift away from the real source of truth.",
7
8
  lookFor: [
8
9
  "local shape mirrors existing fields and maps them one-for-one",
9
10
  "new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
@@ -28,6 +29,7 @@ export const defaultChecks = [
28
29
  id: checkId("unnecessary_complexity"),
29
30
  name: "Unnecessary complexity",
30
31
  question: "Did the change add structure without buying clarity?",
32
+ why: "Extra indirection can hide simple decisions and make the code feel more designed than understood.",
31
33
  lookFor: [
32
34
  "helper, wrapper, service, layer, or extra file around simple logic without reuse",
33
35
  ],
@@ -68,6 +70,7 @@ Prefer no match over a weak match.`,
68
70
  id: checkId("fake_precision_windowing"),
69
71
  name: "Fake precision windowing",
70
72
  question: "Did the change add fake precision around model context?",
73
+ why: "Precise-looking bookkeeping can create confidence without improving the actual behavior.",
71
74
  lookFor: [
72
75
  "precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
73
76
  ],
@@ -80,6 +83,7 @@ Prefer no match over a weak match.`,
80
83
  id: checkId("coauthored_slop"),
81
84
  name: "Coauthored slop",
82
85
  question: "Does author metadata contain co-author text?",
86
+ why: "Careless metadata is a cheap signal that the change may not have been reviewed with intent.",
83
87
  lookFor: [
84
88
  "author signal contains coauhtoried, coauthored, or co-authored text",
85
89
  ],
@@ -91,6 +95,7 @@ Prefer no match over a weak match.`,
91
95
  id: checkId("mega_file"),
92
96
  name: "Mega file",
93
97
  question: "Is a touched non-config file over 1000 LOC?",
98
+ why: "Large files make judgment harder by concentrating unrelated decisions in one place.",
94
99
  lookFor: [
95
100
  "touched non-config source file over 1000 LOC",
96
101
  ],
@@ -102,6 +107,7 @@ Prefer no match over a weak match.`,
102
107
  id: checkId("over_commenting"),
103
108
  name: "Over commenting",
104
109
  question: "Did the change add noisy comments?",
110
+ why: "Narrative comments can make routine code look deliberate without clarifying the underlying tradeoff.",
105
111
  lookFor: [
106
112
  "comments restate obvious code or narrate simple logic",
107
113
  ],
@@ -129,6 +135,7 @@ Prefer no match over a weak match.`,
129
135
  id: checkId("lint_bypass"),
130
136
  name: "Lint bypass",
131
137
  question: "Did the change bypass lint or type rules?",
138
+ why: "Unexplained suppressions remove useful feedback exactly where a change needs more scrutiny.",
132
139
  lookFor: [
133
140
  "adds suppressions, any, broad casts, or weakens lint/typecheck config",
134
141
  ],
@@ -152,6 +159,7 @@ Prefer no match over a weak match.`,
152
159
  id: checkId("inconsistent_patterns"),
153
160
  name: "Inconsistent patterns",
154
161
  question: "Does the change clash with nearby patterns?",
162
+ why: "Pattern drift can signal that a change followed generic suggestions instead of local codebase judgment.",
155
163
  lookFor: [
156
164
  "same job uses different naming, errors, state, imports, or layout than nearby files",
157
165
  ],
@@ -164,6 +172,7 @@ Prefer no match over a weak match.`,
164
172
  id: checkId("reinvented_utils"),
165
173
  name: "Reinvented utils",
166
174
  question: "Did the change recreate an existing utility?",
175
+ why: "Generic helper reinvention can be a sign that the change optimized for plausible code over local reuse.",
167
176
  lookFor: [
168
177
  "new helper duplicates local utility or standard library behavior",
169
178
  ],
@@ -173,7 +182,7 @@ Prefer no match over a weak match.`,
173
182
  "helper is domain-specific or used by multiple local call sites",
174
183
  ],
175
184
  hookMode: "warn",
176
- searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, group, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
185
+ searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match group/resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
177
186
  searchExamples: {
178
187
  match: [
179
188
  "clampValue returns min, max, or value.",
@@ -188,6 +197,7 @@ Prefer no match over a weak match.`,
188
197
  id: checkId("operator_style_mismatch"),
189
198
  name: "Operator style mismatch",
190
199
  question: "Does the change read unlike the surrounding code?",
200
+ why: "Style mismatch can reveal generic generated code that was not reconciled with nearby conventions.",
191
201
  lookFor: [
192
202
  "generic or template-like names, abstractions, comments, or control flow clash with local style",
193
203
  ],
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.6";
1
+ export declare const VERSION = "0.0.8";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
  export declare const DEFAULT_MODEL_ID: ModelId;
4
4
  export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.6";
1
+ export const VERSION = "0.0.8";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
@@ -120,7 +120,7 @@ function lintBypassSignal(value) {
120
120
  }
121
121
  function reinventedUtilitySignal(change) {
122
122
  const name = change.entityName;
123
- if (!/^(clamp|debounce|throttle|slug|slugify|group|sort|shuffle|memoize|pick|omit|uniq)/i.test(name))
123
+ if (!/^(clamp|debounce|throttle|slug|slugify|sort|shuffle|memoize|pick|omit|uniq)/i.test(name))
124
124
  return false;
125
125
  const content = change.afterContent ?? "";
126
126
  if (/currency|invoice|refund|subscription|tier|domain/i.test(`${name}\n${content}`))
package/dist/prompts.js CHANGED
@@ -55,6 +55,7 @@ ${input.pack.text || "(none)"}`;
55
55
  }
56
56
  function formatSearchPattern(check) {
57
57
  return `Pattern: ${check.id} (${check.name})
58
+ Why this matters: ${check.why}
58
59
  Question: ${check.searchPrompt ?? check.question}
59
60
  Look for:
60
61
  ${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
@@ -81,6 +82,7 @@ function patternForContext(context, patterns) {
81
82
  id: context.checkId,
82
83
  name: context.checkId,
83
84
  question: `Does this target match ${context.checkId}?`,
85
+ why: "This pattern may indicate judgment-offload.",
84
86
  lookFor: [],
85
87
  ignoreWhen: [],
86
88
  };
package/dist/render.js CHANGED
@@ -29,7 +29,8 @@ No judgment-offload signals found.`;
29
29
  return `🧙 stupify 🪄
30
30
  Possible judgment-offload detected:
31
31
  ${run.matches.map((match, index) => `${index + 1}. ${match.patternId}
32
- ${match.reason}
32
+ Why: ${match.checkWhy ?? "This pattern may indicate judgment-offload."}
33
+ Match: ${match.reason}
33
34
  Proof: ${match.proof}`).join("\n")}
34
35
  Search mode is warn-only.`;
35
36
  }
package/dist/stupify.js CHANGED
@@ -133,9 +133,18 @@ export async function runSearchCommand(command, startedAt) {
133
133
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
134
134
  ? initialPack
135
135
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
136
- const request = buildSearchRequest(changeSet, searchContexts, pack, checks, profile, command.includeCounterReasonInPrompt);
137
- const estimatedInputTokens = estimatePromptTokens(request.prompt);
138
- if (estimatedInputTokens > maxSearchInputTokens) {
136
+ const batches = await buildSearchBatches({
137
+ command,
138
+ changeSet,
139
+ contexts: searchContexts,
140
+ initialPack: pack,
141
+ checks,
142
+ profile,
143
+ includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
144
+ maxSearchInputTokens,
145
+ baseRepomixConfig,
146
+ });
147
+ if (batches.batches.length === 0) {
139
148
  return {
140
149
  schemaVersion: "search.v1",
141
150
  mode: "search",
@@ -145,7 +154,7 @@ export async function runSearchCommand(command, startedAt) {
145
154
  stats: {
146
155
  elapsedMs: Date.now() - startedAt,
147
156
  modelCalls: 0,
148
- inputTokens: estimatedInputTokens,
157
+ inputTokens: batches.estimatedInputTokens,
149
158
  inputTokenCap: maxSearchInputTokens,
150
159
  skipped: true,
151
160
  skipReason: "input_too_large",
@@ -156,6 +165,8 @@ export async function runSearchCommand(command, startedAt) {
156
165
  repomixFiles: pack.filePaths.length,
157
166
  repomixTokens: pack.totalTokens,
158
167
  repomixConfig: pack.config,
168
+ searchBatches: 0,
169
+ skippedTargets: batches.skippedTargets,
159
170
  profileId: profile?.id,
160
171
  targetsByPattern: countTargetsByPattern(searchContexts),
161
172
  targetsPreview: previewTargets(searchContexts),
@@ -163,38 +174,33 @@ export async function runSearchCommand(command, startedAt) {
163
174
  matches: [],
164
175
  };
165
176
  }
177
+ if (batches.wasSplit && !command.json) {
178
+ console.error(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
179
+ if (batches.skippedTargets > 0) {
180
+ console.error(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
181
+ }
182
+ }
166
183
  const modelPath = await firstRunModelBootstrap(command.model);
167
184
  const model = await loadLocalModel(modelPath, command.model, "scout");
168
- const inputTokens = await countPromptTokens(model, request.prompt);
169
- if (inputTokens > maxSearchInputTokens) {
170
- return {
171
- schemaVersion: "search.v1",
172
- mode: "search",
173
- source: command.source,
174
- model: { id: command.model },
175
- patterns: patternIds,
176
- stats: {
177
- elapsedMs: Date.now() - startedAt,
178
- modelCalls: 0,
179
- inputTokens,
180
- inputTokenCap: maxSearchInputTokens,
181
- skipped: true,
182
- skipReason: "input_too_large",
183
- filesChanged: changeSet.summary.fileCount,
184
- entitiesScanned: changeSet.summary.total,
185
- candidates: contexts.length,
186
- searchTargets: searchContexts.length,
187
- repomixFiles: pack.filePaths.length,
188
- repomixTokens: pack.totalTokens,
189
- repomixConfig: pack.config,
190
- profileId: profile?.id,
191
- targetsByPattern: countTargetsByPattern(searchContexts),
192
- targetsPreview: previewTargets(searchContexts),
193
- },
194
- matches: [],
195
- };
185
+ const matches = [];
186
+ let modelCalls = 0;
187
+ let inputTokens = 0;
188
+ let exactSkippedTargets = batches.skippedTargets;
189
+ for (const batch of batches.batches) {
190
+ const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
191
+ inputTokens += batchInputTokens;
192
+ if (batchInputTokens > maxSearchInputTokens) {
193
+ exactSkippedTargets += batch.contexts.length;
194
+ if (!command.json) {
195
+ console.error(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
196
+ }
197
+ continue;
198
+ }
199
+ const { value } = await t.trace("search.model", () => runSearch(model, batch.request), { count: (v) => v.length });
200
+ modelCalls += 1;
201
+ matches.push(...withCheckWhy(value, checks));
196
202
  }
197
- const { value: matches } = await t.trace("search.model", () => runSearch(model, request), { count: (v) => v.length });
203
+ const uniqueMatches = dedupeMatches(matches);
198
204
  return {
199
205
  schemaVersion: "search.v1",
200
206
  mode: "search",
@@ -203,7 +209,7 @@ export async function runSearchCommand(command, startedAt) {
203
209
  patterns: patternIds,
204
210
  stats: {
205
211
  elapsedMs: Date.now() - startedAt,
206
- modelCalls: 1,
212
+ modelCalls,
207
213
  inputTokens,
208
214
  inputTokenCap: maxSearchInputTokens,
209
215
  filesChanged: changeSet.summary.fileCount,
@@ -213,17 +219,98 @@ export async function runSearchCommand(command, startedAt) {
213
219
  repomixFiles: pack.filePaths.length,
214
220
  repomixTokens: pack.totalTokens,
215
221
  repomixConfig: pack.config,
222
+ searchBatches: batches.batches.length,
223
+ skippedTargets: exactSkippedTargets,
216
224
  profileId: profile?.id,
217
225
  targetsByPattern: countTargetsByPattern(searchContexts),
218
226
  targetsPreview: previewTargets(searchContexts),
219
227
  },
220
- matches,
228
+ matches: uniqueMatches,
221
229
  };
222
230
  }
223
231
  finally {
224
232
  await changeSet.cleanup();
225
233
  }
226
234
  }
235
+ function dedupeMatches(matches) {
236
+ const seen = new Set();
237
+ return matches.filter((match) => {
238
+ const key = `${match.patternId}\n${match.proof.trim()}`;
239
+ if (seen.has(key))
240
+ return false;
241
+ seen.add(key);
242
+ return true;
243
+ });
244
+ }
245
+ function withCheckWhy(matches, checks) {
246
+ const checksById = new Map(checks.map((check) => [check.id, check]));
247
+ return matches.map((match) => ({
248
+ ...match,
249
+ checkWhy: checksById.get(match.patternId)?.why,
250
+ }));
251
+ }
252
+ async function buildSearchBatches(input) {
253
+ const first = makeSearchBatch(input, input.contexts, input.initialPack);
254
+ if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
255
+ return {
256
+ batches: [first],
257
+ estimatedInputTokens: first.estimatedInputTokens,
258
+ skippedTargets: 0,
259
+ wasSplit: false,
260
+ };
261
+ }
262
+ const batches = [];
263
+ let skippedTargets = 0;
264
+ let currentContexts = [];
265
+ let currentBatch = null;
266
+ for (const context of input.contexts) {
267
+ const candidateContexts = [...currentContexts, context];
268
+ const candidateBatch = await makeSearchBatchWithPack(input, candidateContexts);
269
+ if (candidateBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
270
+ currentContexts = candidateContexts;
271
+ currentBatch = candidateBatch;
272
+ continue;
273
+ }
274
+ if (currentBatch) {
275
+ batches.push(currentBatch);
276
+ currentContexts = [];
277
+ currentBatch = null;
278
+ }
279
+ const singleBatch = candidateContexts.length === 1
280
+ ? candidateBatch
281
+ : await makeSearchBatchWithPack(input, [context]);
282
+ if (singleBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
283
+ currentContexts = [context];
284
+ currentBatch = singleBatch;
285
+ }
286
+ else {
287
+ skippedTargets += 1;
288
+ }
289
+ }
290
+ if (currentBatch)
291
+ batches.push(currentBatch);
292
+ return {
293
+ batches,
294
+ estimatedInputTokens: first.estimatedInputTokens,
295
+ skippedTargets,
296
+ wasSplit: true,
297
+ };
298
+ }
299
+ function makeSearchBatch(input, contexts, pack) {
300
+ const request = buildSearchRequest(input.changeSet, contexts, pack, input.checks, input.profile, input.includeCounterReasonInPrompt);
301
+ return {
302
+ contexts,
303
+ pack,
304
+ request,
305
+ estimatedInputTokens: estimatePromptTokens(request.prompt),
306
+ };
307
+ }
308
+ async function makeSearchBatchWithPack(input, contexts) {
309
+ const pack = input.profile?.context === "sem"
310
+ ? emptyContextPack()
311
+ : await repomixContextPack(input.changeSet.contextCwd, contexts, input.changeSet.changes, input.baseRepomixConfig);
312
+ return makeSearchBatch(input, contexts, pack);
313
+ }
227
314
  function buildSearchRequest(changeSet, contexts, pack, patterns, profile, includeCounterReasonInPrompt) {
228
315
  return searchRequest({
229
316
  changeSet,
@@ -261,7 +348,7 @@ function sourceLabel(command) {
261
348
  return "stdin diff";
262
349
  }
263
350
  function estimatePromptTokens(prompt) {
264
- return Math.ceil(prompt.length / 4);
351
+ return Math.ceil(prompt.length / 3);
265
352
  }
266
353
  function countTargetsByPattern(contexts) {
267
354
  const counts = {};
package/dist/types.d.ts CHANGED
@@ -68,6 +68,7 @@ export type StupifyCheck = Readonly<{
68
68
  id: CheckId;
69
69
  name: string;
70
70
  question: string;
71
+ why: string;
71
72
  lookFor: readonly string[];
72
73
  ignoreWhen: readonly string[];
73
74
  enabledByDefault?: boolean;
@@ -194,6 +195,7 @@ export type SearchProfile = Readonly<{
194
195
  export type SearchMatch = Readonly<{
195
196
  targetId: string;
196
197
  patternId: CheckId;
198
+ checkWhy?: string;
197
199
  reason: string;
198
200
  proof: string;
199
201
  }>;
@@ -219,6 +221,8 @@ export type SearchRunJson = Readonly<{
219
221
  repomixTokens?: number;
220
222
  repomixConfig?: RepomixSearchConfig;
221
223
  searchTargets?: number;
224
+ searchBatches?: number;
225
+ skippedTargets?: number;
222
226
  profileId?: string;
223
227
  targetsByPattern?: Readonly<Record<string, number>>;
224
228
  targetsPreview?: readonly SearchTargetPreview[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
5
5
  "private": false,
6
6
  "type": "module",
package/src/analysis.ts CHANGED
@@ -104,11 +104,16 @@ function uncheckedSearchMatches(value: unknown, contexts: readonly SemContext[])
104
104
  targetId,
105
105
  patternId: context.checkId,
106
106
  reason: match.reason ?? "",
107
- proof: match.proof ?? "",
107
+ proof: sourcePointer(context),
108
108
  }];
109
109
  });
110
110
  }
111
111
 
112
+ function sourcePointer(context: SemContext): string {
113
+ const file = context.filePath ?? "(unknown)";
114
+ return `${file}::${context.entityKind || "entity"}::${context.entityName || context.entityId}`;
115
+ }
116
+
112
117
  async function runJsonPrompt(
113
118
  model: LocalModel,
114
119
  prompt: string,
package/src/checks.ts CHANGED
@@ -5,6 +5,7 @@ export const defaultChecks: readonly StupifyCheck[] = [
5
5
  id: checkId("duplicated_schema"),
6
6
  name: "Duplicated schema",
7
7
  question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
8
+ why: "Duplicated shapes make it easier for AI-assisted changes to drift away from the real source of truth.",
8
9
  lookFor: [
9
10
  "local shape mirrors existing fields and maps them one-for-one",
10
11
  "new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
@@ -29,6 +30,7 @@ export const defaultChecks: readonly StupifyCheck[] = [
29
30
  id: checkId("unnecessary_complexity"),
30
31
  name: "Unnecessary complexity",
31
32
  question: "Did the change add structure without buying clarity?",
33
+ why: "Extra indirection can hide simple decisions and make the code feel more designed than understood.",
32
34
  lookFor: [
33
35
  "helper, wrapper, service, layer, or extra file around simple logic without reuse",
34
36
  ],
@@ -69,6 +71,7 @@ Prefer no match over a weak match.`,
69
71
  id: checkId("fake_precision_windowing"),
70
72
  name: "Fake precision windowing",
71
73
  question: "Did the change add fake precision around model context?",
74
+ why: "Precise-looking bookkeeping can create confidence without improving the actual behavior.",
72
75
  lookFor: [
73
76
  "precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
74
77
  ],
@@ -81,6 +84,7 @@ Prefer no match over a weak match.`,
81
84
  id: checkId("coauthored_slop"),
82
85
  name: "Coauthored slop",
83
86
  question: "Does author metadata contain co-author text?",
87
+ why: "Careless metadata is a cheap signal that the change may not have been reviewed with intent.",
84
88
  lookFor: [
85
89
  "author signal contains coauhtoried, coauthored, or co-authored text",
86
90
  ],
@@ -92,6 +96,7 @@ Prefer no match over a weak match.`,
92
96
  id: checkId("mega_file"),
93
97
  name: "Mega file",
94
98
  question: "Is a touched non-config file over 1000 LOC?",
99
+ why: "Large files make judgment harder by concentrating unrelated decisions in one place.",
95
100
  lookFor: [
96
101
  "touched non-config source file over 1000 LOC",
97
102
  ],
@@ -103,6 +108,7 @@ Prefer no match over a weak match.`,
103
108
  id: checkId("over_commenting"),
104
109
  name: "Over commenting",
105
110
  question: "Did the change add noisy comments?",
111
+ why: "Narrative comments can make routine code look deliberate without clarifying the underlying tradeoff.",
106
112
  lookFor: [
107
113
  "comments restate obvious code or narrate simple logic",
108
114
  ],
@@ -130,6 +136,7 @@ Prefer no match over a weak match.`,
130
136
  id: checkId("lint_bypass"),
131
137
  name: "Lint bypass",
132
138
  question: "Did the change bypass lint or type rules?",
139
+ why: "Unexplained suppressions remove useful feedback exactly where a change needs more scrutiny.",
133
140
  lookFor: [
134
141
  "adds suppressions, any, broad casts, or weakens lint/typecheck config",
135
142
  ],
@@ -153,6 +160,7 @@ Prefer no match over a weak match.`,
153
160
  id: checkId("inconsistent_patterns"),
154
161
  name: "Inconsistent patterns",
155
162
  question: "Does the change clash with nearby patterns?",
163
+ why: "Pattern drift can signal that a change followed generic suggestions instead of local codebase judgment.",
156
164
  lookFor: [
157
165
  "same job uses different naming, errors, state, imports, or layout than nearby files",
158
166
  ],
@@ -165,6 +173,7 @@ Prefer no match over a weak match.`,
165
173
  id: checkId("reinvented_utils"),
166
174
  name: "Reinvented utils",
167
175
  question: "Did the change recreate an existing utility?",
176
+ why: "Generic helper reinvention can be a sign that the change optimized for plausible code over local reuse.",
168
177
  lookFor: [
169
178
  "new helper duplicates local utility or standard library behavior",
170
179
  ],
@@ -174,7 +183,7 @@ Prefer no match over a weak match.`,
174
183
  "helper is domain-specific or used by multiple local call sites",
175
184
  ],
176
185
  hookMode: "warn",
177
- searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, group, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
186
+ searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match group/resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
178
187
  searchExamples: {
179
188
  match: [
180
189
  "clampValue returns min, max, or value.",
@@ -189,6 +198,7 @@ Prefer no match over a weak match.`,
189
198
  id: checkId("operator_style_mismatch"),
190
199
  name: "Operator style mismatch",
191
200
  question: "Does the change read unlike the surrounding code?",
201
+ why: "Style mismatch can reveal generic generated code that was not reconciled with nearby conventions.",
192
202
  lookFor: [
193
203
  "generic or template-like names, abstractions, comments, or control flow clash with local style",
194
204
  ],
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.6";
1
+ export const VERSION = "0.0.8";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
@@ -140,7 +140,7 @@ function lintBypassSignal(value: string): boolean {
140
140
 
141
141
  function reinventedUtilitySignal(change: SemChange): boolean {
142
142
  const name = change.entityName;
143
- if (!/^(clamp|debounce|throttle|slug|slugify|group|sort|shuffle|memoize|pick|omit|uniq)/i.test(name)) return false;
143
+ if (!/^(clamp|debounce|throttle|slug|slugify|sort|shuffle|memoize|pick|omit|uniq)/i.test(name)) return false;
144
144
  const content = change.afterContent ?? "";
145
145
  if (/currency|invoice|refund|subscription|tier|domain/i.test(`${name}\n${content}`)) return false;
146
146
  return true;
package/src/prompts.ts CHANGED
@@ -64,6 +64,7 @@ ${input.pack.text || "(none)"}`;
64
64
 
65
65
  function formatSearchPattern(check: StupifyCheck): string {
66
66
  return `Pattern: ${check.id} (${check.name})
67
+ Why this matters: ${check.why}
67
68
  Question: ${check.searchPrompt ?? check.question}
68
69
  Look for:
69
70
  ${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
@@ -92,6 +93,7 @@ function patternForContext(context: SemContext, patterns: readonly StupifyCheck[
92
93
  id: context.checkId,
93
94
  name: context.checkId,
94
95
  question: `Does this target match ${context.checkId}?`,
96
+ why: "This pattern may indicate judgment-offload.",
95
97
  lookFor: [],
96
98
  ignoreWhen: [],
97
99
  };
package/src/render.ts CHANGED
@@ -34,7 +34,8 @@ No judgment-offload signals found.`;
34
34
  return `🧙 stupify 🪄
35
35
  Possible judgment-offload detected:
36
36
  ${run.matches.map((match, index) => `${index + 1}. ${match.patternId}
37
- ${match.reason}
37
+ Why: ${match.checkWhy ?? "This pattern may indicate judgment-offload."}
38
+ Match: ${match.reason}
38
39
  Proof: ${match.proof}`).join("\n")}
39
40
  Search mode is warn-only.`;
40
41
  }
package/src/stupify.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { realpathSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
- import { countPromptTokens, runSearch, searchRequest } from "./analysis.ts";
5
+ import { countPromptTokens, runSearch, searchRequest, type SearchRequest } from "./analysis.ts";
6
6
  import { searchChecks } from "./checks.ts";
7
7
  import { parseCommand } from "./command.ts";
8
8
  import { counterScoutTargets } from "./counter-scout.ts";
@@ -20,7 +20,7 @@ import {
20
20
  } from "./search-profile.ts";
21
21
  import { semChangeSetForCommand } from "./sem-provider.ts";
22
22
  import { createTracer } from "./trace.ts";
23
- import type { SearchCommand, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
23
+ import type { SearchCommand, SearchMatch, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
24
24
 
25
25
  export async function main(argv = process.argv.slice(2)): Promise<number> {
26
26
  const startedAt = Date.now();
@@ -153,17 +153,19 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
153
153
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
154
154
  ? initialPack
155
155
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
156
-
157
- const request = buildSearchRequest(
156
+ const batches = await buildSearchBatches({
157
+ command,
158
158
  changeSet,
159
- searchContexts,
160
- pack,
159
+ contexts: searchContexts,
160
+ initialPack: pack,
161
161
  checks,
162
162
  profile,
163
- command.includeCounterReasonInPrompt,
164
- );
165
- const estimatedInputTokens = estimatePromptTokens(request.prompt);
166
- if (estimatedInputTokens > maxSearchInputTokens) {
163
+ includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
164
+ maxSearchInputTokens,
165
+ baseRepomixConfig,
166
+ });
167
+
168
+ if (batches.batches.length === 0) {
167
169
  return {
168
170
  schemaVersion: "search.v1",
169
171
  mode: "search",
@@ -173,7 +175,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
173
175
  stats: {
174
176
  elapsedMs: Date.now() - startedAt,
175
177
  modelCalls: 0,
176
- inputTokens: estimatedInputTokens,
178
+ inputTokens: batches.estimatedInputTokens,
177
179
  inputTokenCap: maxSearchInputTokens,
178
180
  skipped: true,
179
181
  skipReason: "input_too_large",
@@ -184,6 +186,8 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
184
186
  repomixFiles: pack.filePaths.length,
185
187
  repomixTokens: pack.totalTokens,
186
188
  repomixConfig: pack.config,
189
+ searchBatches: 0,
190
+ skippedTargets: batches.skippedTargets,
187
191
  profileId: profile?.id,
188
192
  targetsByPattern: countTargetsByPattern(searchContexts),
189
193
  targetsPreview: previewTargets(searchContexts),
@@ -192,43 +196,38 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
192
196
  };
193
197
  }
194
198
 
199
+ if (batches.wasSplit && !command.json) {
200
+ console.error(`Search input is large; queued ${batches.batches.length} smaller search batches.`);
201
+ if (batches.skippedTargets > 0) {
202
+ console.error(`Skipped ${batches.skippedTargets} oversized targets that could not fit alone.`);
203
+ }
204
+ }
205
+
195
206
  const modelPath = await firstRunModelBootstrap(command.model);
196
207
  const model = await loadLocalModel(modelPath, command.model, "scout");
197
- const inputTokens = await countPromptTokens(model, request.prompt);
198
- if (inputTokens > maxSearchInputTokens) {
199
- return {
200
- schemaVersion: "search.v1",
201
- mode: "search",
202
- source: command.source,
203
- model: { id: command.model },
204
- patterns: patternIds,
205
- stats: {
206
- elapsedMs: Date.now() - startedAt,
207
- modelCalls: 0,
208
- inputTokens,
209
- inputTokenCap: maxSearchInputTokens,
210
- skipped: true,
211
- skipReason: "input_too_large",
212
- filesChanged: changeSet.summary.fileCount,
213
- entitiesScanned: changeSet.summary.total,
214
- candidates: contexts.length,
215
- searchTargets: searchContexts.length,
216
- repomixFiles: pack.filePaths.length,
217
- repomixTokens: pack.totalTokens,
218
- repomixConfig: pack.config,
219
- profileId: profile?.id,
220
- targetsByPattern: countTargetsByPattern(searchContexts),
221
- targetsPreview: previewTargets(searchContexts),
222
- },
223
- matches: [],
224
- };
208
+ const matches = [];
209
+ let modelCalls = 0;
210
+ let inputTokens = 0;
211
+ let exactSkippedTargets = batches.skippedTargets;
212
+ for (const batch of batches.batches) {
213
+ const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
214
+ inputTokens += batchInputTokens;
215
+ if (batchInputTokens > maxSearchInputTokens) {
216
+ exactSkippedTargets += batch.contexts.length;
217
+ if (!command.json) {
218
+ console.error(`Skipped ${batch.contexts.length} targets after exact token count exceeded the limit.`);
219
+ }
220
+ continue;
221
+ }
222
+ const { value } = await t.trace(
223
+ "search.model",
224
+ () => runSearch(model, batch.request),
225
+ { count: (v) => v.length },
226
+ );
227
+ modelCalls += 1;
228
+ matches.push(...withCheckWhy(value, checks));
225
229
  }
226
-
227
- const { value: matches } = await t.trace(
228
- "search.model",
229
- () => runSearch(model, request),
230
- { count: (v) => v.length },
231
- );
230
+ const uniqueMatches = dedupeMatches(matches);
232
231
 
233
232
  return {
234
233
  schemaVersion: "search.v1",
@@ -238,7 +237,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
238
237
  patterns: patternIds,
239
238
  stats: {
240
239
  elapsedMs: Date.now() - startedAt,
241
- modelCalls: 1,
240
+ modelCalls,
242
241
  inputTokens,
243
242
  inputTokenCap: maxSearchInputTokens,
244
243
  filesChanged: changeSet.summary.fileCount,
@@ -248,17 +247,154 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
248
247
  repomixFiles: pack.filePaths.length,
249
248
  repomixTokens: pack.totalTokens,
250
249
  repomixConfig: pack.config,
250
+ searchBatches: batches.batches.length,
251
+ skippedTargets: exactSkippedTargets,
251
252
  profileId: profile?.id,
252
253
  targetsByPattern: countTargetsByPattern(searchContexts),
253
254
  targetsPreview: previewTargets(searchContexts),
254
255
  },
255
- matches,
256
+ matches: uniqueMatches,
256
257
  };
257
258
  } finally {
258
259
  await changeSet.cleanup();
259
260
  }
260
261
  }
261
262
 
263
+ function dedupeMatches<T extends { targetId: string; patternId: string; proof: string }>(matches: readonly T[]): readonly T[] {
264
+ const seen = new Set<string>();
265
+ return matches.filter((match) => {
266
+ const key = `${match.patternId}\n${match.proof.trim()}`;
267
+ if (seen.has(key)) return false;
268
+ seen.add(key);
269
+ return true;
270
+ });
271
+ }
272
+
273
+ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyCheck[]): readonly SearchMatch[] {
274
+ const checksById = new Map(checks.map((check) => [check.id, check]));
275
+ return matches.map((match) => ({
276
+ ...match,
277
+ checkWhy: checksById.get(match.patternId)?.why,
278
+ }));
279
+ }
280
+
281
+ type SearchBatch = Readonly<{
282
+ contexts: readonly SemContext[];
283
+ pack: SemContextPack;
284
+ request: SearchRequest;
285
+ estimatedInputTokens: number;
286
+ }>;
287
+
288
+ async function buildSearchBatches(input: Readonly<{
289
+ command: SearchCommand;
290
+ changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
291
+ contexts: readonly SemContext[];
292
+ initialPack: SemContextPack;
293
+ checks: readonly StupifyCheck[];
294
+ profile: SearchProfile | null;
295
+ includeCounterReasonInPrompt: boolean;
296
+ maxSearchInputTokens: number;
297
+ baseRepomixConfig: Parameters<typeof repomixContextPack>[3];
298
+ }>): Promise<Readonly<{
299
+ batches: readonly SearchBatch[];
300
+ estimatedInputTokens: number;
301
+ skippedTargets: number;
302
+ wasSplit: boolean;
303
+ }>> {
304
+ const first = makeSearchBatch(input, input.contexts, input.initialPack);
305
+ if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
306
+ return {
307
+ batches: [first],
308
+ estimatedInputTokens: first.estimatedInputTokens,
309
+ skippedTargets: 0,
310
+ wasSplit: false,
311
+ };
312
+ }
313
+
314
+ const batches: SearchBatch[] = [];
315
+ let skippedTargets = 0;
316
+ let currentContexts: readonly SemContext[] = [];
317
+ let currentBatch: SearchBatch | null = null;
318
+
319
+ for (const context of input.contexts) {
320
+ const candidateContexts = [...currentContexts, context];
321
+ const candidateBatch = await makeSearchBatchWithPack(input, candidateContexts);
322
+ if (candidateBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
323
+ currentContexts = candidateContexts;
324
+ currentBatch = candidateBatch;
325
+ continue;
326
+ }
327
+
328
+ if (currentBatch) {
329
+ batches.push(currentBatch);
330
+ currentContexts = [];
331
+ currentBatch = null;
332
+ }
333
+
334
+ const singleBatch = candidateContexts.length === 1
335
+ ? candidateBatch
336
+ : await makeSearchBatchWithPack(input, [context]);
337
+ if (singleBatch.estimatedInputTokens <= input.maxSearchInputTokens) {
338
+ currentContexts = [context];
339
+ currentBatch = singleBatch;
340
+ } else {
341
+ skippedTargets += 1;
342
+ }
343
+ }
344
+
345
+ if (currentBatch) batches.push(currentBatch);
346
+
347
+ return {
348
+ batches,
349
+ estimatedInputTokens: first.estimatedInputTokens,
350
+ skippedTargets,
351
+ wasSplit: true,
352
+ };
353
+ }
354
+
355
+ function makeSearchBatch(
356
+ input: Readonly<{
357
+ changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
358
+ checks: readonly StupifyCheck[];
359
+ profile: SearchProfile | null;
360
+ includeCounterReasonInPrompt: boolean;
361
+ }>,
362
+ contexts: readonly SemContext[],
363
+ pack: SemContextPack,
364
+ ): SearchBatch {
365
+ const request = buildSearchRequest(
366
+ input.changeSet,
367
+ contexts,
368
+ pack,
369
+ input.checks,
370
+ input.profile,
371
+ input.includeCounterReasonInPrompt,
372
+ );
373
+ return {
374
+ contexts,
375
+ pack,
376
+ request,
377
+ estimatedInputTokens: estimatePromptTokens(request.prompt),
378
+ };
379
+ }
380
+
381
+ async function makeSearchBatchWithPack(
382
+ input: Readonly<{
383
+ command: SearchCommand;
384
+ changeSet: Parameters<typeof searchRequest>[0]["changeSet"];
385
+ checks: readonly StupifyCheck[];
386
+ profile: SearchProfile | null;
387
+ includeCounterReasonInPrompt: boolean;
388
+ baseRepomixConfig: Parameters<typeof repomixContextPack>[3];
389
+ }>,
390
+ contexts: readonly SemContext[],
391
+ ): Promise<SearchBatch> {
392
+ const pack = input.profile?.context === "sem"
393
+ ? emptyContextPack()
394
+ : await repomixContextPack(input.changeSet.contextCwd, contexts, input.changeSet.changes, input.baseRepomixConfig);
395
+ return makeSearchBatch(input, contexts, pack);
396
+ }
397
+
262
398
  function buildSearchRequest(
263
399
  changeSet: Parameters<typeof searchRequest>[0]["changeSet"],
264
400
  contexts: Parameters<typeof searchRequest>[0]["contexts"],
@@ -302,7 +438,7 @@ function sourceLabel(command: SearchCommand): string {
302
438
  }
303
439
 
304
440
  function estimatePromptTokens(prompt: string): number {
305
- return Math.ceil(prompt.length / 4);
441
+ return Math.ceil(prompt.length / 3);
306
442
  }
307
443
 
308
444
  function countTargetsByPattern(contexts: readonly SemContext[]): Record<string, number> {
package/src/types.ts CHANGED
@@ -51,6 +51,7 @@ export type StupifyCheck = Readonly<{
51
51
  id: CheckId;
52
52
  name: string;
53
53
  question: string;
54
+ why: string;
54
55
  lookFor: readonly string[];
55
56
  ignoreWhen: readonly string[];
56
57
  enabledByDefault?: boolean;
@@ -192,6 +193,7 @@ export type SearchProfile = Readonly<{
192
193
  export type SearchMatch = Readonly<{
193
194
  targetId: string;
194
195
  patternId: CheckId;
196
+ checkWhy?: string;
195
197
  reason: string;
196
198
  proof: string;
197
199
  }>;
@@ -216,6 +218,8 @@ export type SearchRunJson = Readonly<{
216
218
  repomixTokens?: number;
217
219
  repomixConfig?: RepomixSearchConfig;
218
220
  searchTargets?: number;
221
+ searchBatches?: number;
222
+ skippedTargets?: number;
219
223
  profileId?: string;
220
224
  targetsByPattern?: Readonly<Record<string, number>>;
221
225
  targetsPreview?: readonly SearchTargetPreview[];