@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/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) {
@@ -48,12 +60,32 @@ export async function main(argv = process.argv.slice(2)) {
48
60
  }
49
61
  }
50
62
  export async function runSearchCommand(command, startedAt, ui = createCliUi({ quiet: command.json })) {
63
+ const activeSpans = new Map();
51
64
  const t = createTracer({
52
65
  writeLine: () => undefined,
53
66
  onEvent: (event) => {
54
67
  if (command.json)
55
68
  return;
56
- ui.step(formatStep(event.name, event.ms, event.count, event.detail));
69
+ if (event.phase === "start") {
70
+ activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
71
+ return;
72
+ }
73
+ const active = activeSpans.get(event.name);
74
+ activeSpans.delete(event.name);
75
+ const message = event.phase === "error"
76
+ ? formatErrorStep(event.name, event.ms)
77
+ : formatStep(event.name, event.ms, event.count, event.detail);
78
+ if (!active) {
79
+ if (event.phase === "error")
80
+ ui.error(message);
81
+ else
82
+ ui.step(message);
83
+ return;
84
+ }
85
+ if (event.phase === "error")
86
+ active.error(message);
87
+ else
88
+ active.stop(message);
57
89
  },
58
90
  });
59
91
  const profile = await loadSearchProfile(command.searchProfilePath);
@@ -85,6 +117,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
85
117
  elapsedMs: Date.now() - startedAt,
86
118
  modelCalls: 0,
87
119
  committers: changeSet.committers,
120
+ commitSubjects: changeSet.commitSubjects,
88
121
  skipped: true,
89
122
  skipReason: "no_candidates",
90
123
  filesChanged: changeSet.summary.fileCount,
@@ -124,6 +157,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
124
157
  elapsedMs: Date.now() - startedAt,
125
158
  modelCalls: 0,
126
159
  committers: changeSet.committers,
160
+ commitSubjects: changeSet.commitSubjects,
127
161
  skipped: true,
128
162
  skipReason: "no_candidates",
129
163
  filesChanged: changeSet.summary.fileCount,
@@ -143,7 +177,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
143
177
  const pack = profile?.context === "sem" || searchContexts.length === contexts.length
144
178
  ? initialPack
145
179
  : await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
146
- const batches = await buildSearchBatches({
180
+ const { value: batches } = await t.trace("search.batches", () => buildSearchBatches({
147
181
  command,
148
182
  changeSet,
149
183
  contexts: searchContexts,
@@ -153,6 +187,12 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
153
187
  includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
154
188
  maxSearchInputTokens,
155
189
  baseRepomixConfig,
190
+ }), {
191
+ startDetail: `${searchContexts.length} targets`,
192
+ count: (result) => result.batches.length,
193
+ detail: (result) => result.wasSplit
194
+ ? `${result.skippedTargets} oversized targets skipped`
195
+ : `${result.estimatedInputTokens} estimated tokens`,
156
196
  });
157
197
  if (batches.batches.length === 0) {
158
198
  return {
@@ -167,6 +207,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
167
207
  inputTokens: batches.estimatedInputTokens,
168
208
  inputTokenCap: maxSearchInputTokens,
169
209
  committers: changeSet.committers,
210
+ commitSubjects: changeSet.commitSubjects,
170
211
  skipped: true,
171
212
  skipReason: "input_too_large",
172
213
  filesChanged: changeSet.summary.fileCount,
@@ -201,7 +242,10 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
201
242
  let inputTokens = 0;
202
243
  let exactSkippedTargets = batches.skippedTargets;
203
244
  for (const batch of batches.batches) {
204
- const batchInputTokens = await countPromptTokens(model, batch.request.prompt);
245
+ const { value: batchInputTokens } = await t.trace("prompt.tokens", () => countPromptTokens(model, batch.request.prompt), {
246
+ startDetail: `${batch.contexts.length} targets`,
247
+ count: (tokens) => tokens,
248
+ });
205
249
  inputTokens += batchInputTokens;
206
250
  if (batchInputTokens > maxSearchInputTokens) {
207
251
  exactSkippedTargets += batch.contexts.length;
@@ -210,11 +254,14 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
210
254
  }
211
255
  continue;
212
256
  }
213
- const { value } = await t.trace("search.model", () => runSearch(model, batch.request), { count: (v) => v.length });
257
+ const { value } = await t.trace("search.model", () => runSearch(model, batch.request), {
258
+ startDetail: `${batch.contexts.length} targets`,
259
+ count: (v) => v.length,
260
+ });
214
261
  modelCalls += 1;
215
262
  matches.push(...withCheckWhy(value, checks));
216
263
  }
217
- const uniqueMatches = dedupeMatches(matches);
264
+ const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
218
265
  return {
219
266
  schemaVersion: "search.v1",
220
267
  mode: "search",
@@ -227,6 +274,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
227
274
  inputTokens,
228
275
  inputTokenCap: maxSearchInputTokens,
229
276
  committers: changeSet.committers,
277
+ commitSubjects: changeSet.commitSubjects,
230
278
  filesChanged: changeSet.summary.fileCount,
231
279
  entitiesScanned: changeSet.summary.total,
232
280
  candidates: contexts.length,
@@ -261,9 +309,24 @@ function withCheckWhy(matches, checks) {
261
309
  const checksById = new Map(checks.map((check) => [check.id, check]));
262
310
  return matches.map((match) => ({
263
311
  ...match,
312
+ patternName: checksById.get(match.patternId)?.name,
264
313
  checkWhy: checksById.get(match.patternId)?.why,
265
314
  }));
266
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
+ }
267
330
  async function buildSearchBatches(input) {
268
331
  const first = makeSearchBatch(input, input.contexts, input.initialPack);
269
332
  if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
@@ -344,15 +407,45 @@ function printRunPlan(command, patternIds, ui) {
344
407
  `Patterns: ${patternIds.join(", ")}`,
345
408
  ].join("\n"), "Run");
346
409
  }
410
+ function formatStartStep(name, detail) {
411
+ if (name === "entity.diff")
412
+ return "Diff: running sem over the selected git range";
413
+ if (name === "context.pack")
414
+ return "Context: packing selected target files with Repomix";
415
+ if (name === "search.batches")
416
+ return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
417
+ if (name === "prompt.tokens")
418
+ return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
419
+ if (name === "search.model")
420
+ return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
421
+ return `${name}: working`;
422
+ }
347
423
  function formatStep(name, ms, count, detail) {
348
424
  if (name === "entity.diff")
349
425
  return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
350
426
  if (name === "context.pack")
351
427
  return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
428
+ if (name === "search.batches")
429
+ return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
430
+ if (name === "prompt.tokens")
431
+ return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
352
432
  if (name === "search.model")
353
433
  return `Model: ${count ?? 0} matches (${ms}ms)`;
354
434
  return `${name}: ${ms}ms`;
355
435
  }
436
+ function formatErrorStep(name, ms) {
437
+ if (name === "entity.diff")
438
+ return `Diff failed after ${ms}ms`;
439
+ if (name === "context.pack")
440
+ return `Context packing failed after ${ms}ms`;
441
+ if (name === "search.batches")
442
+ return `Search batch preparation failed after ${ms}ms`;
443
+ if (name === "prompt.tokens")
444
+ return `Token counting failed after ${ms}ms`;
445
+ if (name === "search.model")
446
+ return `Model search failed after ${ms}ms`;
447
+ return `${name} failed after ${ms}ms`;
448
+ }
356
449
  function scoutPlanLine(plan, entitiesScanned) {
357
450
  if (plan.targets.length === 0) {
358
451
  return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
package/dist/trace.d.ts CHANGED
@@ -11,12 +11,14 @@ export type Tracer = {
11
11
  };
12
12
  export type SpanTraceEvent = Readonly<{
13
13
  name: string;
14
+ phase: "start" | "end" | "error";
14
15
  ms: number;
15
16
  count?: number;
16
17
  detail?: string;
17
18
  }>;
18
19
  export type SpanTraceOptions<T> = Readonly<{
19
20
  fields?: TraceFields;
21
+ startDetail?: string | (() => string);
20
22
  count?: (value: T) => number;
21
23
  detail?: (value: T) => string;
22
24
  }>;
package/dist/trace.js CHANGED
@@ -16,6 +16,12 @@ export function createTracer(options) {
16
16
  }
17
17
  function trace(span, fn, options) {
18
18
  const startedAtMs = nowMs();
19
+ onEvent?.({
20
+ name: span,
21
+ phase: "start",
22
+ ms: 0,
23
+ detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
24
+ });
19
25
  try {
20
26
  const out = fn();
21
27
  if (isPromiseLike(out)) {
@@ -26,6 +32,7 @@ export function createTracer(options) {
26
32
  durationMs = nowMs() - startedAtMs;
27
33
  const event = {
28
34
  name: span,
35
+ phase: "end",
29
36
  ms: Math.round(durationMs),
30
37
  count: options?.count?.(value),
31
38
  detail: options?.detail?.(value),
@@ -33,6 +40,15 @@ export function createTracer(options) {
33
40
  onEvent?.(event);
34
41
  return { value, ms: event.ms };
35
42
  }
43
+ catch (error) {
44
+ durationMs = nowMs() - startedAtMs;
45
+ onEvent?.({
46
+ name: span,
47
+ phase: "error",
48
+ ms: Math.round(durationMs),
49
+ });
50
+ throw error;
51
+ }
36
52
  finally {
37
53
  durationMs ??= nowMs() - startedAtMs;
38
54
  emit(span, durationMs, options?.fields);
@@ -43,6 +59,7 @@ export function createTracer(options) {
43
59
  emit(span, durationMs, options?.fields);
44
60
  const event = {
45
61
  name: span,
62
+ phase: "end",
46
63
  ms: Math.round(durationMs),
47
64
  count: options?.count?.(out),
48
65
  detail: options?.detail?.(out),
@@ -52,6 +69,11 @@ export function createTracer(options) {
52
69
  }
53
70
  catch (error) {
54
71
  const durationMs = nowMs() - startedAtMs;
72
+ onEvent?.({
73
+ name: span,
74
+ phase: "error",
75
+ ms: Math.round(durationMs),
76
+ });
55
77
  emit(span, durationMs, options?.fields);
56
78
  throw error;
57
79
  }
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.14",
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",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "repository": {
11
11
  "type": "git",
12
- "url": "git+ssh://git@github.com/Octember/stupif.ai.git",
12
+ "url": "git+https://github.com/Octember/stupif.ai.git",
13
13
  "directory": "packages/cli"
14
14
  },
15
15
  "homepage": "https://stupif.ai",
@@ -23,16 +23,18 @@
23
23
  "local-first",
24
24
  "code-review"
25
25
  ],
26
- "license": "UNLICENSED",
26
+ "license": "MIT",
27
27
  "engines": {
28
28
  "node": ">=20"
29
29
  },
30
30
  "publishConfig": {
31
- "access": "public"
31
+ "access": "public",
32
+ "provenance": true
32
33
  },
33
34
  "files": [
34
35
  "dist",
35
36
  "src",
37
+ "LICENSE",
36
38
  "README.md",
37
39
  "package.json"
38
40
  ],
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.14";
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";
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
  }