@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/dist/analysis.js CHANGED
@@ -74,6 +74,9 @@ function uncheckedSearchMatches(value, contexts) {
74
74
  reason: match.reason ?? "",
75
75
  proof: sourcePointer(context),
76
76
  snapshot: sourceSnapshot(context),
77
+ filePath: context.filePath,
78
+ entityName: context.entityName,
79
+ entityKind: context.entityKind,
77
80
  }];
78
81
  });
79
82
  }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.15";
1
+ export declare const VERSION = "0.0.16";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
  export declare const DEFAULT_MODEL_ID: ModelId;
4
4
  export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.15";
1
+ export const VERSION = "0.0.16";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
package/dist/doctor.d.ts CHANGED
@@ -1,4 +1,16 @@
1
- export declare function runDoctor(): Promise<Readonly<{
1
+ import type { CliUi } from "./ui.ts";
2
+ type CheckStatus = "ok" | "missing" | "info";
3
+ type DoctorCheck = Readonly<{
4
+ label: string;
5
+ status: CheckStatus;
6
+ detail: string;
7
+ required?: boolean;
8
+ }>;
9
+ export type DoctorResult = Readonly<{
2
10
  exitCode: number;
3
11
  text: string;
4
- }>>;
12
+ checks: readonly DoctorCheck[];
13
+ }>;
14
+ export declare function runDoctor(): Promise<DoctorResult>;
15
+ export declare function renderDoctorToUi(result: DoctorResult, ui: CliUi): void;
16
+ export {};
package/dist/doctor.js CHANGED
@@ -19,8 +19,20 @@ export async function runDoctor() {
19
19
  return {
20
20
  exitCode: requiredMissing ? 1 : 0,
21
21
  text: renderDoctor(checks),
22
+ checks,
22
23
  };
23
24
  }
25
+ export function renderDoctorToUi(result, ui) {
26
+ const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
27
+ if (missingRequired.length > 0) {
28
+ ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
29
+ }
30
+ else {
31
+ ui.success("Doctor checks complete.");
32
+ }
33
+ ui.note(result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"), "Doctor");
34
+ ui.note("Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.", "Privacy");
35
+ }
24
36
  async function gitCheck() {
25
37
  try {
26
38
  const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
package/dist/git.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
1
+ import { type BlameSummary, type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
2
2
  export declare function netDiffSince(since: string): Promise<NetDiff>;
3
3
  export declare function netDiffForCommit(commit: string): Promise<NetDiff>;
4
4
  export declare function netDiffForRecentCommits(count: number): Promise<NetDiff>;
@@ -10,3 +10,8 @@ export declare function stagedDiff(): Promise<StagedDiff>;
10
10
  export declare function gitRoot(): Promise<string>;
11
11
  export declare function gitPath(pathspec: string): Promise<string>;
12
12
  export declare function gitUserLabel(): Promise<string>;
13
+ export declare function blameEntity(input: Readonly<{
14
+ filePath: string;
15
+ entityName: string;
16
+ rev: string;
17
+ }>): Promise<BlameSummary | null>;
package/dist/git.js CHANGED
@@ -98,6 +98,23 @@ export async function gitUserLabel() {
98
98
  return `${name} <${email}>`;
99
99
  return name || email || "working tree";
100
100
  }
101
+ export async function blameEntity(input) {
102
+ try {
103
+ const { stdout } = await execFileAsync("git", [
104
+ "blame",
105
+ "--line-porcelain",
106
+ "-L",
107
+ `:${input.entityName}`,
108
+ input.rev,
109
+ "--",
110
+ input.filePath,
111
+ ], { maxBuffer: 16 * 1024 * 1024 });
112
+ return summarizeBlame(stdout);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
101
118
  async function netDiff(base, target, label, id) {
102
119
  const [text, stats, shortBase, shortTarget] = await Promise.all([
103
120
  diff(base, target),
@@ -115,11 +132,12 @@ async function netDiff(base, target, label, id) {
115
132
  };
116
133
  }
117
134
  async function sourceRange(base, target, label, id) {
118
- const [stats, shortBase, shortTarget, committers] = await Promise.all([
135
+ const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
119
136
  diffStats(base, target),
120
137
  shortCommit(base),
121
138
  shortCommit(target),
122
139
  committersForRange(base, target),
140
+ commitSubjectsForRange(base, target),
123
141
  ]);
124
142
  return {
125
143
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
@@ -127,6 +145,7 @@ async function sourceRange(base, target, label, id) {
127
145
  base,
128
146
  target,
129
147
  committers,
148
+ commitSubjects,
130
149
  stats,
131
150
  };
132
151
  }
@@ -150,6 +169,17 @@ async function committersForRange(base, target) {
150
169
  return [];
151
170
  }
152
171
  }
172
+ async function commitSubjectsForRange(base, target) {
173
+ try {
174
+ const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
175
+ maxBuffer: 4 * 1024 * 1024,
176
+ });
177
+ return uniqueLines(stdout);
178
+ }
179
+ catch {
180
+ return [];
181
+ }
182
+ }
153
183
  function uniqueLines(value) {
154
184
  const seen = new Set();
155
185
  const lines = [];
@@ -296,3 +326,43 @@ async function commitMessage(commit) {
296
326
  function firstLine(value) {
297
327
  return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
298
328
  }
329
+ function summarizeBlame(output) {
330
+ const entries = new Map();
331
+ let currentCommit = "";
332
+ let currentAuthor = "";
333
+ let currentSubject = "";
334
+ for (const line of output.split(/\r?\n/)) {
335
+ const header = /^([0-9a-f]{40})\s+/.exec(line);
336
+ if (header?.[1]) {
337
+ currentCommit = header[1];
338
+ currentAuthor = "";
339
+ currentSubject = "";
340
+ continue;
341
+ }
342
+ if (line.startsWith("author ")) {
343
+ currentAuthor = line.slice("author ".length).trim();
344
+ continue;
345
+ }
346
+ if (line.startsWith("summary ")) {
347
+ currentSubject = line.slice("summary ".length).trim();
348
+ continue;
349
+ }
350
+ if (!line.startsWith("\t") || !currentCommit)
351
+ continue;
352
+ const previous = entries.get(currentCommit);
353
+ entries.set(currentCommit, {
354
+ commit: currentCommit,
355
+ author: currentAuthor || previous?.author || "unknown author",
356
+ subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
357
+ count: (previous?.count ?? 0) + 1,
358
+ });
359
+ }
360
+ const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
361
+ if (!best)
362
+ return null;
363
+ return {
364
+ commit: best.commit.slice(0, 7),
365
+ author: best.author,
366
+ subject: best.subject,
367
+ };
368
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  import type { HookAction } from "./types.ts";
2
+ import type { CliUi } from "./ui.ts";
2
3
  export declare function runHookCommand(action: HookAction): Promise<string>;
4
+ export declare function renderHookResultToUi(result: string, ui: CliUi): void;
3
5
  export declare function hookSnippet(): string;
package/dist/hooks.js CHANGED
@@ -14,6 +14,24 @@ export async function runHookCommand(action) {
14
14
  return installHook();
15
15
  return uninstallHook();
16
16
  }
17
+ export function renderHookResultToUi(result, ui) {
18
+ const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
19
+ if (firstLine.includes("not installed")) {
20
+ ui.info(firstLine);
21
+ }
22
+ else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
23
+ ui.success(firstLine);
24
+ }
25
+ else if (firstLine.includes("existing non-Stupify")) {
26
+ ui.warn(firstLine);
27
+ }
28
+ else {
29
+ ui.info(firstLine);
30
+ }
31
+ const detail = rest.join("\n").trim();
32
+ if (detail)
33
+ ui.note(detail, "Hook");
34
+ }
17
35
  export function hookSnippet() {
18
36
  return managedBlock("stupify --staged");
19
37
  }
package/dist/render.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  import type { SearchCommand, SearchRunJson } from "./types.ts";
2
+ import { type CliUi } from "./ui.ts";
2
3
  export declare function renderSearchRun(run: SearchRunJson, command: SearchCommand): string;
4
+ export declare function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void;
5
+ export declare function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string;
3
6
  export declare function helpText(): string;
package/dist/render.js CHANGED
@@ -3,16 +3,43 @@ import { format } from "./ui.js";
3
3
  export function renderSearchRun(run, command) {
4
4
  if (command.json)
5
5
  return JSON.stringify(run, null, 2);
6
+ return renderSearchHumanText(run, command);
7
+ }
8
+ export function renderSearchRunToUi(run, command, ui) {
9
+ if (command.json) {
10
+ ui.writeStdout(renderSearchRun(run, command));
11
+ return;
12
+ }
13
+ if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
14
+ ui.warn("Search skipped: input is too large for precise local search.");
15
+ ui.note(oversizedText(run, command), "Skipped");
16
+ ui.outro("Warn-only. Nothing blocked.");
17
+ return;
18
+ }
19
+ if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
20
+ ui.success("Search complete: no search targets found.");
21
+ ui.note(cleanSummaryText(run), "Summary");
22
+ ui.outro("No judgment-offload signals found.");
23
+ return;
24
+ }
25
+ if (run.matches.length === 0) {
26
+ ui.success("Search complete: no judgment-offload signals found.");
27
+ ui.note(cleanSummaryText(run), "Summary");
28
+ ui.outro("Warn-only. Nothing blocked.");
29
+ return;
30
+ }
31
+ ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
32
+ ui.note(matchSummaryText(run, command), "Summary");
33
+ for (const group of groupMatchesByFile(run.matches)) {
34
+ ui.note(renderMatchGroup(group, run), group.filePath);
35
+ }
36
+ ui.outro(summaryLine(run));
37
+ }
38
+ export function renderSearchHumanText(run, command) {
6
39
  if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
7
- return `${format.heading("Search input is too large for precise local search.")}
8
- ${format.heading("Size:")}
9
- ~${run.stats.inputTokens ?? "unknown"} tokens
10
- ${format.heading("Limit:")}
11
- ${run.stats.inputTokenCap ?? "unknown"} tokens
12
- Stupify skipped the search rather than review truncated context.
13
- Nothing was blocked.
14
- ${format.heading("Try:")}
15
- rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
40
+ return `${format.heading("Search skipped")}
41
+ ${oversizedText(run, command)}
42
+ Warn-only. Nothing blocked.`;
16
43
  }
17
44
  if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
18
45
  return `${format.heading("Search complete.")}
@@ -24,18 +51,12 @@ ${format.success("No search targets found.")}`;
24
51
  ${format.label("Patterns:")} ${run.patterns.join(", ")}
25
52
  ${format.success("No judgment-offload signals found.")}`;
26
53
  }
54
+ const groups = groupMatchesByFile(run.matches);
27
55
  return `${slopHeading()}
28
- ${committerLabel(run)} (${sourceLabel(command)})
29
-
30
- ${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
31
- ${match.reason}
32
-
33
- \`\`\`
34
- ${match.snapshot ?? match.proof}
35
- \`\`\`
36
- ${format.muted(match.proof)}
56
+ ${matchSummaryText(run, command)}
37
57
 
38
- ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n\n")}
58
+ ${groups.map((group) => `${format.heading(group.filePath)}
59
+ ${renderMatchGroup(group, run)}`).join("\n\n")}
39
60
  ${format.muted(summaryLine(run))}`;
40
61
  }
41
62
  export function helpText() {
@@ -85,6 +106,127 @@ Not included:
85
106
  Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
86
107
  `;
87
108
  }
109
+ function oversizedText(run, command) {
110
+ const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
111
+ return [
112
+ `Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
113
+ `Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
114
+ "Stupify skipped the search rather than review truncated context.",
115
+ `Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
116
+ ].join("\n");
117
+ }
118
+ function cleanSummaryText(run) {
119
+ return [
120
+ `Patterns: ${run.patterns.join(", ")}`,
121
+ run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
122
+ ].filter(Boolean).join("\n");
123
+ }
124
+ function matchSummaryText(run, command) {
125
+ const fileCount = groupMatchesByFile(run.matches).length;
126
+ const fileNoun = fileCount === 1 ? "file" : "files";
127
+ return [
128
+ `${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
129
+ `${committerLabel(run)} · ${sourceLabel(command)}`,
130
+ "Warn-only. Nothing blocked.",
131
+ "",
132
+ patternSummaryLine(run),
133
+ ].filter((line) => line !== null).join("\n");
134
+ }
135
+ function patternSummaryLine(run) {
136
+ const counts = new Map();
137
+ for (const match of run.matches)
138
+ counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
139
+ return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
140
+ }
141
+ function groupMatchesByFile(matches) {
142
+ const groups = new Map();
143
+ for (const match of matches) {
144
+ const filePath = proofFilePath(match.proof);
145
+ const group = groups.get(filePath) ?? [];
146
+ group.push(match);
147
+ groups.set(filePath, group);
148
+ }
149
+ return [...groups.entries()].map(([filePath, groupedMatches]) => ({
150
+ filePath,
151
+ matches: groupedMatches,
152
+ }));
153
+ }
154
+ function renderMatchGroup(group, run) {
155
+ return group.matches.map((match, index) => {
156
+ const lines = [
157
+ matchHeadline(match, run, index),
158
+ match.reason,
159
+ match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
160
+ format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
161
+ match.checkWhy ?? "This pattern may indicate judgment-offload.",
162
+ ];
163
+ return lines.filter(Boolean).join("\n");
164
+ }).join("\n\n");
165
+ }
166
+ function matchHeadline(match, run, index) {
167
+ return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
168
+ }
169
+ function patternLabel(match) {
170
+ return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
171
+ }
172
+ function headlineArgs(match) {
173
+ const destination = entityNameFromProof(match.proof);
174
+ const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
175
+ if (source && destination && source !== destination)
176
+ return `${codeLabel(source)} -> ${codeLabel(destination)}`;
177
+ if (destination)
178
+ return codeLabel(destination);
179
+ return codeLabel(match.targetId);
180
+ }
181
+ function matchBlameLabel(match, run) {
182
+ return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
183
+ }
184
+ function blameSummaryLabel(blame) {
185
+ return `${blame.author} (${blame.subject})`;
186
+ }
187
+ function runLevelBlameLabel(run) {
188
+ const author = committerLabel(run);
189
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
190
+ return subject ? `${author} (${subject})` : author;
191
+ }
192
+ function commitSubjectSuffix(run) {
193
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
194
+ return subject ? ` · commit: ${subject}` : "";
195
+ }
196
+ function firstHumanSubject(subjects) {
197
+ return subjects.map((subject) => subject.trim()).find(Boolean);
198
+ }
199
+ function codeLabel(value) {
200
+ return `\`${value}\``;
201
+ }
202
+ function titleCase(value) {
203
+ return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
204
+ }
205
+ function entityNameFromProof(proof) {
206
+ const parts = proof.split("::");
207
+ return parts[2] || parts[1] || undefined;
208
+ }
209
+ function firstBacktickedToken(value) {
210
+ const match = /`([^`]+)`/.exec(value);
211
+ return cleanToken(match?.[1]);
212
+ }
213
+ function firstLikelySource(value, destination) {
214
+ const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
215
+ .map((match) => cleanToken(match[0]))
216
+ .filter((token) => Boolean(token));
217
+ return tokens.find((token) => token !== destination && token !== "The");
218
+ }
219
+ function cleanToken(value) {
220
+ const token = value?.trim().replace(/[.,;:]+$/, "");
221
+ return token || undefined;
222
+ }
223
+ function proofFilePath(proof) {
224
+ return proof.split("::")[0] || proof;
225
+ }
226
+ function proofDetail(proof) {
227
+ const [, ...rest] = proof.split("::");
228
+ return rest.length > 0 ? `::${rest.join("::")}` : proof;
229
+ }
88
230
  function sourceHint(command) {
89
231
  if (command.kind === "staged")
90
232
  return "--staged";
@@ -146,6 +288,8 @@ function sinceLabel(since) {
146
288
  return `last ${count} ${unit}s`;
147
289
  }
148
290
  function summaryLine(run) {
149
- const noun = run.matches.length === 1 ? "signal" : "signals";
150
- return `${run.matches.length} ${noun}. Warn-only. Nothing blocked.`;
291
+ return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
292
+ }
293
+ function signalNoun(count) {
294
+ return count === 1 ? "signal" : "signals";
151
295
  }
@@ -36,6 +36,7 @@ function emptyChangeSet(label, stats, committers) {
36
36
  base: label,
37
37
  target: label,
38
38
  committers,
39
+ commitSubjects: undefined,
39
40
  contextCwd: process.cwd(),
40
41
  cleanup: async () => undefined,
41
42
  changes: [],
@@ -75,6 +76,7 @@ async function semChangeSetFromPatch(patch, debugSem, label = "stdin", committer
75
76
  base: label,
76
77
  target: label,
77
78
  committers,
79
+ commitSubjects: undefined,
78
80
  stats: { filesChanged: 0, additions: 0, deletions: 0 },
79
81
  }),
80
82
  contextCwd: process.cwd(),
@@ -199,6 +201,7 @@ function normalizeSemDiff(value, range) {
199
201
  base: range.base,
200
202
  target: range.target,
201
203
  committers: range.committers,
204
+ commitSubjects: range.commitSubjects,
202
205
  contextCwd: process.cwd(),
203
206
  cleanup: async () => undefined,
204
207
  changes,
package/dist/stupify.js CHANGED
@@ -5,11 +5,12 @@ import { countPromptTokens, runSearch, searchRequest } from "./analysis.js";
5
5
  import { searchChecks } from "./checks.js";
6
6
  import { parseCommand } from "./command.js";
7
7
  import { counterScoutPlan } from "./counter-scout.js";
8
- import { runDoctor } from "./doctor.js";
9
- import { runHookCommand } from "./hooks.js";
8
+ import { renderDoctorToUi, runDoctor } from "./doctor.js";
9
+ import { blameEntity } from "./git.js";
10
+ import { renderHookResultToUi, runHookCommand } from "./hooks.js";
10
11
  import { firstRunModelBootstrap, loadLocalModel } from "./model.js";
11
12
  import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.js";
12
- import { helpText, renderSearchRun } from "./render.js";
13
+ import { helpText, renderSearchRun, renderSearchRunToUi } from "./render.js";
13
14
  import { effectiveMaxCandidates, effectiveMaxSearchInputTokens, effectiveRepomixConfig, effectiveSearchChecks, loadSearchProfile, } from "./search-profile.js";
14
15
  import { semChangeSetForCommand } from "./sem-provider.js";
15
16
  import { createTracer } from "./trace.js";
@@ -20,26 +21,37 @@ export async function main(argv = process.argv.slice(2)) {
20
21
  try {
21
22
  const command = parseCommand(argv);
22
23
  if (command.kind === "help") {
23
- ui.writeStdout(helpText());
24
+ ui.intro("stupify");
25
+ ui.note(helpText().trim(), "Help");
26
+ ui.outro("Local-only. Warn-only.");
24
27
  return 0;
25
28
  }
26
29
  if (command.kind === "hook") {
27
- ui.writeStdout(await runHookCommand(command.action));
30
+ ui.intro("stupify");
31
+ renderHookResultToUi(await runHookCommand(command.action), ui);
32
+ ui.outro("Hook mode is warn-only. Commits are not blocked.");
28
33
  return 0;
29
34
  }
30
35
  if (command.kind === "doctor") {
31
36
  const result = await runDoctor();
32
- ui.writeStdout(result.text);
37
+ ui.intro("stupify");
38
+ renderDoctorToUi(result, ui);
39
+ ui.outro(result.exitCode === 0 ? "Ready." : "Fix missing required dependencies, then rerun doctor.");
33
40
  return result.exitCode;
34
41
  }
35
42
  if (command.kind === "bench-search") {
36
43
  const { runSearchBench } = await import("./search-bench.js");
37
- ui.writeStdout(await runSearchBench(command.configPath));
44
+ ui.intro("stupify");
45
+ ui.note(await runSearchBench(command.configPath), "Search bench");
46
+ ui.outro("Bench complete.");
38
47
  return 0;
39
48
  }
40
49
  ui = createCliUi({ quiet: command.json });
41
50
  const run = await runSearchCommand(command, startedAt, ui);
42
- ui.writeStdout(renderSearchRun(run, command));
51
+ if (command.json)
52
+ ui.writeStdout(renderSearchRun(run, command));
53
+ else
54
+ renderSearchRunToUi(run, command, ui);
43
55
  return 0;
44
56
  }
45
57
  catch (error) {
@@ -105,6 +117,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
105
117
  elapsedMs: Date.now() - startedAt,
106
118
  modelCalls: 0,
107
119
  committers: changeSet.committers,
120
+ commitSubjects: changeSet.commitSubjects,
108
121
  skipped: true,
109
122
  skipReason: "no_candidates",
110
123
  filesChanged: changeSet.summary.fileCount,
@@ -144,6 +157,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
144
157
  elapsedMs: Date.now() - startedAt,
145
158
  modelCalls: 0,
146
159
  committers: changeSet.committers,
160
+ commitSubjects: changeSet.commitSubjects,
147
161
  skipped: true,
148
162
  skipReason: "no_candidates",
149
163
  filesChanged: changeSet.summary.fileCount,
@@ -193,6 +207,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
193
207
  inputTokens: batches.estimatedInputTokens,
194
208
  inputTokenCap: maxSearchInputTokens,
195
209
  committers: changeSet.committers,
210
+ commitSubjects: changeSet.commitSubjects,
196
211
  skipped: true,
197
212
  skipReason: "input_too_large",
198
213
  filesChanged: changeSet.summary.fileCount,
@@ -246,7 +261,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
246
261
  modelCalls += 1;
247
262
  matches.push(...withCheckWhy(value, checks));
248
263
  }
249
- const uniqueMatches = dedupeMatches(matches);
264
+ const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
250
265
  return {
251
266
  schemaVersion: "search.v1",
252
267
  mode: "search",
@@ -259,6 +274,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
259
274
  inputTokens,
260
275
  inputTokenCap: maxSearchInputTokens,
261
276
  committers: changeSet.committers,
277
+ commitSubjects: changeSet.commitSubjects,
262
278
  filesChanged: changeSet.summary.fileCount,
263
279
  entitiesScanned: changeSet.summary.total,
264
280
  candidates: contexts.length,
@@ -293,9 +309,24 @@ function withCheckWhy(matches, checks) {
293
309
  const checksById = new Map(checks.map((check) => [check.id, check]));
294
310
  return matches.map((match) => ({
295
311
  ...match,
312
+ patternName: checksById.get(match.patternId)?.name,
296
313
  checkWhy: checksById.get(match.patternId)?.why,
297
314
  }));
298
315
  }
316
+ async function withEntityBlame(matches, targetRev, command) {
317
+ if (command.kind === "staged" || command.kind === "stdin")
318
+ return matches;
319
+ return Promise.all(matches.map(async (match) => {
320
+ if (!match.filePath || !match.entityName)
321
+ return match;
322
+ const blame = await blameEntity({
323
+ filePath: match.filePath,
324
+ entityName: match.entityName,
325
+ rev: targetRev,
326
+ });
327
+ return blame ? { ...match, blame } : match;
328
+ }));
329
+ }
299
330
  async function buildSearchBatches(input) {
300
331
  const first = makeSearchBatch(input, input.contexts, input.initialPack);
301
332
  if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
package/dist/types.d.ts CHANGED
@@ -92,6 +92,11 @@ export type StagedDiff = Readonly<{
92
92
  text: string;
93
93
  stats: NetDiffStats;
94
94
  }>;
95
+ export type BlameSummary = Readonly<{
96
+ commit: string;
97
+ author: string;
98
+ subject: string;
99
+ }>;
95
100
  export type NetDiff = Readonly<{
96
101
  id: SourceId;
97
102
  label: string;
@@ -106,6 +111,7 @@ export type SourceRange = Readonly<{
106
111
  base: string;
107
112
  target: string;
108
113
  committers?: readonly string[];
114
+ commitSubjects?: readonly string[];
109
115
  stats: NetDiffStats;
110
116
  }>;
111
117
  export type SemChange = Readonly<{
@@ -132,6 +138,7 @@ export type SemChangeSet = Readonly<{
132
138
  base: string;
133
139
  target: string;
134
140
  committers?: readonly string[];
141
+ commitSubjects?: readonly string[];
135
142
  contextCwd: string;
136
143
  cleanup: () => Promise<void>;
137
144
  changes: readonly SemChange[];
@@ -197,10 +204,15 @@ export type SearchProfile = Readonly<{
197
204
  export type SearchMatch = Readonly<{
198
205
  targetId: string;
199
206
  patternId: CheckId;
207
+ patternName?: string;
200
208
  checkWhy?: string;
201
209
  reason: string;
202
210
  proof: string;
203
211
  snapshot?: string;
212
+ filePath?: string;
213
+ entityName?: string;
214
+ entityKind?: string;
215
+ blame?: BlameSummary;
204
216
  }>;
205
217
  export type SearchRunJson = Readonly<{
206
218
  schemaVersion: "search.v1";
@@ -218,6 +230,7 @@ export type SearchRunJson = Readonly<{
218
230
  skipped?: boolean;
219
231
  skipReason?: "input_too_large" | "no_candidates";
220
232
  committers?: readonly string[];
233
+ commitSubjects?: readonly string[];
221
234
  filesChanged?: number;
222
235
  entitiesScanned?: number;
223
236
  candidates?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
4
4
  "description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
5
5
  "private": false,
6
6
  "type": "module",
package/src/analysis.ts CHANGED
@@ -107,6 +107,9 @@ function uncheckedSearchMatches(value: unknown, contexts: readonly SemContext[])
107
107
  reason: match.reason ?? "",
108
108
  proof: sourcePointer(context),
109
109
  snapshot: sourceSnapshot(context),
110
+ filePath: context.filePath,
111
+ entityName: context.entityName,
112
+ entityKind: context.entityKind,
110
113
  }];
111
114
  });
112
115
  }
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.15";
1
+ export const VERSION = "0.0.16";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";