@stupify/cli 0.0.14 → 0.0.16

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/src/render.ts CHANGED
@@ -1,20 +1,52 @@
1
1
  import { VERSION } from "./constants.ts";
2
2
  import type { SearchCommand, SearchRunJson } from "./types.ts";
3
- import { format } from "./ui.ts";
3
+ import { format, type CliUi } from "./ui.ts";
4
4
 
5
5
  export function renderSearchRun(run: SearchRunJson, command: SearchCommand): string {
6
6
  if (command.json) return JSON.stringify(run, null, 2);
7
+ return renderSearchHumanText(run, command);
8
+ }
9
+
10
+ export function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void {
11
+ if (command.json) {
12
+ ui.writeStdout(renderSearchRun(run, command));
13
+ return;
14
+ }
7
15
 
8
16
  if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
9
- return `${format.heading("Search input is too large for precise local search.")}
10
- ${format.heading("Size:")}
11
- ~${run.stats.inputTokens ?? "unknown"} tokens
12
- ${format.heading("Limit:")}
13
- ${run.stats.inputTokenCap ?? "unknown"} tokens
14
- Stupify skipped the search rather than review truncated context.
15
- Nothing was blocked.
16
- ${format.heading("Try:")}
17
- rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
17
+ ui.warn("Search skipped: input is too large for precise local search.");
18
+ ui.note(oversizedText(run, command), "Skipped");
19
+ ui.outro("Warn-only. Nothing blocked.");
20
+ return;
21
+ }
22
+
23
+ if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
24
+ ui.success("Search complete: no search targets found.");
25
+ ui.note(cleanSummaryText(run), "Summary");
26
+ ui.outro("No judgment-offload signals found.");
27
+ return;
28
+ }
29
+
30
+ if (run.matches.length === 0) {
31
+ ui.success("Search complete: no judgment-offload signals found.");
32
+ ui.note(cleanSummaryText(run), "Summary");
33
+ ui.outro("Warn-only. Nothing blocked.");
34
+ return;
35
+ }
36
+
37
+ ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
38
+ ui.note(matchSummaryText(run, command), "Summary");
39
+ for (const group of groupMatchesByFile(run.matches)) {
40
+ ui.note(renderMatchGroup(group, run), group.filePath);
41
+ }
42
+ ui.outro(summaryLine(run));
43
+ }
44
+
45
+ export function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string {
46
+ if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
47
+ return `${format.heading("Search skipped")}
48
+ ${oversizedText(run, command)}
49
+ Warn-only. Nothing blocked.`;
18
50
  }
19
51
 
20
52
  if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
@@ -29,18 +61,12 @@ ${format.label("Patterns:")} ${run.patterns.join(", ")}
29
61
  ${format.success("No judgment-offload signals found.")}`;
30
62
  }
31
63
 
64
+ const groups = groupMatchesByFile(run.matches);
32
65
  return `${slopHeading()}
33
- ${committerLabel(run)} (${sourceLabel(command)})
34
-
35
- ${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
36
- ${match.reason}
37
-
38
- \`\`\`
39
- ${match.snapshot ?? match.proof}
40
- \`\`\`
41
- ${format.muted(match.proof)}
66
+ ${matchSummaryText(run, command)}
42
67
 
43
- ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n\n")}
68
+ ${groups.map((group) => `${format.heading(group.filePath)}
69
+ ${renderMatchGroup(group, run)}`).join("\n\n")}
44
70
  ${format.muted(summaryLine(run))}`;
45
71
  }
46
72
 
@@ -92,6 +118,151 @@ Not included:
92
118
  `;
93
119
  }
94
120
 
121
+ type MatchGroup = Readonly<{
122
+ filePath: string;
123
+ matches: SearchRunJson["matches"];
124
+ }>;
125
+
126
+ function oversizedText(run: SearchRunJson, command: SearchCommand): string {
127
+ const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
128
+ return [
129
+ `Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
130
+ `Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
131
+ "Stupify skipped the search rather than review truncated context.",
132
+ `Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
133
+ ].join("\n");
134
+ }
135
+
136
+ function cleanSummaryText(run: SearchRunJson): string {
137
+ return [
138
+ `Patterns: ${run.patterns.join(", ")}`,
139
+ run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
140
+ ].filter(Boolean).join("\n");
141
+ }
142
+
143
+ function matchSummaryText(run: SearchRunJson, command: SearchCommand): string {
144
+ const fileCount = groupMatchesByFile(run.matches).length;
145
+ const fileNoun = fileCount === 1 ? "file" : "files";
146
+ return [
147
+ `${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
148
+ `${committerLabel(run)} · ${sourceLabel(command)}`,
149
+ "Warn-only. Nothing blocked.",
150
+ "",
151
+ patternSummaryLine(run),
152
+ ].filter((line) => line !== null).join("\n");
153
+ }
154
+
155
+ function patternSummaryLine(run: SearchRunJson): string {
156
+ const counts = new Map<string, number>();
157
+ for (const match of run.matches) counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
158
+ return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
159
+ }
160
+
161
+ function groupMatchesByFile(matches: SearchRunJson["matches"]): readonly MatchGroup[] {
162
+ const groups = new Map<string, SearchRunJson["matches"][number][]>();
163
+ for (const match of matches) {
164
+ const filePath = proofFilePath(match.proof);
165
+ const group = groups.get(filePath) ?? [];
166
+ group.push(match);
167
+ groups.set(filePath, group);
168
+ }
169
+ return [...groups.entries()].map(([filePath, groupedMatches]) => ({
170
+ filePath,
171
+ matches: groupedMatches,
172
+ }));
173
+ }
174
+
175
+ function renderMatchGroup(group: MatchGroup, run: SearchRunJson): string {
176
+ return group.matches.map((match, index) => {
177
+ const lines = [
178
+ matchHeadline(match, run, index),
179
+ match.reason,
180
+ match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
181
+ format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
182
+ match.checkWhy ?? "This pattern may indicate judgment-offload.",
183
+ ];
184
+ return lines.filter(Boolean).join("\n");
185
+ }).join("\n\n");
186
+ }
187
+
188
+ function matchHeadline(match: SearchRunJson["matches"][number], run: SearchRunJson, index: number): string {
189
+ return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
190
+ }
191
+
192
+ function patternLabel(match: SearchRunJson["matches"][number]): string {
193
+ return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
194
+ }
195
+
196
+ function headlineArgs(match: SearchRunJson["matches"][number]): string {
197
+ const destination = entityNameFromProof(match.proof);
198
+ const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
199
+ if (source && destination && source !== destination) return `${codeLabel(source)} -> ${codeLabel(destination)}`;
200
+ if (destination) return codeLabel(destination);
201
+ return codeLabel(match.targetId);
202
+ }
203
+
204
+ function matchBlameLabel(match: SearchRunJson["matches"][number], run: SearchRunJson): string {
205
+ return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
206
+ }
207
+
208
+ function blameSummaryLabel(blame: NonNullable<SearchRunJson["matches"][number]["blame"]>): string {
209
+ return `${blame.author} (${blame.subject})`;
210
+ }
211
+
212
+ function runLevelBlameLabel(run: SearchRunJson): string {
213
+ const author = committerLabel(run);
214
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
215
+ return subject ? `${author} (${subject})` : author;
216
+ }
217
+
218
+ function commitSubjectSuffix(run: SearchRunJson): string {
219
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
220
+ return subject ? ` · commit: ${subject}` : "";
221
+ }
222
+
223
+ function firstHumanSubject(subjects: readonly string[]): string | undefined {
224
+ return subjects.map((subject) => subject.trim()).find(Boolean);
225
+ }
226
+
227
+ function codeLabel(value: string): string {
228
+ return `\`${value}\``;
229
+ }
230
+
231
+ function titleCase(value: string): string {
232
+ return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
233
+ }
234
+
235
+ function entityNameFromProof(proof: string): string | undefined {
236
+ const parts = proof.split("::");
237
+ return parts[2] || parts[1] || undefined;
238
+ }
239
+
240
+ function firstBacktickedToken(value: string): string | undefined {
241
+ const match = /`([^`]+)`/.exec(value);
242
+ return cleanToken(match?.[1]);
243
+ }
244
+
245
+ function firstLikelySource(value: string, destination?: string): string | undefined {
246
+ const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
247
+ .map((match) => cleanToken(match[0]))
248
+ .filter((token): token is string => Boolean(token));
249
+ return tokens.find((token) => token !== destination && token !== "The");
250
+ }
251
+
252
+ function cleanToken(value: string | undefined): string | undefined {
253
+ const token = value?.trim().replace(/[.,;:]+$/, "");
254
+ return token || undefined;
255
+ }
256
+
257
+ function proofFilePath(proof: string): string {
258
+ return proof.split("::")[0] || proof;
259
+ }
260
+
261
+ function proofDetail(proof: string): string {
262
+ const [, ...rest] = proof.split("::");
263
+ return rest.length > 0 ? `::${rest.join("::")}` : proof;
264
+ }
265
+
95
266
  function sourceHint(command: SearchCommand): string {
96
267
  if (command.kind === "staged") return "--staged";
97
268
  if (command.kind === "since") return `--since "${command.since}"`;
@@ -149,6 +320,9 @@ function sinceLabel(since: string): string {
149
320
  }
150
321
 
151
322
  function summaryLine(run: SearchRunJson): string {
152
- const noun = run.matches.length === 1 ? "signal" : "signals";
153
- return `${run.matches.length} ${noun}. Warn-only. Nothing blocked.`;
323
+ return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
324
+ }
325
+
326
+ function signalNoun(count: number): string {
327
+ return count === 1 ? "signal" : "signals";
154
328
  }
@@ -65,6 +65,7 @@ function emptyChangeSet(
65
65
  base: label,
66
66
  target: label,
67
67
  committers,
68
+ commitSubjects: undefined,
68
69
  contextCwd: process.cwd(),
69
70
  cleanup: async () => undefined,
70
71
  changes: [],
@@ -109,9 +110,10 @@ async function semChangeSetFromPatch(
109
110
  id: sourceId(label),
110
111
  label,
111
112
  base: label,
112
- target: label,
113
- committers,
114
- stats: { filesChanged: 0, additions: 0, deletions: 0 },
113
+ target: label,
114
+ committers,
115
+ commitSubjects: undefined,
116
+ stats: { filesChanged: 0, additions: 0, deletions: 0 },
115
117
  }),
116
118
  contextCwd: process.cwd(),
117
119
  cleanup: async () => undefined,
@@ -240,6 +242,7 @@ function normalizeSemDiff(value: unknown, range: SourceRange): SemChangeSet {
240
242
  base: range.base,
241
243
  target: range.target,
242
244
  committers: range.committers,
245
+ commitSubjects: range.commitSubjects,
243
246
  contextCwd: process.cwd(),
244
247
  cleanup: async () => undefined,
245
248
  changes,
package/src/stupify.ts CHANGED
@@ -6,11 +6,12 @@ import { countPromptTokens, runSearch, searchRequest, type SearchRequest } from
6
6
  import { searchChecks } from "./checks.ts";
7
7
  import { parseCommand } from "./command.ts";
8
8
  import { counterScoutPlan } from "./counter-scout.ts";
9
- import { runDoctor } from "./doctor.ts";
10
- import { runHookCommand } from "./hooks.ts";
9
+ import { renderDoctorToUi, runDoctor } from "./doctor.ts";
10
+ import { blameEntity } from "./git.ts";
11
+ import { renderHookResultToUi, runHookCommand } from "./hooks.ts";
11
12
  import { firstRunModelBootstrap, loadLocalModel } from "./model.ts";
12
13
  import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.ts";
13
- import { helpText, renderSearchRun } from "./render.ts";
14
+ import { helpText, renderSearchRun, renderSearchRunToUi } from "./render.ts";
14
15
  import {
15
16
  effectiveMaxCandidates,
16
17
  effectiveMaxSearchInputTokens,
@@ -30,27 +31,36 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
30
31
  try {
31
32
  const command = parseCommand(argv);
32
33
  if (command.kind === "help") {
33
- ui.writeStdout(helpText());
34
+ ui.intro("stupify");
35
+ ui.note(helpText().trim(), "Help");
36
+ ui.outro("Local-only. Warn-only.");
34
37
  return 0;
35
38
  }
36
39
  if (command.kind === "hook") {
37
- ui.writeStdout(await runHookCommand(command.action));
40
+ ui.intro("stupify");
41
+ renderHookResultToUi(await runHookCommand(command.action), ui);
42
+ ui.outro("Hook mode is warn-only. Commits are not blocked.");
38
43
  return 0;
39
44
  }
40
45
  if (command.kind === "doctor") {
41
46
  const result = await runDoctor();
42
- ui.writeStdout(result.text);
47
+ ui.intro("stupify");
48
+ renderDoctorToUi(result, ui);
49
+ ui.outro(result.exitCode === 0 ? "Ready." : "Fix missing required dependencies, then rerun doctor.");
43
50
  return result.exitCode;
44
51
  }
45
52
  if (command.kind === "bench-search") {
46
53
  const { runSearchBench } = await import("./search-bench.ts");
47
- ui.writeStdout(await runSearchBench(command.configPath));
54
+ ui.intro("stupify");
55
+ ui.note(await runSearchBench(command.configPath), "Search bench");
56
+ ui.outro("Bench complete.");
48
57
  return 0;
49
58
  }
50
59
 
51
60
  ui = createCliUi({ quiet: command.json });
52
61
  const run = await runSearchCommand(command, startedAt, ui);
53
- ui.writeStdout(renderSearchRun(run, command));
62
+ if (command.json) ui.writeStdout(renderSearchRun(run, command));
63
+ else renderSearchRunToUi(run, command, ui);
54
64
  return 0;
55
65
  } catch (error) {
56
66
  ui.error(error instanceof Error ? error.message : String(error), { force: true });
@@ -59,11 +69,28 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
59
69
  }
60
70
 
61
71
  export async function runSearchCommand(command: SearchCommand, startedAt: number, ui = createCliUi({ quiet: command.json })): Promise<SearchRunJson> {
72
+ const activeSpans = new Map<string, ReturnType<CliUi["spinner"]>>();
62
73
  const t = createTracer({
63
74
  writeLine: () => undefined,
64
75
  onEvent: (event) => {
65
76
  if (command.json) return;
66
- ui.step(formatStep(event.name, event.ms, event.count, event.detail));
77
+ if (event.phase === "start") {
78
+ activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
79
+ return;
80
+ }
81
+
82
+ const active = activeSpans.get(event.name);
83
+ activeSpans.delete(event.name);
84
+ const message = event.phase === "error"
85
+ ? formatErrorStep(event.name, event.ms)
86
+ : formatStep(event.name, event.ms, event.count, event.detail);
87
+ if (!active) {
88
+ if (event.phase === "error") ui.error(message);
89
+ else ui.step(message);
90
+ return;
91
+ }
92
+ if (event.phase === "error") active.error(message);
93
+ else active.stop(message);
67
94
  },
68
95
  });
69
96
 
@@ -100,6 +127,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
100
127
  elapsedMs: Date.now() - startedAt,
101
128
  modelCalls: 0,
102
129
  committers: changeSet.committers,
130
+ commitSubjects: changeSet.commitSubjects,
103
131
  skipped: true,
104
132
  skipReason: "no_candidates",
105
133
  filesChanged: changeSet.summary.fileCount,
@@ -143,6 +171,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
143
171
  elapsedMs: Date.now() - startedAt,
144
172
  modelCalls: 0,
145
173
  committers: changeSet.committers,
174
+ commitSubjects: changeSet.commitSubjects,
146
175
  skipped: true,
147
176
  skipReason: "no_candidates",
148
177
  filesChanged: changeSet.summary.fileCount,
@@ -162,17 +191,27 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
162
191
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
163
192
  ? initialPack
164
193
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
165
- const batches = await buildSearchBatches({
166
- command,
167
- changeSet,
168
- contexts: searchContexts,
169
- initialPack: pack,
170
- checks,
171
- profile,
172
- includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
173
- maxSearchInputTokens,
174
- baseRepomixConfig,
175
- });
194
+ const { value: batches } = await t.trace(
195
+ "search.batches",
196
+ () => buildSearchBatches({
197
+ command,
198
+ changeSet,
199
+ contexts: searchContexts,
200
+ initialPack: pack,
201
+ checks,
202
+ profile,
203
+ includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
204
+ maxSearchInputTokens,
205
+ baseRepomixConfig,
206
+ }),
207
+ {
208
+ startDetail: `${searchContexts.length} targets`,
209
+ count: (result) => result.batches.length,
210
+ detail: (result) => result.wasSplit
211
+ ? `${result.skippedTargets} oversized targets skipped`
212
+ : `${result.estimatedInputTokens} estimated tokens`,
213
+ },
214
+ );
176
215
 
177
216
  if (batches.batches.length === 0) {
178
217
  return {
@@ -187,6 +226,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
187
226
  inputTokens: batches.estimatedInputTokens,
188
227
  inputTokenCap: maxSearchInputTokens,
189
228
  committers: changeSet.committers,
229
+ commitSubjects: changeSet.commitSubjects,
190
230
  skipped: true,
191
231
  skipReason: "input_too_large",
192
232
  filesChanged: changeSet.summary.fileCount,
@@ -222,7 +262,14 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
222
262
  let inputTokens = 0;
223
263
  let exactSkippedTargets = batches.skippedTargets;
224
264
  for (const batch of batches.batches) {
225
- const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
265
+ const { value: batchInputTokens } = await t.trace(
266
+ "prompt.tokens",
267
+ () => countPromptTokens(model, batch.request.prompt),
268
+ {
269
+ startDetail: `${batch.contexts.length} targets`,
270
+ count: (tokens) => tokens,
271
+ },
272
+ );
226
273
  inputTokens += batchInputTokens;
227
274
  if (batchInputTokens > maxSearchInputTokens) {
228
275
  exactSkippedTargets += batch.contexts.length;
@@ -234,12 +281,15 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
234
281
  const { value } = await t.trace(
235
282
  "search.model",
236
283
  () => runSearch(model, batch.request),
237
- { count: (v) => v.length },
284
+ {
285
+ startDetail: `${batch.contexts.length} targets`,
286
+ count: (v) => v.length,
287
+ },
238
288
  );
239
289
  modelCalls += 1;
240
290
  matches.push(...withCheckWhy(value, checks));
241
291
  }
242
- const uniqueMatches = dedupeMatches(matches);
292
+ const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
243
293
 
244
294
  return {
245
295
  schemaVersion: "search.v1",
@@ -253,6 +303,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
253
303
  inputTokens,
254
304
  inputTokenCap: maxSearchInputTokens,
255
305
  committers: changeSet.committers,
306
+ commitSubjects: changeSet.commitSubjects,
256
307
  filesChanged: changeSet.summary.fileCount,
257
308
  entitiesScanned: changeSet.summary.total,
258
309
  candidates: contexts.length,
@@ -287,10 +338,29 @@ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyC
287
338
  const checksById = new Map(checks.map((check) => [check.id, check]));
288
339
  return matches.map((match) => ({
289
340
  ...match,
341
+ patternName: checksById.get(match.patternId)?.name,
290
342
  checkWhy: checksById.get(match.patternId)?.why,
291
343
  }));
292
344
  }
293
345
 
346
+ async function withEntityBlame(
347
+ matches: readonly SearchMatch[],
348
+ targetRev: string,
349
+ command: SearchCommand,
350
+ ): Promise<readonly SearchMatch[]> {
351
+ if (command.kind === "staged" || command.kind === "stdin") return matches;
352
+
353
+ return Promise.all(matches.map(async (match) => {
354
+ if (!match.filePath || !match.entityName) return match;
355
+ const blame = await blameEntity({
356
+ filePath: match.filePath,
357
+ entityName: match.entityName,
358
+ rev: targetRev,
359
+ });
360
+ return blame ? { ...match, blame } : match;
361
+ }));
362
+ }
363
+
294
364
  type SearchBatch = Readonly<{
295
365
  contexts: readonly SemContext[];
296
366
  pack: SemContextPack;
@@ -441,13 +511,33 @@ function printRunPlan(
441
511
  );
442
512
  }
443
513
 
514
+ function formatStartStep(name: string, detail?: string): string {
515
+ if (name === "entity.diff") return "Diff: running sem over the selected git range";
516
+ if (name === "context.pack") return "Context: packing selected target files with Repomix";
517
+ if (name === "search.batches") return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
518
+ if (name === "prompt.tokens") return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
519
+ if (name === "search.model") return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
520
+ return `${name}: working`;
521
+ }
522
+
444
523
  function formatStep(name: string, ms: number, count?: number, detail?: string): string {
445
524
  if (name === "entity.diff") return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
446
525
  if (name === "context.pack") return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
526
+ if (name === "search.batches") return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
527
+ if (name === "prompt.tokens") return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
447
528
  if (name === "search.model") return `Model: ${count ?? 0} matches (${ms}ms)`;
448
529
  return `${name}: ${ms}ms`;
449
530
  }
450
531
 
532
+ function formatErrorStep(name: string, ms: number): string {
533
+ if (name === "entity.diff") return `Diff failed after ${ms}ms`;
534
+ if (name === "context.pack") return `Context packing failed after ${ms}ms`;
535
+ if (name === "search.batches") return `Search batch preparation failed after ${ms}ms`;
536
+ if (name === "prompt.tokens") return `Token counting failed after ${ms}ms`;
537
+ if (name === "search.model") return `Model search failed after ${ms}ms`;
538
+ return `${name} failed after ${ms}ms`;
539
+ }
540
+
451
541
  function scoutPlanLine(plan: CounterScoutPlan, entitiesScanned: number): string {
452
542
  if (plan.targets.length === 0) {
453
543
  return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
package/src/trace.ts CHANGED
@@ -9,6 +9,7 @@ export type Tracer = {
9
9
 
10
10
  export type SpanTraceEvent = Readonly<{
11
11
  name: string;
12
+ phase: "start" | "end" | "error";
12
13
  ms: number;
13
14
  count?: number;
14
15
  detail?: string;
@@ -16,6 +17,7 @@ export type SpanTraceEvent = Readonly<{
16
17
 
17
18
  export type SpanTraceOptions<T> = Readonly<{
18
19
  fields?: TraceFields;
20
+ startDetail?: string | (() => string);
19
21
  count?: (value: T) => number;
20
22
  detail?: (value: T) => string;
21
23
  }>;
@@ -53,6 +55,12 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
53
55
  options?: SpanTraceOptions<T>,
54
56
  ): Promise<{ value: T; ms: number }> | { value: T; ms: number } {
55
57
  const startedAtMs = nowMs();
58
+ onEvent?.({
59
+ name: span,
60
+ phase: "start",
61
+ ms: 0,
62
+ detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
63
+ });
56
64
  try {
57
65
  const out = fn();
58
66
  if (isPromiseLike(out)) {
@@ -63,12 +71,21 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
63
71
  durationMs = nowMs() - startedAtMs;
64
72
  const event: SpanTraceEvent = {
65
73
  name: span,
74
+ phase: "end",
66
75
  ms: Math.round(durationMs),
67
76
  count: options?.count?.(value),
68
77
  detail: options?.detail?.(value),
69
78
  };
70
79
  onEvent?.(event);
71
80
  return { value, ms: event.ms };
81
+ } catch (error) {
82
+ durationMs = nowMs() - startedAtMs;
83
+ onEvent?.({
84
+ name: span,
85
+ phase: "error",
86
+ ms: Math.round(durationMs),
87
+ });
88
+ throw error;
72
89
  } finally {
73
90
  durationMs ??= nowMs() - startedAtMs;
74
91
  emit(span, durationMs, options?.fields);
@@ -80,6 +97,7 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
80
97
  emit(span, durationMs, options?.fields);
81
98
  const event: SpanTraceEvent = {
82
99
  name: span,
100
+ phase: "end",
83
101
  ms: Math.round(durationMs),
84
102
  count: options?.count?.(out),
85
103
  detail: options?.detail?.(out),
@@ -88,6 +106,11 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
88
106
  return { value: out, ms: event.ms };
89
107
  } catch (error) {
90
108
  const durationMs = nowMs() - startedAtMs;
109
+ onEvent?.({
110
+ name: span,
111
+ phase: "error",
112
+ ms: Math.round(durationMs),
113
+ });
91
114
  emit(span, durationMs, options?.fields);
92
115
  throw error;
93
116
  }
package/src/types.ts CHANGED
@@ -78,6 +78,12 @@ export type StagedDiff = Readonly<{
78
78
  stats: NetDiffStats;
79
79
  }>;
80
80
 
81
+ export type BlameSummary = Readonly<{
82
+ commit: string;
83
+ author: string;
84
+ subject: string;
85
+ }>;
86
+
81
87
  export type NetDiff = Readonly<{
82
88
  id: SourceId;
83
89
  label: string;
@@ -93,6 +99,7 @@ export type SourceRange = Readonly<{
93
99
  base: string;
94
100
  target: string;
95
101
  committers?: readonly string[];
102
+ commitSubjects?: readonly string[];
96
103
  stats: NetDiffStats;
97
104
  }>;
98
105
 
@@ -122,6 +129,7 @@ export type SemChangeSet = Readonly<{
122
129
  base: string;
123
130
  target: string;
124
131
  committers?: readonly string[];
132
+ commitSubjects?: readonly string[];
125
133
  contextCwd: string;
126
134
  cleanup: () => Promise<void>;
127
135
  changes: readonly SemChange[];
@@ -195,10 +203,15 @@ export type SearchProfile = Readonly<{
195
203
  export type SearchMatch = Readonly<{
196
204
  targetId: string;
197
205
  patternId: CheckId;
206
+ patternName?: string;
198
207
  checkWhy?: string;
199
208
  reason: string;
200
209
  proof: string;
201
210
  snapshot?: string;
211
+ filePath?: string;
212
+ entityName?: string;
213
+ entityKind?: string;
214
+ blame?: BlameSummary;
202
215
  }>;
203
216
 
204
217
  export type SearchRunJson = Readonly<{
@@ -215,6 +228,7 @@ export type SearchRunJson = Readonly<{
215
228
  skipped?: boolean;
216
229
  skipReason?: "input_too_large" | "no_candidates";
217
230
  committers?: readonly string[];
231
+ commitSubjects?: readonly string[];
218
232
  filesChanged?: number;
219
233
  entitiesScanned?: number;
220
234
  candidates?: number;