@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/dist/stupify.js CHANGED
@@ -1,21 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from "node:url";
3
- import { auditCandidates, countPromptTokens, findingsAuditRequest, runFindingsAudit, scoutBatch, scoutSemChanges, } from "./analysis.js";
4
- import { batchDiff } from "./batcher.js";
5
- import { candidateContexts } from "./candidate-context.js";
6
- import { enabledChecks } from "./checks.js";
3
+ import { countPromptTokens, runSearch, searchRequest } from "./analysis.js";
4
+ import { searchChecks } from "./checks.js";
7
5
  import { parseCommand } from "./command.js";
8
- import { MODEL_REGISTRY } from "./constants.js";
9
6
  import { counterScoutTargets } from "./counter-scout.js";
10
- import { readDiffFromStdin } from "./diff.js";
11
- import { runExperiment } from "./experiment.js";
12
- import { netDiffForCommit, netDiffForRecentCommits, netDiffFromStdin, netDiffSince, } from "./git.js";
13
- import { loadLocalModels } from "./model.js";
14
- import { emptyContextPack, entityContextsFromChanges, repomixContextPack } from "./repomix-provider.js";
15
- import { helpText, renderReport } from "./render.js";
7
+ import { runDoctor } from "./doctor.js";
8
+ import { runHookCommand } from "./hooks.js";
9
+ import { firstRunModelBootstrap, loadLocalModel } from "./model.js";
10
+ import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.js";
11
+ import { helpText, renderSearchRun } from "./render.js";
12
+ import { effectiveMaxCandidates, effectiveMaxSearchInputTokens, effectiveRepomixConfig, effectiveSearchChecks, loadSearchProfile, } from "./search-profile.js";
16
13
  import { semChangeSetForCommand } from "./sem-provider.js";
17
- import { createTracer, trace } from "./trace.js";
18
- const SEM_SCOUT_CHUNK_SIZE = 200;
14
+ import { createTracer } from "./trace.js";
19
15
  export async function main(argv = process.argv.slice(2)) {
20
16
  const startedAt = Date.now();
21
17
  try {
@@ -24,16 +20,22 @@ export async function main(argv = process.argv.slice(2)) {
24
20
  console.log(helpText());
25
21
  return 0;
26
22
  }
27
- if (command.kind === "experiment") {
28
- const outputDir = await runExperiment(command.configPath);
29
- console.log(`Experiment results written to ${outputDir}`);
23
+ if (command.kind === "hook") {
24
+ console.log(await runHookCommand(command.action));
30
25
  return 0;
31
26
  }
32
- const checks = enabledChecks(command.checkIds);
33
- const report = command.engine === "sem"
34
- ? await runSemEngine(command, checks, startedAt)
35
- : await runRawDiffEngine(command, checks, startedAt);
36
- console.log(renderReport(report, command));
27
+ if (command.kind === "doctor") {
28
+ const result = await runDoctor();
29
+ console.log(result.text);
30
+ return result.exitCode;
31
+ }
32
+ if (command.kind === "bench-search") {
33
+ const { runSearchBench } = await import("./search-bench.js");
34
+ console.log(await runSearchBench(command.configPath));
35
+ return 0;
36
+ }
37
+ const run = await runSearchCommand(command, startedAt);
38
+ console.log(renderSearchRun(run, command));
37
39
  return 0;
38
40
  }
39
41
  catch (error) {
@@ -41,346 +43,194 @@ export async function main(argv = process.argv.slice(2)) {
41
43
  return 1;
42
44
  }
43
45
  }
44
- async function runRawDiffEngine(command, checks, startedAt) {
45
- const { value: diff, ms: diffMs } = await trace.trace("net.diff", () => netDiffForCommand(command));
46
- printRunPlan(command, diff, checks.map((check) => check.id));
47
- const { value: models, ms: modelMs } = await trace.trace("model.load", () => loadLocalModels(command.model));
48
- const { scoutModel, auditModel } = models;
49
- const batches = batchDiff(diff.text);
50
- const { value: candidatePointers, ms: searchMs } = await trace.trace("search.total", async () => {
51
- const pointers = [];
52
- for (const batch of batches) {
53
- const { value: candidates } = await trace.trace("search.batch", () => scoutBatch(scoutModel, batch, checks, diff.label), { fields: { batch: batch.id } });
54
- pointers.push(...candidates);
55
- }
56
- return pointers;
57
- });
58
- const contexts = candidateContexts(batches, candidatePointers);
59
- const auditedContexts = contexts;
60
- const { value: result, ms: auditMs } = await trace.trace("audit.candidates", () => auditCandidates(auditModel, diff, auditedContexts, checks), { fields: { candidates: auditedContexts.length } });
61
- return {
62
- run: {
63
- mode: command.kind,
64
- engine: command.engine,
65
- auditContext: command.auditContext,
66
- auditPrompt: command.auditPrompt,
67
- modelId: command.model,
68
- checkIds: checks.map((check) => check.id),
69
- sourceId: diff.id,
70
- label: diff.label,
71
- stats: diff.stats,
72
- batchesScanned: batches.length,
73
- candidateCount: new Set(candidatePointers).size,
74
- entitiesScanned: 0,
75
- auditedCandidateCount: auditedContexts.length,
76
- scoutModelCalls: batches.length,
77
- auditModelCalls: auditedContexts.length > 0 ? 1 : 0,
78
- warnings: [],
79
- timingsMs: {
80
- diff: diffMs,
81
- modelLoad: modelMs,
82
- search: searchMs,
83
- audit: auditMs,
84
- total: Date.now() - startedAt,
85
- },
86
- debugTargets: command.debugTargets ? [] : undefined,
87
- },
88
- result,
89
- };
90
- }
91
- async function runSemEngine(command, checks, startedAt) {
92
- const traceEvents = [];
46
+ export async function runSearchCommand(command, startedAt) {
93
47
  const t = createTracer({
48
+ writeLine: () => undefined,
94
49
  onEvent: (event) => {
95
- traceEvents.push(event);
96
- debugSemTrace(command, event);
50
+ const parts = [`trace ${event.name}`, `${event.ms}ms`];
51
+ if (event.count !== undefined)
52
+ parts.push(`count=${event.count}`);
53
+ if (event.detail)
54
+ parts.push(event.detail);
55
+ console.error(parts.join(" "));
97
56
  },
98
57
  });
99
- const { value: changeSet, ms: diffMs } = await t.trace("entity.diff", () => semChangeSetForCommand(command), {
58
+ const profile = await loadSearchProfile(command.searchProfilePath);
59
+ const checks = profile ? effectiveSearchChecks(command.checkIds, profile) : searchChecks(command.checkIds);
60
+ const patternIds = checks.map((check) => check.id);
61
+ const maxCandidates = effectiveMaxCandidates(command.maxCandidates, profile);
62
+ const maxSearchInputTokens = effectiveMaxSearchInputTokens(command.maxSearchInputTokens, profile);
63
+ const { value: changeSet } = await t.trace("entity.diff", () => semChangeSetForCommand(command), {
100
64
  count: (v) => v.summary.total,
101
65
  detail: (v) => `${v.summary.fileCount} files`,
102
66
  });
103
- printSemRunPlan(command, changeSet, checks.map((check) => check.id));
104
- const { value: models, ms: modelMs } = await t.trace("model.load", () => loadLocalModels(command.model), {
105
- count: () => 2,
106
- detail: () => "scout+audit",
107
- });
108
- const { scoutModel, auditModel } = models;
109
67
  try {
110
- const candidateBatches = chunkSemChangeSet(changeSet);
111
- const { value: candidates, ms: searchMs } = await t.trace("scout.total", async () => candidateBatches.length === 0
112
- ? []
113
- : command.scout === "counter"
114
- ? counterScoutTargets(changeSet, checks, command.maxCandidates)
115
- : scoutSemBatches(scoutModel, candidateBatches, checks, command, traceEvents, t), {
116
- count: (v) => v.length,
117
- detail: () => `${command.scout} scout ${candidateBatches.length} batches`,
118
- });
119
- const { value: contexts, ms: contextMs } = await t.trace("context.select", async () => entityContextsFromChanges(candidates, changeSet.changes), {
120
- fields: { candidates: candidates.length },
121
- count: (v) => v.length,
122
- detail: (v) => `${new Set(v.map((context) => context.filePath).filter(Boolean)).size} files`,
123
- });
124
- const auditBatches = chunkSemContexts(contexts, command.auditBatchSize);
125
- const { value: result, ms: auditMs } = await t.trace("audit.total", () => findingsAuditBatches(auditModel, changeSet, auditBatches, checks, traceEvents, t, command), {
126
- count: (v) => v.findings.length,
127
- detail: (v) => `${auditBatches.length} batches targets=${v.stats.totalTargets} clean=${v.stats.clean} uncertain=${v.stats.uncertain} invalid=${v.stats.invalid}`,
128
- });
129
- return {
130
- run: {
131
- mode: command.kind,
132
- engine: command.engine,
133
- auditContext: command.auditContext,
134
- auditPrompt: command.auditPrompt,
135
- modelId: command.model,
136
- checkIds: checks.map((check) => check.id),
137
- sourceId: changeSet.id,
138
- label: changeSet.label,
68
+ printRunPlan(command, changeSet.summary.fileCount, changeSet.summary.total, patternIds);
69
+ const candidates = counterScoutTargets(changeSet, checks, maxCandidates);
70
+ const contexts = entityContextsFromChanges(candidates, changeSet.changes);
71
+ const targetsByPattern = countTargetsByPattern(contexts);
72
+ const targetsPreview = previewTargets(contexts);
73
+ if (contexts.length === 0) {
74
+ return {
75
+ schemaVersion: "search.v1",
76
+ mode: "search",
77
+ source: command.source,
78
+ model: { id: command.model },
79
+ patterns: patternIds,
139
80
  stats: {
81
+ elapsedMs: Date.now() - startedAt,
82
+ modelCalls: 0,
83
+ skipped: true,
84
+ skipReason: "no_candidates",
140
85
  filesChanged: changeSet.summary.fileCount,
141
- additions: changeSet.summary.added,
142
- deletions: changeSet.summary.deleted,
86
+ entitiesScanned: changeSet.summary.total,
87
+ candidates: 0,
88
+ searchTargets: 0,
89
+ repomixFiles: 0,
90
+ repomixTokens: 0,
91
+ profileId: profile?.id,
92
+ targetsByPattern,
93
+ targetsPreview,
143
94
  },
144
- batchesScanned: 0,
145
- entitiesScanned: changeSet.summary.total,
146
- candidateCount: candidates.length,
147
- targetsByCheck: countTargetsByCheck(candidates),
148
- auditedCandidateCount: contexts.length,
149
- scoutModelCalls: traceEvents.filter((event) => event.name === "scout.batch").length,
150
- auditModelCalls: result.auditModelCalls,
151
- timingsMs: {
152
- diff: diffMs,
153
- modelLoad: modelMs,
154
- search: searchMs,
155
- audit: auditMs + contextMs,
156
- total: Date.now() - startedAt,
95
+ matches: [],
96
+ };
97
+ }
98
+ const baseRepomixConfig = effectiveRepomixConfig(repomixSearchConfig(), profile);
99
+ const initialPack = profile?.context === "sem"
100
+ ? emptyContextPack()
101
+ : await t.trace("context.pack", () => repomixContextPack(changeSet.contextCwd, contexts, changeSet.changes, baseRepomixConfig), {
102
+ count: (v) => v.filePaths.length,
103
+ detail: (v) => `${v.totalTokens} tokens`,
104
+ }).then((result) => result.value);
105
+ const packedFiles = new Set(initialPack.filePaths);
106
+ const searchContexts = profile?.context === "sem"
107
+ ? contexts
108
+ : contexts.filter((context) => context.filePath && packedFiles.has(context.filePath));
109
+ if (searchContexts.length === 0) {
110
+ return {
111
+ schemaVersion: "search.v1",
112
+ mode: "search",
113
+ source: command.source,
114
+ model: { id: command.model },
115
+ patterns: patternIds,
116
+ stats: {
117
+ elapsedMs: Date.now() - startedAt,
118
+ modelCalls: 0,
119
+ skipped: true,
120
+ skipReason: "no_candidates",
121
+ filesChanged: changeSet.summary.fileCount,
122
+ entitiesScanned: changeSet.summary.total,
123
+ candidates: contexts.length,
124
+ searchTargets: 0,
125
+ repomixFiles: initialPack.filePaths.length,
126
+ repomixTokens: initialPack.totalTokens,
127
+ repomixConfig: initialPack.config,
128
+ profileId: profile?.id,
129
+ targetsByPattern,
130
+ targetsPreview,
157
131
  },
158
- warnings: [],
159
- auditStats: result.stats,
160
- debugTargets: command.debugTargets ? debugTargetsFromContexts(contexts, changeSet.label) : undefined,
161
- traceEvents,
132
+ matches: [],
133
+ };
134
+ }
135
+ const pack = profile?.context === "sem" || searchContexts.length === contexts.length
136
+ ? initialPack
137
+ : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
138
+ const modelPath = await firstRunModelBootstrap(command.model);
139
+ const model = await loadLocalModel(modelPath, command.model, "scout");
140
+ const request = buildSearchRequest(changeSet, searchContexts, pack, checks, profile, command.includeCounterReasonInPrompt);
141
+ const inputTokens = await countPromptTokens(model, request.prompt);
142
+ if (inputTokens > maxSearchInputTokens) {
143
+ return {
144
+ schemaVersion: "search.v1",
145
+ mode: "search",
146
+ source: command.source,
147
+ model: { id: command.model },
148
+ patterns: patternIds,
149
+ stats: {
150
+ elapsedMs: Date.now() - startedAt,
151
+ modelCalls: 0,
152
+ inputTokens,
153
+ inputTokenCap: maxSearchInputTokens,
154
+ skipped: true,
155
+ skipReason: "input_too_large",
156
+ filesChanged: changeSet.summary.fileCount,
157
+ entitiesScanned: changeSet.summary.total,
158
+ candidates: contexts.length,
159
+ searchTargets: searchContexts.length,
160
+ repomixFiles: pack.filePaths.length,
161
+ repomixTokens: pack.totalTokens,
162
+ repomixConfig: pack.config,
163
+ profileId: profile?.id,
164
+ targetsByPattern: countTargetsByPattern(searchContexts),
165
+ targetsPreview: previewTargets(searchContexts),
166
+ },
167
+ matches: [],
168
+ };
169
+ }
170
+ const { value: matches } = await t.trace("search.model", () => runSearch(model, request), { count: (v) => v.length });
171
+ return {
172
+ schemaVersion: "search.v1",
173
+ mode: "search",
174
+ source: command.source,
175
+ model: { id: command.model },
176
+ patterns: patternIds,
177
+ stats: {
178
+ elapsedMs: Date.now() - startedAt,
179
+ modelCalls: 1,
180
+ inputTokens,
181
+ inputTokenCap: maxSearchInputTokens,
182
+ filesChanged: changeSet.summary.fileCount,
183
+ entitiesScanned: changeSet.summary.total,
184
+ candidates: contexts.length,
185
+ searchTargets: searchContexts.length,
186
+ repomixFiles: pack.filePaths.length,
187
+ repomixTokens: pack.totalTokens,
188
+ repomixConfig: pack.config,
189
+ profileId: profile?.id,
190
+ targetsByPattern: countTargetsByPattern(searchContexts),
191
+ targetsPreview: previewTargets(searchContexts),
162
192
  },
163
- result,
193
+ matches,
164
194
  };
165
195
  }
166
196
  finally {
167
197
  await changeSet.cleanup();
168
198
  }
169
199
  }
170
- async function findingsAuditBatches(model, changeSet, batches, checks, traceEvents, t, command) {
171
- const findings = [];
172
- const stats = { totalTargets: 0, finding: 0, clean: 0, uncertain: 0, invalid: 0 };
173
- const limiter = new ConcurrencyLimiter(command.auditConcurrency);
174
- for (const [index, batch] of batches.entries()) {
175
- const result = await findingsAuditBatch(model, changeSet, batch, checks, traceEvents, command, limiter, `${index + 1}/${batches.length}`);
176
- findings.push(...result.findings);
177
- stats.totalTargets += result.stats.totalTargets;
178
- stats.finding += result.stats.finding;
179
- stats.clean += result.stats.clean;
180
- stats.uncertain += result.stats.uncertain;
181
- stats.invalid += result.stats.invalid;
182
- }
183
- return {
184
- findings,
185
- summary: findings.length === 0
186
- ? "No clear judgment-offload signal found."
187
- : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
188
- stats,
189
- auditModelCalls: traceEvents.filter((event) => event.name === "audit.batch").length,
190
- };
191
- }
192
- async function findingsAuditBatch(model, changeSet, batch, checks, traceEvents, command, limiter, batchLabel) {
193
- const { value: pack, ms: contextMs } = await trace.trace("context.pack", () => command.auditContext === "none"
194
- ? Promise.resolve(emptyContextPack())
195
- : repomixContextPack(changeSet.contextCwd, batch, changeSet.changes), { fields: { candidates: batch.length } });
196
- const request = findingsAuditRequest(changeSet, batch, pack, checks, command.auditPrompt);
197
- const inputTokens = await countPromptTokens(model, request.prompt);
198
- const contextEvent = {
199
- name: "context.pack",
200
- ms: contextMs,
201
- count: pack.filePaths.length,
202
- detail: `batch=${batchLabel} input_tokens=${inputTokens} pack_tokens=${pack.totalTokens} chars=${pack.totalCharacters}`,
203
- };
204
- traceEvents.push(contextEvent);
205
- debugSemTrace(command, contextEvent);
206
- if (inputTokens > command.maxAuditInputTokens) {
207
- if (batch.length <= 1) {
208
- throw new Error(`Findings audit input has ${inputTokens} tokens, above max ${command.maxAuditInputTokens}.`);
209
- }
210
- const splitAt = Math.ceil(batch.length / 2);
211
- const splitEvent = {
212
- name: "audit.split",
213
- ms: 0,
214
- count: batch.length,
215
- detail: `batch=${batchLabel} input_tokens=${inputTokens} max=${command.maxAuditInputTokens}`,
216
- };
217
- traceEvents.push(splitEvent);
218
- debugSemTrace(command, splitEvent);
219
- const [left, right] = await Promise.all([
220
- findingsAuditBatch(model, changeSet, batch.slice(0, splitAt), checks, traceEvents, command, limiter, `${batchLabel}.1`),
221
- findingsAuditBatch(model, changeSet, batch.slice(splitAt), checks, traceEvents, command, limiter, `${batchLabel}.2`),
222
- ]);
223
- return combineAuditResults(left, right);
224
- }
225
- const { value: result, ms: auditMs } = await trace.trace("audit.batch", () => limiter.run(() => runFindingsAudit(model, changeSet, batch, pack, checks, request)), { fields: { candidates: batch.length } });
226
- const event = {
227
- name: "audit.batch",
228
- ms: auditMs,
229
- count: result.findings.length,
230
- 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}`,
231
- };
232
- traceEvents.push(event);
233
- debugSemTrace(command, event);
234
- return result;
200
+ function buildSearchRequest(changeSet, contexts, pack, patterns, profile, includeCounterReasonInPrompt) {
201
+ return searchRequest({
202
+ changeSet,
203
+ contexts,
204
+ pack,
205
+ patterns,
206
+ includeCounterReasonInPrompt: profile?.includeCounterReasonInPrompt ?? includeCounterReasonInPrompt,
207
+ });
235
208
  }
236
- function combineAuditResults(left, right) {
237
- const findings = [...left.findings, ...right.findings];
238
- return {
239
- findings,
240
- summary: findings.length === 0
241
- ? "No clear judgment-offload signal found."
242
- : `${findings.length} finding review${findings.length === 1 ? "" : "s"} accepted.`,
243
- stats: {
244
- totalTargets: left.stats.totalTargets + right.stats.totalTargets,
245
- finding: left.stats.finding + right.stats.finding,
246
- clean: left.stats.clean + right.stats.clean,
247
- uncertain: left.stats.uncertain + right.stats.uncertain,
248
- invalid: left.stats.invalid + right.stats.invalid,
249
- },
250
- };
209
+ function printRunPlan(command, filesChanged, entitiesScanned, patternIds) {
210
+ if (command.json)
211
+ return;
212
+ console.error("🧙 stupify 🪄");
213
+ console.error(`Mode: search (${command.source})`);
214
+ console.error(`Sem: ${filesChanged} files, ${entitiesScanned} changed entities`);
215
+ console.error(`Patterns: ${patternIds.join(", ")}`);
251
216
  }
252
- function countTargetsByCheck(candidates) {
217
+ function countTargetsByPattern(contexts) {
253
218
  const counts = {};
254
- for (const candidate of candidates) {
255
- counts[candidate.checkId] = (counts[candidate.checkId] ?? 0) + 1;
256
- }
219
+ for (const context of contexts)
220
+ counts[context.checkId] = (counts[context.checkId] ?? 0) + 1;
257
221
  return counts;
258
222
  }
259
- function debugTargetsFromContexts(contexts, sourceLabel) {
223
+ function previewTargets(contexts) {
260
224
  return contexts.map((context) => ({
261
225
  targetId: context.targetId,
262
- checkId: context.checkId,
263
- entityId: context.entityId,
264
- entityKind: context.entityKind,
265
- changeKind: context.changeKind,
266
- scoutReason: context.reason,
267
- sourceLabel,
226
+ patternId: context.checkId,
227
+ entityKind: context.entityKind || undefined,
228
+ sourceKind: context.filePath ? pathKind(context.filePath) : undefined,
268
229
  }));
269
230
  }
270
- class ConcurrencyLimiter {
271
- max;
272
- active = 0;
273
- queue = [];
274
- constructor(max) {
275
- this.max = max;
276
- }
277
- async run(task) {
278
- if (this.active >= this.max) {
279
- await new Promise((resolve) => this.queue.push(resolve));
280
- }
281
- this.active += 1;
282
- try {
283
- return await task();
284
- }
285
- finally {
286
- this.active -= 1;
287
- this.queue.shift()?.();
288
- }
289
- }
290
- }
291
- async function scoutSemBatches(model, batches, checks, command, traceEvents, t) {
292
- const candidates = [];
293
- const seen = new Set();
294
- const targetsByCheck = new Map();
295
- const maxTargetsPerCheck = 6;
296
- for (const [index, batch] of batches.entries()) {
297
- if (candidates.length >= command.maxCandidates)
298
- break;
299
- const remaining = command.maxCandidates - candidates.length;
300
- const { value: batchCandidates } = await t.trace("scout.batch", async () => scoutSemChanges(model, batch, checks, remaining), {
301
- fields: { entities: batch.changes.length },
302
- count: (v) => v.length,
303
- detail: (v) => `batch=${index + 1}/${batches.length} entities=${batch.changes.length} remaining=${remaining}`,
304
- });
305
- for (const candidate of batchCandidates) {
306
- const key = `${candidate.entityId}\u0000${candidate.checkId}`;
307
- if (seen.has(key))
308
- continue;
309
- const checkCount = targetsByCheck.get(candidate.checkId) ?? 0;
310
- if (checkCount >= maxTargetsPerCheck)
311
- continue;
312
- seen.add(key);
313
- targetsByCheck.set(candidate.checkId, checkCount + 1);
314
- candidates.push({
315
- ...candidate,
316
- targetId: `t${String(candidates.length + 1).padStart(3, "0")}`,
317
- });
318
- if (candidates.length >= command.maxCandidates)
319
- break;
320
- }
321
- }
322
- return candidates;
323
- }
324
- function debugSemTrace(command, event) {
325
- if (!command.debugSem)
326
- return;
327
- const parts = [`trace ${event.name}`, `${event.ms}ms`];
328
- if (event.count !== undefined)
329
- parts.push(`count=${event.count}`);
330
- if (event.detail)
331
- parts.push(event.detail);
332
- console.error(parts.join(" "));
333
- }
334
- function chunkSemChangeSet(changeSet) {
335
- const chunks = [];
336
- for (let index = 0; index < changeSet.changes.length; index += SEM_SCOUT_CHUNK_SIZE) {
337
- const changes = changeSet.changes.slice(index, index + SEM_SCOUT_CHUNK_SIZE);
338
- chunks.push({
339
- ...changeSet,
340
- label: `${changeSet.label} batch ${chunks.length + 1}`,
341
- changes,
342
- summary: {
343
- ...changeSet.summary,
344
- fileCount: new Set(changes.map((change) => change.filePath)).size,
345
- total: changes.length,
346
- },
347
- });
348
- }
349
- return chunks;
350
- }
351
- function chunkSemContexts(contexts, chunkSize) {
352
- const chunks = [];
353
- for (let index = 0; index < contexts.length; index += chunkSize) {
354
- chunks.push(contexts.slice(index, index + chunkSize));
355
- }
356
- return chunks;
357
- }
358
- function printRunPlan(command, diff, checkIds) {
359
- if (command.json)
360
- return;
361
- console.error("🧙 stupify 🪄");
362
- console.error(`Window: ${diff.label}`);
363
- console.error(`Diff: ${diff.stats.filesChanged} files changed, ${diff.stats.additions} added, ${diff.stats.deletions} deleted`);
364
- console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
365
- console.error(`Checks: ${checkIds.join(", ")}`);
366
- }
367
- function printSemRunPlan(command, changeSet, checkIds) {
368
- if (command.json)
369
- return;
370
- console.error("🧙 stupify 🪄");
371
- console.error(`Window: ${changeSet.label}`);
372
- console.error(`Sem: ${changeSet.summary.fileCount} files, ${changeSet.summary.total} changed entities`);
373
- console.error(`Model: ${MODEL_REGISTRY[command.model].name}`);
374
- console.error(`Checks: ${checkIds.join(", ")}`);
375
- }
376
- async function netDiffForCommand(command) {
377
- if (command.kind === "since")
378
- return netDiffSince(command.since);
379
- if (command.kind === "stdin")
380
- return netDiffFromStdin(await readDiffFromStdin());
381
- if (command.kind === "commit")
382
- return netDiffForCommit(command.commit);
383
- return netDiffForRecentCommits(command.count);
231
+ function pathKind(filePath) {
232
+ const ext = filePath.split(".").pop();
233
+ return ext && ext !== filePath ? ext : "unknown";
384
234
  }
385
235
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
386
236
  process.exitCode = await main();