@stupify/cli 0.0.3 → 0.0.5

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 +185 -334
  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 +215 -527
  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,26 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import { realpathSync } from "node:fs";
3
4
  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";
5
+ import { countPromptTokens, runSearch, searchRequest } from "./analysis.ts";
6
+ import { searchChecks } from "./checks.ts";
15
7
  import { parseCommand } from "./command.ts";
16
- import { MODEL_REGISTRY } from "./constants.ts";
17
8
  import { counterScoutTargets } from "./counter-scout.ts";
18
- import { readDiffFromStdin } from "./diff.ts";
19
- import { runExperiment } from "./experiment.ts";
9
+ import { runDoctor } from "./doctor.ts";
10
+ import { runHookCommand } from "./hooks.ts";
11
+ import { firstRunModelBootstrap, loadLocalModel } from "./model.ts";
12
+ import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.ts";
13
+ import { helpText, renderSearchRun } from "./render.ts";
20
14
  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";
15
+ effectiveMaxCandidates,
16
+ effectiveMaxSearchInputTokens,
17
+ effectiveRepomixConfig,
18
+ effectiveSearchChecks,
19
+ loadSearchProfile,
20
+ } from "./search-profile.ts";
29
21
  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;
22
+ import { createTracer } from "./trace.ts";
23
+ import type { SearchCommand, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
46
24
 
47
25
  export async function main(argv = process.argv.slice(2)): Promise<number> {
48
26
  const startedAt = Date.now();
@@ -52,19 +30,23 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
52
30
  console.log(helpText());
53
31
  return 0;
54
32
  }
55
- if (command.kind === "experiment") {
56
- const outputDir = await runExperiment(command.configPath);
57
- console.log(`Experiment results written to ${outputDir}`);
33
+ if (command.kind === "hook") {
34
+ console.log(await runHookCommand(command.action));
35
+ return 0;
36
+ }
37
+ if (command.kind === "doctor") {
38
+ const result = await runDoctor();
39
+ console.log(result.text);
40
+ return result.exitCode;
41
+ }
42
+ if (command.kind === "bench-search") {
43
+ const { runSearchBench } = await import("./search-bench.ts");
44
+ console.log(await runSearchBench(command.configPath));
58
45
  return 0;
59
46
  }
60
47
 
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));
48
+ const run = await runSearchCommand(command, startedAt);
49
+ console.log(renderSearchRun(run, command));
68
50
  return 0;
69
51
  } catch (error) {
70
52
  console.error(error instanceof Error ? error.message : String(error));
@@ -72,97 +54,23 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
72
54
  }
73
55
  }
74
56
 
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[] = [];
57
+ export async function runSearchCommand(command: SearchCommand, startedAt: number): Promise<SearchRunJson> {
158
58
  const t = createTracer({
59
+ writeLine: () => undefined,
159
60
  onEvent: (event) => {
160
- traceEvents.push(event);
161
- debugSemTrace(command, event);
61
+ const parts = [`trace ${event.name}`, `${event.ms}ms`];
62
+ if (event.count !== undefined) parts.push(`count=${event.count}`);
63
+ if (event.detail) parts.push(event.detail);
64
+ console.error(parts.join(" "));
162
65
  },
163
66
  });
164
67
 
165
- const { value: changeSet, ms: diffMs } = await t.trace(
68
+ const profile = await loadSearchProfile(command.searchProfilePath);
69
+ const checks = profile ? effectiveSearchChecks(command.checkIds, profile) : searchChecks(command.checkIds);
70
+ const patternIds = checks.map((check) => check.id);
71
+ const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
72
+ const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
73
+ const { value: changeSet } = await t.trace(
166
74
  "entity.diff",
167
75
  () => semChangeSetForCommand(command),
168
76
  {
@@ -171,428 +79,208 @@ async function runSemEngine(
171
79
  },
172
80
  );
173
81
 
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
82
  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
- );
83
+ printRunPlan(command, changeSet.summary.fileCount, changeSet.summary.total, patternIds);
84
+ const candidates = counterScoutTargets(changeSet, checks, maxCandidates);
85
+ const contexts = entityContextsFromChanges(candidates, changeSet.changes);
86
+ const targetsByPattern = countTargetsByPattern(contexts);
87
+ const targetsPreview = previewTargets(contexts);
88
+ if (contexts.length === 0) {
89
+ return {
90
+ schemaVersion: "search.v1",
91
+ mode: "search",
92
+ source: command.source,
93
+ model: { id: command.model },
94
+ patterns: patternIds,
95
+ stats: {
96
+ elapsedMs: Date.now() - startedAt,
97
+ modelCalls: 0,
98
+ skipped: true,
99
+ skipReason: "no_candidates",
100
+ filesChanged: changeSet.summary.fileCount,
101
+ entitiesScanned: changeSet.summary.total,
102
+ candidates: 0,
103
+ searchTargets: 0,
104
+ repomixFiles: 0,
105
+ repomixTokens: 0,
106
+ profileId: profile?.id,
107
+ targetsByPattern,
108
+ targetsPreview,
109
+ },
110
+ matches: [],
111
+ };
112
+ }
212
113
 
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
- );
114
+ const baseRepomixConfig = effectiveRepomixConfig(repomixSearchConfig(), profile);
115
+ const initialPack = profile?.context === "sem"
116
+ ? emptyContextPack()
117
+ : await t.trace(
118
+ "context.pack",
119
+ () => repomixContextPack(changeSet.contextCwd, contexts, changeSet.changes, baseRepomixConfig),
120
+ {
121
+ count: (v) => v.filePaths.length,
122
+ detail: (v) => `${v.totalTokens} tokens`,
123
+ },
124
+ ).then((result) => result.value);
125
+ const packedFiles = new Set(initialPack.filePaths);
126
+ const searchContexts = profile?.context === "sem"
127
+ ? contexts
128
+ : contexts.filter((context) => context.filePath && packedFiles.has(context.filePath));
129
+ if (searchContexts.length === 0) {
130
+ return {
131
+ schemaVersion: "search.v1",
132
+ mode: "search",
133
+ source: command.source,
134
+ model: { id: command.model },
135
+ patterns: patternIds,
136
+ stats: {
137
+ elapsedMs: Date.now() - startedAt,
138
+ modelCalls: 0,
139
+ skipped: true,
140
+ skipReason: "no_candidates",
141
+ filesChanged: changeSet.summary.fileCount,
142
+ entitiesScanned: changeSet.summary.total,
143
+ candidates: contexts.length,
144
+ searchTargets: 0,
145
+ repomixFiles: initialPack.filePaths.length,
146
+ repomixTokens: initialPack.totalTokens,
147
+ repomixConfig: initialPack.config,
148
+ profileId: profile?.id,
149
+ targetsByPattern,
150
+ targetsPreview,
151
+ },
152
+ matches: [],
153
+ };
154
+ }
155
+ const pack = profile?.context === "sem" || searchContexts.length === contexts.length
156
+ ? initialPack
157
+ : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
222
158
 
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
- },
159
+ const modelPath = await firstRunModelBootstrap(command.model);
160
+ const model = await loadLocalModel(modelPath, command.model, "scout");
161
+ const request = buildSearchRequest(
162
+ changeSet,
163
+ searchContexts,
164
+ pack,
165
+ checks,
166
+ profile,
167
+ command.includeCounterReasonInPrompt,
241
168
  );
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,
169
+ const inputTokens = await countPromptTokens(model, request.prompt);
170
+ if (inputTokens > maxSearchInputTokens) {
171
+ return {
172
+ schemaVersion: "search.v1",
173
+ mode: "search",
174
+ source: command.source,
175
+ model: { id: command.model },
176
+ patterns: patternIds,
253
177
  stats: {
178
+ elapsedMs: Date.now() - startedAt,
179
+ modelCalls: 0,
180
+ inputTokens,
181
+ inputTokenCap: maxSearchInputTokens,
182
+ skipped: true,
183
+ skipReason: "input_too_large",
254
184
  filesChanged: changeSet.summary.fileCount,
255
- additions: changeSet.summary.added,
256
- deletions: changeSet.summary.deleted,
185
+ entitiesScanned: changeSet.summary.total,
186
+ candidates: contexts.length,
187
+ searchTargets: searchContexts.length,
188
+ repomixFiles: pack.filePaths.length,
189
+ repomixTokens: pack.totalTokens,
190
+ repomixConfig: pack.config,
191
+ profileId: profile?.id,
192
+ targetsByPattern: countTargetsByPattern(searchContexts),
193
+ targetsPreview: previewTargets(searchContexts),
257
194
  },
258
- batchesScanned: 0,
195
+ matches: [],
196
+ };
197
+ }
198
+
199
+ const { value: matches } = await t.trace(
200
+ "search.model",
201
+ () => runSearch(model, request),
202
+ { count: (v) => v.length },
203
+ );
204
+
205
+ return {
206
+ schemaVersion: "search.v1",
207
+ mode: "search",
208
+ source: command.source,
209
+ model: { id: command.model },
210
+ patterns: patternIds,
211
+ stats: {
212
+ elapsedMs: Date.now() - startedAt,
213
+ modelCalls: 1,
214
+ inputTokens,
215
+ inputTokenCap: maxSearchInputTokens,
216
+ filesChanged: changeSet.summary.fileCount,
259
217
  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,
218
+ candidates: contexts.length,
219
+ searchTargets: searchContexts.length,
220
+ repomixFiles: pack.filePaths.length,
221
+ repomixTokens: pack.totalTokens,
222
+ repomixConfig: pack.config,
223
+ profileId: profile?.id,
224
+ targetsByPattern: countTargetsByPattern(searchContexts),
225
+ targetsPreview: previewTargets(searchContexts),
276
226
  },
277
- result,
227
+ matches,
278
228
  };
279
229
  } finally {
280
230
  await changeSet.cleanup();
281
231
  }
282
232
  }
283
233
 
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;
234
+ function buildSearchRequest(
235
+ changeSet: Parameters<typeof searchRequest>[0]["changeSet"],
236
+ contexts: Parameters<typeof searchRequest>[0]["contexts"],
237
+ pack: SemContextPack,
238
+ patterns: readonly StupifyCheck[],
239
+ profile: SearchProfile | null,
240
+ includeCounterReasonInPrompt: boolean,
241
+ ) {
242
+ return searchRequest({
243
+ changeSet,
244
+ contexts,
245
+ pack,
246
+ patterns,
247
+ includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
248
+ });
406
249
  }
407
250
 
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
- };
251
+ function printRunPlan(
252
+ command: SearchCommand,
253
+ filesChanged: number,
254
+ entitiesScanned: number,
255
+ patternIds: readonly string[],
256
+ ): void {
257
+ if (command.json) return;
258
+ console.error("🧙 stupify 🪄");
259
+ console.error(`Mode: search (${command.source})`);
260
+ console.error(`Sem: ${filesChanged} files, ${entitiesScanned} changed entities`);
261
+ console.error(`Patterns: ${patternIds.join(", ")}`);
427
262
  }
428
263
 
429
- function countTargetsByCheck(candidates: readonly SemCandidate[]): Record<string, number> {
264
+ function countTargetsByPattern(contexts: readonly SemContext[]): Record<string, number> {
430
265
  const counts: Record<string, number> = {};
431
- for (const candidate of candidates) {
432
- counts[candidate.checkId] = (counts[candidate.checkId] ?? 0) + 1;
433
- }
266
+ for (const context of contexts) counts[context.checkId] = (counts[context.checkId] ?? 0) + 1;
434
267
  return counts;
435
268
  }
436
269
 
437
- function debugTargetsFromContexts(
438
- contexts: readonly SemContext[],
439
- sourceLabel: string,
440
- ): readonly DebugTarget[] {
270
+ function previewTargets(contexts: readonly SemContext[]) {
441
271
  return contexts.map((context) => ({
442
272
  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,
273
+ patternId: context.checkId,
274
+ entityKind: context.entityKind || undefined,
275
+ sourceKind: context.filePath ? pathKind(context.filePath) : undefined,
449
276
  }));
450
277
  }
451
278
 
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);
279
+ function pathKind(filePath: string): string {
280
+ const ext = filePath.split(".").pop();
281
+ return ext && ext !== filePath ? ext : "unknown";
594
282
  }
595
283
 
596
- if (process.argv[1] === fileURLToPath(import.meta.url)) {
284
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
597
285
  process.exitCode = await main();
598
286
  }