@stupify/cli 0.0.3 → 0.0.4

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.
Files changed (59) hide show
  1. package/README.md +26 -31
  2. package/dist/analysis.d.ts +11 -9
  3. package/dist/analysis.js +30 -173
  4. package/dist/checks.d.ts +1 -0
  5. package/dist/checks.js +89 -2
  6. package/dist/command.js +55 -91
  7. package/dist/constants.d.ts +1 -1
  8. package/dist/constants.js +1 -1
  9. package/dist/counter-scout.js +70 -8
  10. package/dist/doctor.d.ts +4 -0
  11. package/dist/doctor.js +131 -0
  12. package/dist/git.d.ts +4 -1
  13. package/dist/git.js +34 -0
  14. package/dist/hooks.d.ts +3 -0
  15. package/dist/hooks.js +117 -0
  16. package/dist/model.d.ts +1 -15
  17. package/dist/model.js +37 -21
  18. package/dist/prompts.d.ts +8 -5
  19. package/dist/prompts.js +58 -168
  20. package/dist/render.d.ts +2 -2
  21. package/dist/render.js +70 -78
  22. package/dist/repomix-provider.d.ts +10 -2
  23. package/dist/repomix-provider.js +62 -11
  24. package/dist/search-bench.d.ts +1 -0
  25. package/dist/search-bench.js +675 -0
  26. package/dist/search-profile.d.ts +6 -0
  27. package/dist/search-profile.js +73 -0
  28. package/dist/sem-provider.d.ts +2 -2
  29. package/dist/sem-provider.js +33 -7
  30. package/dist/stupify.d.ts +2 -0
  31. package/dist/stupify.js +183 -333
  32. package/dist/types.d.ts +193 -109
  33. package/package.json +1 -1
  34. package/src/analysis.ts +48 -268
  35. package/src/checks.ts +91 -2
  36. package/src/command.ts +62 -107
  37. package/src/constants.ts +1 -1
  38. package/src/counter-scout.ts +63 -7
  39. package/src/doctor.ts +140 -0
  40. package/src/git.ts +35 -1
  41. package/src/hooks.ts +134 -0
  42. package/src/model.ts +39 -26
  43. package/src/prompts.ts +66 -202
  44. package/src/render.ts +68 -79
  45. package/src/repomix-provider.ts +66 -10
  46. package/src/search-bench.ts +783 -0
  47. package/src/search-profile.ts +89 -0
  48. package/src/sem-provider.ts +36 -9
  49. package/src/stupify.ts +213 -526
  50. package/src/types.ts +195 -119
  51. package/dist/batcher.d.ts +0 -3
  52. package/dist/batcher.js +0 -142
  53. package/dist/candidate-context.d.ts +0 -2
  54. package/dist/candidate-context.js +0 -40
  55. package/dist/experiment.d.ts +0 -1
  56. package/dist/experiment.js +0 -225
  57. package/src/batcher.ts +0 -198
  58. package/src/candidate-context.ts +0 -43
  59. package/src/experiment.ts +0 -317
package/src/stupify.ts CHANGED
@@ -1,48 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { fileURLToPath } from "node:url";
4
- import {
5
- auditCandidates,
6
- countPromptTokens,
7
- findingsAuditRequest,
8
- runFindingsAudit,
9
- scoutBatch,
10
- scoutSemChanges,
11
- } from "./analysis.ts";
12
- import { batchDiff } from "./batcher.ts";
13
- import { candidateContexts } from "./candidate-context.ts";
14
- import { enabledChecks } from "./checks.ts";
4
+ import { countPromptTokens, runSearch, searchRequest } from "./analysis.ts";
5
+ import { searchChecks } from "./checks.ts";
15
6
  import { parseCommand } from "./command.ts";
16
- import { MODEL_REGISTRY } from "./constants.ts";
17
7
  import { counterScoutTargets } from "./counter-scout.ts";
18
- import { readDiffFromStdin } from "./diff.ts";
19
- import { runExperiment } from "./experiment.ts";
8
+ import { runDoctor } from "./doctor.ts";
9
+ import { runHookCommand } from "./hooks.ts";
10
+ import { firstRunModelBootstrap, loadLocalModel } from "./model.ts";
11
+ import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.ts";
12
+ import { helpText, renderSearchRun } from "./render.ts";
20
13
  import {
21
- netDiffForCommit,
22
- netDiffForRecentCommits,
23
- netDiffFromStdin,
24
- netDiffSince,
25
- } from "./git.ts";
26
- import { loadLocalModels, type LocalModel } from "./model.ts";
27
- import { emptyContextPack, entityContextsFromChanges, repomixContextPack } from "./repomix-provider.ts";
28
- import { helpText, renderReport } from "./render.ts";
14
+ effectiveMaxCandidates,
15
+ effectiveMaxSearchInputTokens,
16
+ effectiveRepomixConfig,
17
+ effectiveSearchChecks,
18
+ loadSearchProfile,
19
+ } from "./search-profile.ts";
29
20
  import { semChangeSetForCommand } from "./sem-provider.ts";
30
- import { createTracer, trace } from "./trace.ts";
31
- import type {
32
- AnalysisReport,
33
- AnalyzeCommand,
34
- AuditReviewResult,
35
- AuditReviewStats,
36
- DebugTarget,
37
- FindingsResult,
38
- NetDiff,
39
- SemCandidate,
40
- SemChangeSet,
41
- SemContext,
42
- TraceEvent,
43
- } from "./types.ts";
44
-
45
- const SEM_SCOUT_CHUNK_SIZE = 200;
21
+ import { createTracer } from "./trace.ts";
22
+ import type { SearchCommand, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
46
23
 
47
24
  export async function main(argv = process.argv.slice(2)): Promise<number> {
48
25
  const startedAt = Date.now();
@@ -52,19 +29,23 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
52
29
  console.log(helpText());
53
30
  return 0;
54
31
  }
55
- if (command.kind === "experiment") {
56
- const outputDir = await runExperiment(command.configPath);
57
- console.log(`Experiment results written to ${outputDir}`);
32
+ if (command.kind === "hook") {
33
+ console.log(await runHookCommand(command.action));
34
+ return 0;
35
+ }
36
+ if (command.kind === "doctor") {
37
+ const result = await runDoctor();
38
+ console.log(result.text);
39
+ return result.exitCode;
40
+ }
41
+ if (command.kind === "bench-search") {
42
+ const { runSearchBench } = await import("./search-bench.ts");
43
+ console.log(await runSearchBench(command.configPath));
58
44
  return 0;
59
45
  }
60
46
 
61
- const checks = enabledChecks(command.checkIds);
62
- const report =
63
- command.engine === "sem"
64
- ? await runSemEngine(command, checks, startedAt)
65
- : await runRawDiffEngine(command, checks, startedAt);
66
-
67
- console.log(renderReport(report, command));
47
+ const run = await runSearchCommand(command, startedAt);
48
+ console.log(renderSearchRun(run, command));
68
49
  return 0;
69
50
  } catch (error) {
70
51
  console.error(error instanceof Error ? error.message : String(error));
@@ -72,97 +53,23 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
72
53
  }
73
54
  }
74
55
 
75
- async function runRawDiffEngine(
76
- command: AnalyzeCommand,
77
- checks: ReturnType<typeof enabledChecks>,
78
- startedAt: number,
79
- ): Promise<AnalysisReport> {
80
- const { value: diff, ms: diffMs } = await trace.trace("net.diff", () =>
81
- netDiffForCommand(command),
82
- );
83
-
84
- printRunPlan(
85
- command,
86
- diff,
87
- checks.map((check) => check.id),
88
- );
89
-
90
- const { value: models, ms: modelMs } = await trace.trace(
91
- "model.load",
92
- () => loadLocalModels(command.model),
93
- );
94
- const { scoutModel, auditModel } = models;
95
-
96
- const batches = batchDiff(diff.text);
97
- const { value: candidatePointers, ms: searchMs } = await trace.trace(
98
- "search.total",
99
- async () => {
100
- const pointers: string[] = [];
101
- for (const batch of batches) {
102
- const { value: candidates } = await trace.trace(
103
- "search.batch",
104
- () => scoutBatch(scoutModel, batch, checks, diff.label),
105
- { fields: { batch: batch.id } },
106
- );
107
- pointers.push(...candidates);
108
- }
109
- return pointers;
110
- },
111
- );
112
-
113
- const contexts = candidateContexts(batches, candidatePointers);
114
- const auditedContexts = contexts;
115
- const { value: result, ms: auditMs } = await trace.trace(
116
- "audit.candidates",
117
- () => auditCandidates(auditModel, diff, auditedContexts, checks),
118
- { fields: { candidates: auditedContexts.length } },
119
- );
120
-
121
- return {
122
- run: {
123
- mode: command.kind,
124
- engine: command.engine,
125
- auditContext: command.auditContext,
126
- auditPrompt: command.auditPrompt,
127
- modelId: command.model,
128
- checkIds: checks.map((check) => check.id),
129
- sourceId: diff.id,
130
- label: diff.label,
131
- stats: diff.stats,
132
- batchesScanned: batches.length,
133
- candidateCount: new Set(candidatePointers).size,
134
- entitiesScanned: 0,
135
- auditedCandidateCount: auditedContexts.length,
136
- scoutModelCalls: batches.length,
137
- auditModelCalls: auditedContexts.length > 0 ? 1 : 0,
138
- warnings: [],
139
- timingsMs: {
140
- diff: diffMs,
141
- modelLoad: modelMs,
142
- search: searchMs,
143
- audit: auditMs,
144
- total: Date.now() - startedAt,
145
- },
146
- debugTargets: command.debugTargets ? [] : undefined,
147
- },
148
- result,
149
- };
150
- }
151
-
152
- async function runSemEngine(
153
- command: AnalyzeCommand,
154
- checks: ReturnType<typeof enabledChecks>,
155
- startedAt: number,
156
- ): Promise<AnalysisReport> {
157
- const traceEvents: TraceEvent[] = [];
56
+ export async function runSearchCommand(command: SearchCommand, startedAt: number): Promise<SearchRunJson> {
158
57
  const t = createTracer({
58
+ writeLine: () => undefined,
159
59
  onEvent: (event) => {
160
- traceEvents.push(event);
161
- debugSemTrace(command, event);
60
+ const parts = [`trace ${event.name}`, `${event.ms}ms`];
61
+ if (event.count !== undefined) parts.push(`count=${event.count}`);
62
+ if (event.detail) parts.push(event.detail);
63
+ console.error(parts.join(" "));
162
64
  },
163
65
  });
164
66
 
165
- const { value: changeSet, ms: diffMs } = await t.trace(
67
+ const profile = await loadSearchProfile(command.searchProfilePath);
68
+ const checks = profile ? effectiveSearchChecks(command.checkIds, profile) : searchChecks(command.checkIds);
69
+ const patternIds = checks.map((check) => check.id);
70
+ const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
71
+ const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
72
+ const { value: changeSet } = await t.trace(
166
73
  "entity.diff",
167
74
  () => semChangeSetForCommand(command),
168
75
  {
@@ -171,426 +78,206 @@ async function runSemEngine(
171
78
  },
172
79
  );
173
80
 
174
- printSemRunPlan(
175
- command,
176
- changeSet,
177
- checks.map((check) => check.id),
178
- );
179
-
180
- const { value: models, ms: modelMs } = await t.trace(
181
- "model.load",
182
- () => loadLocalModels(command.model),
183
- {
184
- count: () => 2,
185
- detail: () => "scout+audit",
186
- },
187
- );
188
- const { scoutModel, auditModel } = models;
189
-
190
81
  try {
191
- const candidateBatches = chunkSemChangeSet(changeSet);
192
- const { value: candidates, ms: searchMs } = await t.trace(
193
- "scout.total",
194
- async () =>
195
- candidateBatches.length === 0
196
- ? []
197
- : command.scout === "counter"
198
- ? counterScoutTargets(changeSet, checks, command.maxCandidates)
199
- : scoutSemBatches(
200
- scoutModel,
201
- candidateBatches,
202
- checks,
203
- command,
204
- traceEvents,
205
- t,
206
- ),
207
- {
208
- count: (v) => v.length,
209
- detail: () => `${command.scout} scout ${candidateBatches.length} batches`,
210
- },
211
- );
82
+ printRunPlan(command, changeSet.summary.fileCount, changeSet.summary.total, patternIds);
83
+ const candidates = counterScoutTargets(changeSet, checks, maxCandidates);
84
+ const contexts = entityContextsFromChanges(candidates, changeSet.changes);
85
+ const targetsByPattern = countTargetsByPattern(contexts);
86
+ const targetsPreview = previewTargets(contexts);
87
+ if (contexts.length === 0) {
88
+ return {
89
+ schemaVersion: "search.v1",
90
+ mode: "search",
91
+ source: command.source,
92
+ model: { id: command.model },
93
+ patterns: patternIds,
94
+ stats: {
95
+ elapsedMs: Date.now() - startedAt,
96
+ modelCalls: 0,
97
+ skipped: true,
98
+ skipReason: "no_candidates",
99
+ filesChanged: changeSet.summary.fileCount,
100
+ entitiesScanned: changeSet.summary.total,
101
+ candidates: 0,
102
+ searchTargets: 0,
103
+ repomixFiles: 0,
104
+ repomixTokens: 0,
105
+ profileId: profile?.id,
106
+ targetsByPattern,
107
+ targetsPreview,
108
+ },
109
+ matches: [],
110
+ };
111
+ }
212
112
 
213
- const { value: contexts, ms: contextMs } = await t.trace(
214
- "context.select",
215
- async () => entityContextsFromChanges(candidates, changeSet.changes),
216
- {
217
- fields: { candidates: candidates.length },
218
- count: (v) => v.length,
219
- detail: (v) => `${new Set(v.map((context) => context.filePath).filter(Boolean)).size} files`,
220
- },
221
- );
113
+ const baseRepomixConfig = effectiveRepomixConfig(repomixSearchConfig(), profile);
114
+ const initialPack = profile?.context === "sem"
115
+ ? emptyContextPack()
116
+ : await t.trace(
117
+ "context.pack",
118
+ () => repomixContextPack(changeSet.contextCwd, contexts, changeSet.changes, baseRepomixConfig),
119
+ {
120
+ count: (v) => v.filePaths.length,
121
+ detail: (v) => `${v.totalTokens} tokens`,
122
+ },
123
+ ).then((result) => result.value);
124
+ const packedFiles = new Set(initialPack.filePaths);
125
+ const searchContexts = profile?.context === "sem"
126
+ ? contexts
127
+ : contexts.filter((context) => context.filePath && packedFiles.has(context.filePath));
128
+ if (searchContexts.length === 0) {
129
+ return {
130
+ schemaVersion: "search.v1",
131
+ mode: "search",
132
+ source: command.source,
133
+ model: { id: command.model },
134
+ patterns: patternIds,
135
+ stats: {
136
+ elapsedMs: Date.now() - startedAt,
137
+ modelCalls: 0,
138
+ skipped: true,
139
+ skipReason: "no_candidates",
140
+ filesChanged: changeSet.summary.fileCount,
141
+ entitiesScanned: changeSet.summary.total,
142
+ candidates: contexts.length,
143
+ searchTargets: 0,
144
+ repomixFiles: initialPack.filePaths.length,
145
+ repomixTokens: initialPack.totalTokens,
146
+ repomixConfig: initialPack.config,
147
+ profileId: profile?.id,
148
+ targetsByPattern,
149
+ targetsPreview,
150
+ },
151
+ matches: [],
152
+ };
153
+ }
154
+ const pack = profile?.context === "sem" || searchContexts.length === contexts.length
155
+ ? initialPack
156
+ : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
222
157
 
223
- const auditBatches = chunkSemContexts(contexts, command.auditBatchSize);
224
- const { value: result, ms: auditMs } = await t.trace(
225
- "audit.total",
226
- () =>
227
- findingsAuditBatches(
228
- auditModel,
229
- changeSet,
230
- auditBatches,
231
- checks,
232
- traceEvents,
233
- t,
234
- command,
235
- ),
236
- {
237
- count: (v) => v.findings.length,
238
- detail: (v) =>
239
- `${auditBatches.length} batches targets=${v.stats.totalTargets} clean=${v.stats.clean} uncertain=${v.stats.uncertain} invalid=${v.stats.invalid}`,
240
- },
158
+ const modelPath = await firstRunModelBootstrap(command.model);
159
+ const model = await loadLocalModel(modelPath, command.model, "scout");
160
+ const request = buildSearchRequest(
161
+ changeSet,
162
+ searchContexts,
163
+ pack,
164
+ checks,
165
+ profile,
166
+ command.includeCounterReasonInPrompt,
241
167
  );
242
-
243
- return {
244
- run: {
245
- mode: command.kind,
246
- engine: command.engine,
247
- auditContext: command.auditContext,
248
- auditPrompt: command.auditPrompt,
249
- modelId: command.model,
250
- checkIds: checks.map((check) => check.id),
251
- sourceId: changeSet.id,
252
- label: changeSet.label,
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,
253
176
  stats: {
177
+ elapsedMs: Date.now() - startedAt,
178
+ modelCalls: 0,
179
+ inputTokens,
180
+ inputTokenCap: maxSearchInputTokens,
181
+ skipped: true,
182
+ skipReason: "input_too_large",
254
183
  filesChanged: changeSet.summary.fileCount,
255
- additions: changeSet.summary.added,
256
- deletions: changeSet.summary.deleted,
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),
257
193
  },
258
- batchesScanned: 0,
194
+ matches: [],
195
+ };
196
+ }
197
+
198
+ const { value: matches } = await t.trace(
199
+ "search.model",
200
+ () => runSearch(model, request),
201
+ { count: (v) => v.length },
202
+ );
203
+
204
+ return {
205
+ schemaVersion: "search.v1",
206
+ mode: "search",
207
+ source: command.source,
208
+ model: { id: command.model },
209
+ patterns: patternIds,
210
+ stats: {
211
+ elapsedMs: Date.now() - startedAt,
212
+ modelCalls: 1,
213
+ inputTokens,
214
+ inputTokenCap: maxSearchInputTokens,
215
+ filesChanged: changeSet.summary.fileCount,
259
216
  entitiesScanned: changeSet.summary.total,
260
- candidateCount: candidates.length,
261
- targetsByCheck: countTargetsByCheck(candidates),
262
- auditedCandidateCount: contexts.length,
263
- scoutModelCalls: traceEvents.filter((event) => event.name === "scout.batch").length,
264
- auditModelCalls: result.auditModelCalls,
265
- timingsMs: {
266
- diff: diffMs,
267
- modelLoad: modelMs,
268
- search: searchMs,
269
- audit: auditMs + contextMs,
270
- total: Date.now() - startedAt,
271
- },
272
- warnings: [],
273
- auditStats: result.stats,
274
- debugTargets: command.debugTargets ? debugTargetsFromContexts(contexts, changeSet.label) : undefined,
275
- traceEvents,
217
+ candidates: contexts.length,
218
+ searchTargets: searchContexts.length,
219
+ repomixFiles: pack.filePaths.length,
220
+ repomixTokens: pack.totalTokens,
221
+ repomixConfig: pack.config,
222
+ profileId: profile?.id,
223
+ targetsByPattern: countTargetsByPattern(searchContexts),
224
+ targetsPreview: previewTargets(searchContexts),
276
225
  },
277
- result,
226
+ matches,
278
227
  };
279
228
  } finally {
280
229
  await changeSet.cleanup();
281
230
  }
282
231
  }
283
232
 
284
- async function findingsAuditBatches(
285
- model: LocalModel,
286
- changeSet: SemChangeSet,
287
- batches: readonly (readonly SemContext[])[],
288
- checks: ReturnType<typeof enabledChecks>,
289
- traceEvents: TraceEvent[],
290
- t: ReturnType<typeof createTracer>,
291
- command: AnalyzeCommand,
292
- ): Promise<FindingsResult & { stats: AuditReviewStats; auditModelCalls: number }> {
293
- const findings = [];
294
- const stats = { totalTargets: 0, finding: 0, clean: 0, uncertain: 0, invalid: 0 };
295
- const limiter = new ConcurrencyLimiter(command.auditConcurrency);
296
- for (const [index, batch] of batches.entries()) {
297
- const result = await findingsAuditBatch(
298
- model,
299
- changeSet,
300
- batch,
301
- checks,
302
- traceEvents,
303
- command,
304
- limiter,
305
- `${index + 1}/${batches.length}`,
306
- );
307
- findings.push(...result.findings);
308
- stats.totalTargets += result.stats.totalTargets;
309
- stats.finding += result.stats.finding;
310
- stats.clean += result.stats.clean;
311
- stats.uncertain += result.stats.uncertain;
312
- stats.invalid += result.stats.invalid;
313
- }
314
- return {
315
- findings,
316
- summary:
317
- findings.length === 0
318
- ? "No clear judgment-offload signal found."
319
- : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
320
- stats,
321
- auditModelCalls: traceEvents.filter((event) => event.name === "audit.batch").length,
322
- };
323
- }
324
-
325
- async function findingsAuditBatch(
326
- model: LocalModel,
327
- changeSet: SemChangeSet,
328
- batch: readonly SemContext[],
329
- checks: ReturnType<typeof enabledChecks>,
330
- traceEvents: TraceEvent[],
331
- command: AnalyzeCommand,
332
- limiter: ConcurrencyLimiter,
333
- batchLabel: string,
334
- ): Promise<AuditReviewResult> {
335
- const { value: pack, ms: contextMs } = await trace.trace(
336
- "context.pack",
337
- () =>
338
- command.auditContext === "none"
339
- ? Promise.resolve(emptyContextPack())
340
- : repomixContextPack(changeSet.contextCwd, batch, changeSet.changes),
341
- { fields: { candidates: batch.length } },
342
- );
343
- const request = findingsAuditRequest(changeSet, batch, pack, checks, command.auditPrompt);
344
- const inputTokens = await countPromptTokens(model, request.prompt);
345
- const contextEvent = {
346
- name: "context.pack",
347
- ms: contextMs,
348
- count: pack.filePaths.length,
349
- detail: `batch=${batchLabel} input_tokens=${inputTokens} pack_tokens=${pack.totalTokens} chars=${pack.totalCharacters}`,
350
- };
351
- traceEvents.push(contextEvent);
352
- debugSemTrace(command, contextEvent);
353
-
354
- if (inputTokens > command.maxAuditInputTokens) {
355
- if (batch.length <= 1) {
356
- throw new Error(`Findings audit input has ${inputTokens} tokens, above max ${command.maxAuditInputTokens}.`);
357
- }
358
- const splitAt = Math.ceil(batch.length / 2);
359
- const splitEvent = {
360
- name: "audit.split",
361
- ms: 0,
362
- count: batch.length,
363
- detail: `batch=${batchLabel} input_tokens=${inputTokens} max=${command.maxAuditInputTokens}`,
364
- };
365
- traceEvents.push(splitEvent);
366
- debugSemTrace(command, splitEvent);
367
- const [left, right] = await Promise.all([
368
- findingsAuditBatch(
369
- model,
370
- changeSet,
371
- batch.slice(0, splitAt),
372
- checks,
373
- traceEvents,
374
- command,
375
- limiter,
376
- `${batchLabel}.1`,
377
- ),
378
- findingsAuditBatch(
379
- model,
380
- changeSet,
381
- batch.slice(splitAt),
382
- checks,
383
- traceEvents,
384
- command,
385
- limiter,
386
- `${batchLabel}.2`,
387
- ),
388
- ]);
389
- return combineAuditResults(left, right);
390
- }
391
-
392
- const { value: result, ms: auditMs } = await trace.trace(
393
- "audit.batch",
394
- () => limiter.run(() => runFindingsAudit(model, changeSet, batch, pack, checks, request)),
395
- { fields: { candidates: batch.length } },
396
- );
397
- const event = {
398
- name: "audit.batch",
399
- ms: auditMs,
400
- count: result.findings.length,
401
- detail: `batch=${batchLabel} candidates=${batch.length} input_tokens=${inputTokens} targets=${result.stats.totalTargets} clean=${result.stats.clean} uncertain=${result.stats.uncertain} invalid=${result.stats.invalid}`,
402
- };
403
- traceEvents.push(event);
404
- debugSemTrace(command, event);
405
- return result;
233
+ function buildSearchRequest(
234
+ changeSet: Parameters<typeof searchRequest>[0]["changeSet"],
235
+ contexts: Parameters<typeof searchRequest>[0]["contexts"],
236
+ pack: SemContextPack,
237
+ patterns: readonly StupifyCheck[],
238
+ profile: SearchProfile | null,
239
+ includeCounterReasonInPrompt: boolean,
240
+ ) {
241
+ return searchRequest({
242
+ changeSet,
243
+ contexts,
244
+ pack,
245
+ patterns,
246
+ includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
247
+ });
406
248
  }
407
249
 
408
- function combineAuditResults(
409
- left: AuditReviewResult,
410
- right: AuditReviewResult,
411
- ): AuditReviewResult {
412
- const findings = [...left.findings, ...right.findings];
413
- return {
414
- findings,
415
- summary:
416
- findings.length === 0
417
- ? "No clear judgment-offload signal found."
418
- : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
419
- stats: {
420
- totalTargets: left.stats.totalTargets + right.stats.totalTargets,
421
- finding: left.stats.finding + right.stats.finding,
422
- clean: left.stats.clean + right.stats.clean,
423
- uncertain: left.stats.uncertain + right.stats.uncertain,
424
- invalid: left.stats.invalid + right.stats.invalid,
425
- },
426
- };
250
+ function printRunPlan(
251
+ command: SearchCommand,
252
+ filesChanged: number,
253
+ entitiesScanned: number,
254
+ patternIds: readonly string[],
255
+ ): void {
256
+ if (command.json) return;
257
+ console.error("🧙 stupify 🪄");
258
+ console.error(`Mode: search (${command.source})`);
259
+ console.error(`Sem: ${filesChanged} files, ${entitiesScanned} changed entities`);
260
+ console.error(`Patterns: ${patternIds.join(", ")}`);
427
261
  }
428
262
 
429
- function countTargetsByCheck(candidates: readonly SemCandidate[]): Record<string, number> {
263
+ function countTargetsByPattern(contexts: readonly SemContext[]): Record<string, number> {
430
264
  const counts: Record<string, number> = {};
431
- for (const candidate of candidates) {
432
- counts[candidate.checkId] = (counts[candidate.checkId] ?? 0) + 1;
433
- }
265
+ for (const context of contexts) counts[context.checkId] = (counts[context.checkId] ?? 0) + 1;
434
266
  return counts;
435
267
  }
436
268
 
437
- function debugTargetsFromContexts(
438
- contexts: readonly SemContext[],
439
- sourceLabel: string,
440
- ): readonly DebugTarget[] {
269
+ function previewTargets(contexts: readonly SemContext[]) {
441
270
  return contexts.map((context) => ({
442
271
  targetId: context.targetId,
443
- checkId: context.checkId,
444
- entityId: context.entityId,
445
- entityKind: context.entityKind,
446
- changeKind: context.changeKind,
447
- scoutReason: context.reason,
448
- sourceLabel,
272
+ patternId: context.checkId,
273
+ entityKind: context.entityKind || undefined,
274
+ sourceKind: context.filePath ? pathKind(context.filePath) : undefined,
449
275
  }));
450
276
  }
451
277
 
452
- class ConcurrencyLimiter {
453
- private active = 0;
454
- private readonly queue: Array<() => void> = [];
455
-
456
- constructor(private readonly max: number) {}
457
-
458
- async run<T>(task: () => Promise<T>): Promise<T> {
459
- if (this.active >= this.max) {
460
- await new Promise<void>((resolve) => this.queue.push(resolve));
461
- }
462
- this.active += 1;
463
- try {
464
- return await task();
465
- } finally {
466
- this.active -= 1;
467
- this.queue.shift()?.();
468
- }
469
- }
470
- }
471
-
472
- async function scoutSemBatches(
473
- model: LocalModel,
474
- batches: readonly SemChangeSet[],
475
- checks: ReturnType<typeof enabledChecks>,
476
- command: AnalyzeCommand,
477
- traceEvents: TraceEvent[],
478
- t: ReturnType<typeof createTracer>,
479
- ): Promise<readonly SemCandidate[]> {
480
- const candidates: SemCandidate[] = [];
481
- const seen = new Set<string>();
482
- const targetsByCheck = new Map<string, number>();
483
- const maxTargetsPerCheck = 6;
484
- for (const [index, batch] of batches.entries()) {
485
- if (candidates.length >= command.maxCandidates) break;
486
- const remaining: number = command.maxCandidates - candidates.length;
487
- const { value: batchCandidates } = await t.trace(
488
- "scout.batch",
489
- async () => scoutSemChanges(model, batch, checks, remaining),
490
- {
491
- fields: { entities: batch.changes.length },
492
- count: (v) => v.length,
493
- detail: (v) =>
494
- `batch=${index + 1}/${batches.length} entities=${batch.changes.length} remaining=${remaining}`,
495
- },
496
- );
497
- for (const candidate of batchCandidates) {
498
- const key = `${candidate.entityId}\u0000${candidate.checkId}`;
499
- if (seen.has(key)) continue;
500
- const checkCount = targetsByCheck.get(candidate.checkId) ?? 0;
501
- if (checkCount >= maxTargetsPerCheck) continue;
502
- seen.add(key);
503
- targetsByCheck.set(candidate.checkId, checkCount + 1);
504
- candidates.push({
505
- ...candidate,
506
- targetId: `t${String(candidates.length + 1).padStart(3, "0")}`,
507
- });
508
- if (candidates.length >= command.maxCandidates) break;
509
- }
510
- }
511
- return candidates;
512
- }
513
-
514
- function debugSemTrace(command: AnalyzeCommand, event: TraceEvent): void {
515
- if (!command.debugSem) return;
516
- const parts = [`trace ${event.name}`, `${event.ms}ms`];
517
- if (event.count !== undefined) parts.push(`count=${event.count}`);
518
- if (event.detail) parts.push(event.detail);
519
- console.error(parts.join(" "));
520
- }
521
-
522
- function chunkSemChangeSet(changeSet: SemChangeSet): readonly SemChangeSet[] {
523
- const chunks: SemChangeSet[] = [];
524
- for (
525
- let index = 0;
526
- index < changeSet.changes.length;
527
- index += SEM_SCOUT_CHUNK_SIZE
528
- ) {
529
- const changes = changeSet.changes.slice(
530
- index,
531
- index + SEM_SCOUT_CHUNK_SIZE,
532
- );
533
- chunks.push({
534
- ...changeSet,
535
- label: `${changeSet.label} batch ${chunks.length + 1}`,
536
- changes,
537
- summary: {
538
- ...changeSet.summary,
539
- fileCount: new Set(changes.map((change) => change.filePath)).size,
540
- total: changes.length,
541
- },
542
- });
543
- }
544
- return chunks;
545
- }
546
-
547
- function chunkSemContexts(
548
- contexts: readonly SemContext[],
549
- chunkSize: number,
550
- ): readonly (readonly SemContext[])[] {
551
- const chunks: SemContext[][] = [];
552
- for (let index = 0; index < contexts.length; index += chunkSize) {
553
- chunks.push(contexts.slice(index, index + chunkSize));
554
- }
555
- return chunks;
556
- }
557
-
558
- function printRunPlan(
559
- command: AnalyzeCommand,
560
- diff: NetDiff,
561
- checkIds: readonly string[],
562
- ): void {
563
- if (command.json) return;
564
- console.error("🧙 stupify 🪄");
565
- console.error(`Window: ${diff.label}`);
566
- console.error(
567
- `Diff: ${diff.stats.filesChanged} files changed, ${diff.stats.additions} added, ${diff.stats.deletions} deleted`,
568
- );
569
- console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
570
- console.error(`Checks: ${checkIds.join(", ")}`);
571
- }
572
-
573
- function printSemRunPlan(
574
- command: AnalyzeCommand,
575
- changeSet: SemChangeSet,
576
- checkIds: readonly string[],
577
- ): void {
578
- if (command.json) return;
579
- console.error("🧙 stupify 🪄");
580
- console.error(`Window: ${changeSet.label}`);
581
- console.error(
582
- `Sem: ${changeSet.summary.fileCount} files, ${changeSet.summary.total} changed entities`,
583
- );
584
- console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
585
- console.error(`Checks: ${checkIds.join(", ")}`);
586
- }
587
-
588
- async function netDiffForCommand(command: AnalyzeCommand): Promise<NetDiff> {
589
- if (command.kind === "since") return netDiffSince(command.since);
590
- if (command.kind === "stdin")
591
- return netDiffFromStdin(await readDiffFromStdin());
592
- if (command.kind === "commit") return netDiffForCommit(command.commit);
593
- return netDiffForRecentCommits(command.count);
278
+ function pathKind(filePath: string): string {
279
+ const ext = filePath.split(".").pop();
280
+ return ext && ext !== filePath ? ext : "unknown";
594
281
  }
595
282
 
596
283
  if (process.argv[1] === fileURLToPath(import.meta.url)) {