@stupify/cli 0.0.15 → 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/doctor.ts CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
7
7
  import { runHookCommand } from "./hooks.ts";
8
+ import type { CliUi } from "./ui.ts";
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
 
@@ -17,7 +18,13 @@ type DoctorCheck = Readonly<{
17
18
  required?: boolean;
18
19
  }>;
19
20
 
20
- export async function runDoctor(): Promise<Readonly<{ exitCode: number; text: string }>> {
21
+ export type DoctorResult = Readonly<{
22
+ exitCode: number;
23
+ text: string;
24
+ checks: readonly DoctorCheck[];
25
+ }>;
26
+
27
+ export async function runDoctor(): Promise<DoctorResult> {
21
28
  const checks = await Promise.all([
22
29
  gitCheck(),
23
30
  hookCheck(),
@@ -30,9 +37,28 @@ export async function runDoctor(): Promise<Readonly<{ exitCode: number; text: st
30
37
  return {
31
38
  exitCode: requiredMissing ? 1 : 0,
32
39
  text: renderDoctor(checks),
40
+ checks,
33
41
  };
34
42
  }
35
43
 
44
+ export function renderDoctorToUi(result: DoctorResult, ui: CliUi): void {
45
+ const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
46
+ if (missingRequired.length > 0) {
47
+ ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
48
+ } else {
49
+ ui.success("Doctor checks complete.");
50
+ }
51
+
52
+ ui.note(
53
+ result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"),
54
+ "Doctor",
55
+ );
56
+ ui.note(
57
+ "Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.",
58
+ "Privacy",
59
+ );
60
+ }
61
+
36
62
  async function gitCheck(): Promise<DoctorCheck> {
37
63
  try {
38
64
  const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
package/src/git.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { sourceId, type NetDiff, type NetDiffStats, type SourceRange, type StagedDiff } from "./types.ts";
3
+ import { sourceId, type BlameSummary, type NetDiff, type NetDiffStats, type SourceRange, type StagedDiff } from "./types.ts";
4
4
 
5
5
  const execFileAsync = promisify(execFile);
6
6
 
@@ -106,6 +106,27 @@ export async function gitUserLabel(): Promise<string> {
106
106
  return name || email || "working tree";
107
107
  }
108
108
 
109
+ export async function blameEntity(input: Readonly<{
110
+ filePath: string;
111
+ entityName: string;
112
+ rev: string;
113
+ }>): Promise<BlameSummary | null> {
114
+ try {
115
+ const { stdout } = await execFileAsync("git", [
116
+ "blame",
117
+ "--line-porcelain",
118
+ "-L",
119
+ `:${input.entityName}`,
120
+ input.rev,
121
+ "--",
122
+ input.filePath,
123
+ ], { maxBuffer: 16 * 1024 * 1024 });
124
+ return summarizeBlame(stdout);
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
109
130
  async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
110
131
  const [text, stats, shortBase, shortTarget] = await Promise.all([
111
132
  diff(base, target),
@@ -124,11 +145,12 @@ async function netDiff(base: string, target: string, label: string, id?: NetDiff
124
145
  }
125
146
 
126
147
  async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
127
- const [stats, shortBase, shortTarget, committers] = await Promise.all([
148
+ const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
128
149
  diffStats(base, target),
129
150
  shortCommit(base),
130
151
  shortCommit(target),
131
152
  committersForRange(base, target),
153
+ commitSubjectsForRange(base, target),
132
154
  ]);
133
155
  return {
134
156
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
@@ -136,6 +158,7 @@ async function sourceRange(base: string, target: string, label: string, id?: Sou
136
158
  base,
137
159
  target,
138
160
  committers,
161
+ commitSubjects,
139
162
  stats,
140
163
  };
141
164
  }
@@ -160,6 +183,17 @@ async function committersForRange(base: string, target: string): Promise<readonl
160
183
  }
161
184
  }
162
185
 
186
+ async function commitSubjectsForRange(base: string, target: string): Promise<readonly string[]> {
187
+ try {
188
+ const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
189
+ maxBuffer: 4 * 1024 * 1024,
190
+ });
191
+ return uniqueLines(stdout);
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
196
+
163
197
  function uniqueLines(value: string): readonly string[] {
164
198
  const seen = new Set<string>();
165
199
  const lines: string[] = [];
@@ -304,3 +338,43 @@ async function commitMessage(commit: string): Promise<string> {
304
338
  function firstLine(value: string): string {
305
339
  return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
306
340
  }
341
+
342
+ function summarizeBlame(output: string): BlameSummary | null {
343
+ const entries = new Map<string, { commit: string; author: string; subject: string; count: number }>();
344
+ let currentCommit = "";
345
+ let currentAuthor = "";
346
+ let currentSubject = "";
347
+ for (const line of output.split(/\r?\n/)) {
348
+ const header = /^([0-9a-f]{40})\s+/.exec(line);
349
+ if (header?.[1]) {
350
+ currentCommit = header[1];
351
+ currentAuthor = "";
352
+ currentSubject = "";
353
+ continue;
354
+ }
355
+ if (line.startsWith("author ")) {
356
+ currentAuthor = line.slice("author ".length).trim();
357
+ continue;
358
+ }
359
+ if (line.startsWith("summary ")) {
360
+ currentSubject = line.slice("summary ".length).trim();
361
+ continue;
362
+ }
363
+ if (!line.startsWith("\t") || !currentCommit) continue;
364
+ const previous = entries.get(currentCommit);
365
+ entries.set(currentCommit, {
366
+ commit: currentCommit,
367
+ author: currentAuthor || previous?.author || "unknown author",
368
+ subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
369
+ count: (previous?.count ?? 0) + 1,
370
+ });
371
+ }
372
+
373
+ const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
374
+ if (!best) return null;
375
+ return {
376
+ commit: best.commit.slice(0, 7),
377
+ author: best.author,
378
+ subject: best.subject,
379
+ };
380
+ }
package/src/hooks.ts CHANGED
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { gitPath, gitRoot } from "./git.ts";
7
7
  import type { HookAction } from "./types.ts";
8
+ import type { CliUi } from "./ui.ts";
8
9
 
9
10
  const execFileAsync = promisify(execFile);
10
11
  const START = "# stupify hook start";
@@ -16,6 +17,22 @@ export async function runHookCommand(action: HookAction): Promise<string> {
16
17
  return uninstallHook();
17
18
  }
18
19
 
20
+ export function renderHookResultToUi(result: string, ui: CliUi): void {
21
+ const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
22
+ if (firstLine.includes("not installed")) {
23
+ ui.info(firstLine);
24
+ } else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
25
+ ui.success(firstLine);
26
+ } else if (firstLine.includes("existing non-Stupify")) {
27
+ ui.warn(firstLine);
28
+ } else {
29
+ ui.info(firstLine);
30
+ }
31
+
32
+ const detail = rest.join("\n").trim();
33
+ if (detail) ui.note(detail, "Hook");
34
+ }
35
+
19
36
  export function hookSnippet(): string {
20
37
  return managedBlock("stupify --staged");
21
38
  }
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 });
@@ -117,6 +127,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
117
127
  elapsedMs: Date.now() - startedAt,
118
128
  modelCalls: 0,
119
129
  committers: changeSet.committers,
130
+ commitSubjects: changeSet.commitSubjects,
120
131
  skipped: true,
121
132
  skipReason: "no_candidates",
122
133
  filesChanged: changeSet.summary.fileCount,
@@ -160,6 +171,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
160
171
  elapsedMs: Date.now() - startedAt,
161
172
  modelCalls: 0,
162
173
  committers: changeSet.committers,
174
+ commitSubjects: changeSet.commitSubjects,
163
175
  skipped: true,
164
176
  skipReason: "no_candidates",
165
177
  filesChanged: changeSet.summary.fileCount,
@@ -214,6 +226,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
214
226
  inputTokens: batches.estimatedInputTokens,
215
227
  inputTokenCap: maxSearchInputTokens,
216
228
  committers: changeSet.committers,
229
+ commitSubjects: changeSet.commitSubjects,
217
230
  skipped: true,
218
231
  skipReason: "input_too_large",
219
232
  filesChanged: changeSet.summary.fileCount,
@@ -276,7 +289,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
276
289
  modelCalls += 1;
277
290
  matches.push(...withCheckWhy(value, checks));
278
291
  }
279
- const uniqueMatches = dedupeMatches(matches);
292
+ const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
280
293
 
281
294
  return {
282
295
  schemaVersion: "search.v1",
@@ -290,6 +303,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
290
303
  inputTokens,
291
304
  inputTokenCap: maxSearchInputTokens,
292
305
  committers: changeSet.committers,
306
+ commitSubjects: changeSet.commitSubjects,
293
307
  filesChanged: changeSet.summary.fileCount,
294
308
  entitiesScanned: changeSet.summary.total,
295
309
  candidates: contexts.length,
@@ -324,10 +338,29 @@ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyC
324
338
  const checksById = new Map(checks.map((check) => [check.id, check]));
325
339
  return matches.map((match) => ({
326
340
  ...match,
341
+ patternName: checksById.get(match.patternId)?.name,
327
342
  checkWhy: checksById.get(match.patternId)?.why,
328
343
  }));
329
344
  }
330
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
+
331
364
  type SearchBatch = Readonly<{
332
365
  contexts: readonly SemContext[];
333
366
  pack: SemContextPack;
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;