@stupify/cli 0.0.7 → 0.0.9

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
@@ -71,10 +71,14 @@ function uncheckedSearchMatches(value, contexts) {
71
71
  targetId,
72
72
  patternId: context.checkId,
73
73
  reason: match.reason ?? "",
74
- proof: match.proof ?? "",
74
+ proof: sourcePointer(context),
75
75
  }];
76
76
  });
77
77
  }
78
+ function sourcePointer(context) {
79
+ const file = context.filePath ?? "(unknown)";
80
+ return `${file}::${context.entityKind || "entity"}::${context.entityName || context.entityId}`;
81
+ }
78
82
  async function runJsonPrompt(model, prompt, schema, temperature) {
79
83
  return cachedJson("model-json", fingerprint({
80
84
  version: 1,
package/dist/checks.js CHANGED
@@ -4,6 +4,7 @@ export const defaultChecks = [
4
4
  id: checkId("duplicated_schema"),
5
5
  name: "Duplicated schema",
6
6
  question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
7
+ why: "Duplicated shapes make it easier for AI-assisted changes to drift away from the real source of truth.",
7
8
  lookFor: [
8
9
  "local shape mirrors existing fields and maps them one-for-one",
9
10
  "new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
@@ -28,6 +29,7 @@ export const defaultChecks = [
28
29
  id: checkId("unnecessary_complexity"),
29
30
  name: "Unnecessary complexity",
30
31
  question: "Did the change add structure without buying clarity?",
32
+ why: "Extra indirection can hide simple decisions and make the code feel more designed than understood.",
31
33
  lookFor: [
32
34
  "helper, wrapper, service, layer, or extra file around simple logic without reuse",
33
35
  ],
@@ -68,6 +70,7 @@ Prefer no match over a weak match.`,
68
70
  id: checkId("fake_precision_windowing"),
69
71
  name: "Fake precision windowing",
70
72
  question: "Did the change add fake precision around model context?",
73
+ why: "Precise-looking bookkeeping can create confidence without improving the actual behavior.",
71
74
  lookFor: [
72
75
  "precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
73
76
  ],
@@ -80,6 +83,7 @@ Prefer no match over a weak match.`,
80
83
  id: checkId("coauthored_slop"),
81
84
  name: "Coauthored slop",
82
85
  question: "Does author metadata contain co-author text?",
86
+ why: "Careless metadata is a cheap signal that the change may not have been reviewed with intent.",
83
87
  lookFor: [
84
88
  "author signal contains coauhtoried, coauthored, or co-authored text",
85
89
  ],
@@ -91,6 +95,7 @@ Prefer no match over a weak match.`,
91
95
  id: checkId("mega_file"),
92
96
  name: "Mega file",
93
97
  question: "Is a touched non-config file over 1000 LOC?",
98
+ why: "Large files make judgment harder by concentrating unrelated decisions in one place.",
94
99
  lookFor: [
95
100
  "touched non-config source file over 1000 LOC",
96
101
  ],
@@ -102,6 +107,7 @@ Prefer no match over a weak match.`,
102
107
  id: checkId("over_commenting"),
103
108
  name: "Over commenting",
104
109
  question: "Did the change add noisy comments?",
110
+ why: "Narrative comments can make routine code look deliberate without clarifying the underlying tradeoff.",
105
111
  lookFor: [
106
112
  "comments restate obvious code or narrate simple logic",
107
113
  ],
@@ -129,6 +135,7 @@ Prefer no match over a weak match.`,
129
135
  id: checkId("lint_bypass"),
130
136
  name: "Lint bypass",
131
137
  question: "Did the change bypass lint or type rules?",
138
+ why: "Unexplained suppressions remove useful feedback exactly where a change needs more scrutiny.",
132
139
  lookFor: [
133
140
  "adds suppressions, any, broad casts, or weakens lint/typecheck config",
134
141
  ],
@@ -152,6 +159,7 @@ Prefer no match over a weak match.`,
152
159
  id: checkId("inconsistent_patterns"),
153
160
  name: "Inconsistent patterns",
154
161
  question: "Does the change clash with nearby patterns?",
162
+ why: "Pattern drift can signal that a change followed generic suggestions instead of local codebase judgment.",
155
163
  lookFor: [
156
164
  "same job uses different naming, errors, state, imports, or layout than nearby files",
157
165
  ],
@@ -164,6 +172,7 @@ Prefer no match over a weak match.`,
164
172
  id: checkId("reinvented_utils"),
165
173
  name: "Reinvented utils",
166
174
  question: "Did the change recreate an existing utility?",
175
+ why: "Generic helper reinvention can be a sign that the change optimized for plausible code over local reuse.",
167
176
  lookFor: [
168
177
  "new helper duplicates local utility or standard library behavior",
169
178
  ],
@@ -188,6 +197,7 @@ Prefer no match over a weak match.`,
188
197
  id: checkId("operator_style_mismatch"),
189
198
  name: "Operator style mismatch",
190
199
  question: "Does the change read unlike the surrounding code?",
200
+ why: "Style mismatch can reveal generic generated code that was not reconciled with nearby conventions.",
191
201
  lookFor: [
192
202
  "generic or template-like names, abstractions, comments, or control flow clash with local style",
193
203
  ],
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.7";
1
+ export declare const VERSION = "0.0.9";
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.7";
1
+ export const VERSION = "0.0.9";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
package/dist/git.d.ts CHANGED
@@ -9,3 +9,4 @@ export declare function netDiffFromStdin(text: string): Promise<NetDiff>;
9
9
  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
+ export declare function gitUserLabel(): Promise<string>;
package/dist/git.js CHANGED
@@ -87,6 +87,15 @@ export async function gitPath(pathspec) {
87
87
  throw new Error(`Could not resolve git path: ${pathspec}`);
88
88
  }
89
89
  }
90
+ export async function gitUserLabel() {
91
+ const [name, email] = await Promise.all([
92
+ gitConfig("user.name"),
93
+ gitConfig("user.email"),
94
+ ]);
95
+ if (name && email)
96
+ return `${name} <${email}>`;
97
+ return name || email || "working tree";
98
+ }
90
99
  async function netDiff(base, target, label, id) {
91
100
  const [text, stats, shortBase, shortTarget] = await Promise.all([
92
101
  diff(base, target),
@@ -104,19 +113,53 @@ async function netDiff(base, target, label, id) {
104
113
  };
105
114
  }
106
115
  async function sourceRange(base, target, label, id) {
107
- const [stats, shortBase, shortTarget] = await Promise.all([
116
+ const [stats, shortBase, shortTarget, committers] = await Promise.all([
108
117
  diffStats(base, target),
109
118
  shortCommit(base),
110
119
  shortCommit(target),
120
+ committersForRange(base, target),
111
121
  ]);
112
122
  return {
113
123
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
114
124
  label,
115
125
  base,
116
126
  target,
127
+ committers,
117
128
  stats,
118
129
  };
119
130
  }
131
+ async function gitConfig(key) {
132
+ try {
133
+ const { stdout } = await execFileAsync("git", ["config", "--get", key]);
134
+ return stdout.trim();
135
+ }
136
+ catch {
137
+ return "";
138
+ }
139
+ }
140
+ async function committersForRange(base, target) {
141
+ try {
142
+ const { stdout } = await execFileAsync("git", ["log", "--format=%cn <%ce>", `${base}..${target}`], {
143
+ maxBuffer: 4 * 1024 * 1024,
144
+ });
145
+ return uniqueLines(stdout);
146
+ }
147
+ catch {
148
+ return [];
149
+ }
150
+ }
151
+ function uniqueLines(value) {
152
+ const seen = new Set();
153
+ const lines = [];
154
+ for (const line of value.split(/\r?\n/)) {
155
+ const trimmed = line.trim();
156
+ if (!trimmed || seen.has(trimmed))
157
+ continue;
158
+ seen.add(trimmed);
159
+ lines.push(trimmed);
160
+ }
161
+ return lines;
162
+ }
120
163
  async function baseBefore(since) {
121
164
  try {
122
165
  const { stdout } = await execFileAsync("git", [
package/dist/prompts.js CHANGED
@@ -55,6 +55,7 @@ ${input.pack.text || "(none)"}`;
55
55
  }
56
56
  function formatSearchPattern(check) {
57
57
  return `Pattern: ${check.id} (${check.name})
58
+ Why this matters: ${check.why}
58
59
  Question: ${check.searchPrompt ?? check.question}
59
60
  Look for:
60
61
  ${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
@@ -81,6 +82,7 @@ function patternForContext(context, patterns) {
81
82
  id: context.checkId,
82
83
  name: context.checkId,
83
84
  question: `Does this target match ${context.checkId}?`,
85
+ why: "This pattern may indicate judgment-offload.",
84
86
  lookFor: [],
85
87
  ignoreWhen: [],
86
88
  };
package/dist/render.js CHANGED
@@ -27,10 +27,13 @@ Patterns: ${run.patterns.join(", ")}
27
27
  No judgment-offload signals found.`;
28
28
  }
29
29
  return `🧙 stupify 🪄
30
- Possible judgment-offload detected:
31
- ${run.matches.map((match, index) => `${index + 1}. ${match.patternId}
32
- ${match.reason}
33
- Proof: ${match.proof}`).join("\n")}
30
+ AI SLOP DETECTED
31
+ ${run.matches.map((match, index) => `${index + 1}.
32
+ who: ${committerLabel(run)}
33
+ what: ${match.patternId} - ${match.reason}
34
+ when: ${sourceLabel(command)}
35
+ where: ${match.proof}
36
+ why: ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n")}
34
37
  Search mode is warn-only.`;
35
38
  }
36
39
  export function helpText() {
@@ -91,3 +94,22 @@ function sourceHint(command) {
91
94
  return `--commits ${command.count}`;
92
95
  return "--stdin";
93
96
  }
97
+ function sourceLabel(command) {
98
+ if (command.kind === "staged")
99
+ return "staged changes";
100
+ if (command.kind === "since")
101
+ return `since ${command.since}`;
102
+ if (command.kind === "commit")
103
+ return `commit ${command.commit}`;
104
+ if (command.kind === "commits")
105
+ return `last ${command.count} commits`;
106
+ return "stdin diff";
107
+ }
108
+ function committerLabel(run) {
109
+ const committers = (run.stats.committers ?? []).filter(Boolean);
110
+ if (committers.length === 0)
111
+ return "unknown committer";
112
+ if (committers.length <= 3)
113
+ return committers.join(", ");
114
+ return `${committers.slice(0, 3).join(", ")} +${committers.length - 3} more`;
115
+ }
@@ -6,17 +6,18 @@ import path from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { cachedJson, fingerprint } from "./cache.js";
8
8
  import { readDiffFromStdin } from "./diff.js";
9
- import { sourceRangeForCommit, sourceRangeForRecentCommits, sourceRangeSince, stagedDiff, } from "./git.js";
9
+ import { gitUserLabel, sourceRangeForCommit, sourceRangeForRecentCommits, sourceRangeSince, stagedDiff, } from "./git.js";
10
10
  import { sourceId } from "./types.js";
11
11
  const execFileAsync = promisify(execFile);
12
12
  export async function semChangeSetForCommand(command) {
13
13
  if (command.kind === "stdin")
14
- return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem);
14
+ return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem, "stdin", ["stdin"]);
15
15
  if (command.kind === "staged") {
16
- const diff = await stagedDiff();
16
+ const [diff, committer] = await Promise.all([stagedDiff(), gitUserLabel()]);
17
+ const committers = [committer];
17
18
  if (!diff.text.trim())
18
- return emptyChangeSet("staged", diff.stats);
19
- return semChangeSetFromPatch(diff.text, command.debugSem, "staged");
19
+ return emptyChangeSet("staged", diff.stats, committers);
20
+ return semChangeSetFromPatch(diff.text, command.debugSem, "staged", committers);
20
21
  }
21
22
  if (command.kind === "commit") {
22
23
  const range = await sourceRangeForCommit(command.commit);
@@ -27,12 +28,13 @@ export async function semChangeSetForCommand(command) {
27
28
  const raw = await cachedSemDiff(["diff", "--from", range.base, "--to", range.target, "--format", "json"], range, command.debugSem);
28
29
  return withContextWorkspace(normalizeSemDiff(raw, range), command.debugSem);
29
30
  }
30
- function emptyChangeSet(label, stats) {
31
+ function emptyChangeSet(label, stats, committers) {
31
32
  return {
32
33
  id: sourceId(label),
33
34
  label,
34
35
  base: label,
35
36
  target: label,
37
+ committers,
36
38
  contextCwd: process.cwd(),
37
39
  cleanup: async () => undefined,
38
40
  changes: [],
@@ -56,7 +58,7 @@ async function semRangeForCommand(command) {
56
58
  return sourceRangeForRecentCommits(command.count);
57
59
  throw new Error("sem cannot resolve stdin as a git range.");
58
60
  }
59
- async function semChangeSetFromPatch(patch, debugSem, label = "stdin") {
61
+ async function semChangeSetFromPatch(patch, debugSem, label = "stdin", committers) {
60
62
  if (!patch.trim())
61
63
  throw new Error("No diff received on stdin.");
62
64
  const raw = await cachedJson("sem-diff", fingerprint({
@@ -71,6 +73,7 @@ async function semChangeSetFromPatch(patch, debugSem, label = "stdin") {
71
73
  label,
72
74
  base: label,
73
75
  target: label,
76
+ committers,
74
77
  stats: { filesChanged: 0, additions: 0, deletions: 0 },
75
78
  }),
76
79
  contextCwd: process.cwd(),
@@ -194,6 +197,7 @@ function normalizeSemDiff(value, range) {
194
197
  label: range.label,
195
198
  base: range.base,
196
199
  target: range.target,
200
+ committers: range.committers,
197
201
  contextCwd: process.cwd(),
198
202
  cleanup: async () => undefined,
199
203
  changes,
package/dist/stupify.js CHANGED
@@ -78,6 +78,7 @@ export async function runSearchCommand(command, startedAt) {
78
78
  stats: {
79
79
  elapsedMs: Date.now() - startedAt,
80
80
  modelCalls: 0,
81
+ committers: changeSet.committers,
81
82
  skipped: true,
82
83
  skipReason: "no_candidates",
83
84
  filesChanged: changeSet.summary.fileCount,
@@ -114,6 +115,7 @@ export async function runSearchCommand(command, startedAt) {
114
115
  stats: {
115
116
  elapsedMs: Date.now() - startedAt,
116
117
  modelCalls: 0,
118
+ committers: changeSet.committers,
117
119
  skipped: true,
118
120
  skipReason: "no_candidates",
119
121
  filesChanged: changeSet.summary.fileCount,
@@ -156,6 +158,7 @@ export async function runSearchCommand(command, startedAt) {
156
158
  modelCalls: 0,
157
159
  inputTokens: batches.estimatedInputTokens,
158
160
  inputTokenCap: maxSearchInputTokens,
161
+ committers: changeSet.committers,
159
162
  skipped: true,
160
163
  skipReason: "input_too_large",
161
164
  filesChanged: changeSet.summary.fileCount,
@@ -198,7 +201,7 @@ export async function runSearchCommand(command, startedAt) {
198
201
  }
199
202
  const { value } = await t.trace("search.model", () => runSearch(model, batch.request), { count: (v) => v.length });
200
203
  modelCalls += 1;
201
- matches.push(...value);
204
+ matches.push(...withCheckWhy(value, checks));
202
205
  }
203
206
  const uniqueMatches = dedupeMatches(matches);
204
207
  return {
@@ -212,6 +215,7 @@ export async function runSearchCommand(command, startedAt) {
212
215
  modelCalls,
213
216
  inputTokens,
214
217
  inputTokenCap: maxSearchInputTokens,
218
+ committers: changeSet.committers,
215
219
  filesChanged: changeSet.summary.fileCount,
216
220
  entitiesScanned: changeSet.summary.total,
217
221
  candidates: contexts.length,
@@ -242,6 +246,13 @@ function dedupeMatches(matches) {
242
246
  return true;
243
247
  });
244
248
  }
249
+ function withCheckWhy(matches, checks) {
250
+ const checksById = new Map(checks.map((check) => [check.id, check]));
251
+ return matches.map((match) => ({
252
+ ...match,
253
+ checkWhy: checksById.get(match.patternId)?.why,
254
+ }));
255
+ }
245
256
  async function buildSearchBatches(input) {
246
257
  const first = makeSearchBatch(input, input.contexts, input.initialPack);
247
258
  if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
package/dist/types.d.ts CHANGED
@@ -68,6 +68,7 @@ export type StupifyCheck = Readonly<{
68
68
  id: CheckId;
69
69
  name: string;
70
70
  question: string;
71
+ why: string;
71
72
  lookFor: readonly string[];
72
73
  ignoreWhen: readonly string[];
73
74
  enabledByDefault?: boolean;
@@ -104,6 +105,7 @@ export type SourceRange = Readonly<{
104
105
  label: string;
105
106
  base: string;
106
107
  target: string;
108
+ committers?: readonly string[];
107
109
  stats: NetDiffStats;
108
110
  }>;
109
111
  export type SemChange = Readonly<{
@@ -129,6 +131,7 @@ export type SemChangeSet = Readonly<{
129
131
  label: string;
130
132
  base: string;
131
133
  target: string;
134
+ committers?: readonly string[];
132
135
  contextCwd: string;
133
136
  cleanup: () => Promise<void>;
134
137
  changes: readonly SemChange[];
@@ -194,6 +197,7 @@ export type SearchProfile = Readonly<{
194
197
  export type SearchMatch = Readonly<{
195
198
  targetId: string;
196
199
  patternId: CheckId;
200
+ checkWhy?: string;
197
201
  reason: string;
198
202
  proof: string;
199
203
  }>;
@@ -212,6 +216,7 @@ export type SearchRunJson = Readonly<{
212
216
  inputTokenCap?: number;
213
217
  skipped?: boolean;
214
218
  skipReason?: "input_too_large" | "no_candidates";
219
+ committers?: readonly string[];
215
220
  filesChanged?: number;
216
221
  entitiesScanned?: number;
217
222
  candidates?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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
@@ -104,11 +104,16 @@ function uncheckedSearchMatches(value: unknown, contexts: readonly SemContext[])
104
104
  targetId,
105
105
  patternId: context.checkId,
106
106
  reason: match.reason ?? "",
107
- proof: match.proof ?? "",
107
+ proof: sourcePointer(context),
108
108
  }];
109
109
  });
110
110
  }
111
111
 
112
+ function sourcePointer(context: SemContext): string {
113
+ const file = context.filePath ?? "(unknown)";
114
+ return `${file}::${context.entityKind || "entity"}::${context.entityName || context.entityId}`;
115
+ }
116
+
112
117
  async function runJsonPrompt(
113
118
  model: LocalModel,
114
119
  prompt: string,
package/src/checks.ts CHANGED
@@ -5,6 +5,7 @@ export const defaultChecks: readonly StupifyCheck[] = [
5
5
  id: checkId("duplicated_schema"),
6
6
  name: "Duplicated schema",
7
7
  question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
8
+ why: "Duplicated shapes make it easier for AI-assisted changes to drift away from the real source of truth.",
8
9
  lookFor: [
9
10
  "local shape mirrors existing fields and maps them one-for-one",
10
11
  "new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
@@ -29,6 +30,7 @@ export const defaultChecks: readonly StupifyCheck[] = [
29
30
  id: checkId("unnecessary_complexity"),
30
31
  name: "Unnecessary complexity",
31
32
  question: "Did the change add structure without buying clarity?",
33
+ why: "Extra indirection can hide simple decisions and make the code feel more designed than understood.",
32
34
  lookFor: [
33
35
  "helper, wrapper, service, layer, or extra file around simple logic without reuse",
34
36
  ],
@@ -69,6 +71,7 @@ Prefer no match over a weak match.`,
69
71
  id: checkId("fake_precision_windowing"),
70
72
  name: "Fake precision windowing",
71
73
  question: "Did the change add fake precision around model context?",
74
+ why: "Precise-looking bookkeeping can create confidence without improving the actual behavior.",
72
75
  lookFor: [
73
76
  "precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
74
77
  ],
@@ -81,6 +84,7 @@ Prefer no match over a weak match.`,
81
84
  id: checkId("coauthored_slop"),
82
85
  name: "Coauthored slop",
83
86
  question: "Does author metadata contain co-author text?",
87
+ why: "Careless metadata is a cheap signal that the change may not have been reviewed with intent.",
84
88
  lookFor: [
85
89
  "author signal contains coauhtoried, coauthored, or co-authored text",
86
90
  ],
@@ -92,6 +96,7 @@ Prefer no match over a weak match.`,
92
96
  id: checkId("mega_file"),
93
97
  name: "Mega file",
94
98
  question: "Is a touched non-config file over 1000 LOC?",
99
+ why: "Large files make judgment harder by concentrating unrelated decisions in one place.",
95
100
  lookFor: [
96
101
  "touched non-config source file over 1000 LOC",
97
102
  ],
@@ -103,6 +108,7 @@ Prefer no match over a weak match.`,
103
108
  id: checkId("over_commenting"),
104
109
  name: "Over commenting",
105
110
  question: "Did the change add noisy comments?",
111
+ why: "Narrative comments can make routine code look deliberate without clarifying the underlying tradeoff.",
106
112
  lookFor: [
107
113
  "comments restate obvious code or narrate simple logic",
108
114
  ],
@@ -130,6 +136,7 @@ Prefer no match over a weak match.`,
130
136
  id: checkId("lint_bypass"),
131
137
  name: "Lint bypass",
132
138
  question: "Did the change bypass lint or type rules?",
139
+ why: "Unexplained suppressions remove useful feedback exactly where a change needs more scrutiny.",
133
140
  lookFor: [
134
141
  "adds suppressions, any, broad casts, or weakens lint/typecheck config",
135
142
  ],
@@ -153,6 +160,7 @@ Prefer no match over a weak match.`,
153
160
  id: checkId("inconsistent_patterns"),
154
161
  name: "Inconsistent patterns",
155
162
  question: "Does the change clash with nearby patterns?",
163
+ why: "Pattern drift can signal that a change followed generic suggestions instead of local codebase judgment.",
156
164
  lookFor: [
157
165
  "same job uses different naming, errors, state, imports, or layout than nearby files",
158
166
  ],
@@ -165,6 +173,7 @@ Prefer no match over a weak match.`,
165
173
  id: checkId("reinvented_utils"),
166
174
  name: "Reinvented utils",
167
175
  question: "Did the change recreate an existing utility?",
176
+ why: "Generic helper reinvention can be a sign that the change optimized for plausible code over local reuse.",
168
177
  lookFor: [
169
178
  "new helper duplicates local utility or standard library behavior",
170
179
  ],
@@ -189,6 +198,7 @@ Prefer no match over a weak match.`,
189
198
  id: checkId("operator_style_mismatch"),
190
199
  name: "Operator style mismatch",
191
200
  question: "Does the change read unlike the surrounding code?",
201
+ why: "Style mismatch can reveal generic generated code that was not reconciled with nearby conventions.",
192
202
  lookFor: [
193
203
  "generic or template-like names, abstractions, comments, or control flow clash with local style",
194
204
  ],
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.7";
1
+ export const VERSION = "0.0.9";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
package/src/git.ts CHANGED
@@ -96,6 +96,15 @@ export async function gitPath(pathspec: string): Promise<string> {
96
96
  }
97
97
  }
98
98
 
99
+ export async function gitUserLabel(): Promise<string> {
100
+ const [name, email] = await Promise.all([
101
+ gitConfig("user.name"),
102
+ gitConfig("user.email"),
103
+ ]);
104
+ if (name && email) return `${name} <${email}>`;
105
+ return name || email || "working tree";
106
+ }
107
+
99
108
  async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
100
109
  const [text, stats, shortBase, shortTarget] = await Promise.all([
101
110
  diff(base, target),
@@ -114,20 +123,54 @@ async function netDiff(base: string, target: string, label: string, id?: NetDiff
114
123
  }
115
124
 
116
125
  async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
117
- const [stats, shortBase, shortTarget] = await Promise.all([
126
+ const [stats, shortBase, shortTarget, committers] = await Promise.all([
118
127
  diffStats(base, target),
119
128
  shortCommit(base),
120
129
  shortCommit(target),
130
+ committersForRange(base, target),
121
131
  ]);
122
132
  return {
123
133
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
124
134
  label,
125
135
  base,
126
136
  target,
137
+ committers,
127
138
  stats,
128
139
  };
129
140
  }
130
141
 
142
+ async function gitConfig(key: string): Promise<string> {
143
+ try {
144
+ const { stdout } = await execFileAsync("git", ["config", "--get", key]);
145
+ return stdout.trim();
146
+ } catch {
147
+ return "";
148
+ }
149
+ }
150
+
151
+ async function committersForRange(base: string, target: string): Promise<readonly string[]> {
152
+ try {
153
+ const { stdout } = await execFileAsync("git", ["log", "--format=%cn <%ce>", `${base}..${target}`], {
154
+ maxBuffer: 4 * 1024 * 1024,
155
+ });
156
+ return uniqueLines(stdout);
157
+ } catch {
158
+ return [];
159
+ }
160
+ }
161
+
162
+ function uniqueLines(value: string): readonly string[] {
163
+ const seen = new Set<string>();
164
+ const lines: string[] = [];
165
+ for (const line of value.split(/\r?\n/)) {
166
+ const trimmed = line.trim();
167
+ if (!trimmed || seen.has(trimmed)) continue;
168
+ seen.add(trimmed);
169
+ lines.push(trimmed);
170
+ }
171
+ return lines;
172
+ }
173
+
131
174
  async function baseBefore(since: string): Promise<string> {
132
175
  try {
133
176
  const { stdout } = await execFileAsync("git", [
package/src/prompts.ts CHANGED
@@ -64,6 +64,7 @@ ${input.pack.text || "(none)"}`;
64
64
 
65
65
  function formatSearchPattern(check: StupifyCheck): string {
66
66
  return `Pattern: ${check.id} (${check.name})
67
+ Why this matters: ${check.why}
67
68
  Question: ${check.searchPrompt ?? check.question}
68
69
  Look for:
69
70
  ${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
@@ -92,6 +93,7 @@ function patternForContext(context: SemContext, patterns: readonly StupifyCheck[
92
93
  id: context.checkId,
93
94
  name: context.checkId,
94
95
  question: `Does this target match ${context.checkId}?`,
96
+ why: "This pattern may indicate judgment-offload.",
95
97
  lookFor: [],
96
98
  ignoreWhen: [],
97
99
  };
package/src/render.ts CHANGED
@@ -32,10 +32,13 @@ No judgment-offload signals found.`;
32
32
  }
33
33
 
34
34
  return `🧙 stupify 🪄
35
- Possible judgment-offload detected:
36
- ${run.matches.map((match, index) => `${index + 1}. ${match.patternId}
37
- ${match.reason}
38
- Proof: ${match.proof}`).join("\n")}
35
+ AI SLOP DETECTED
36
+ ${run.matches.map((match, index) => `${index + 1}.
37
+ who: ${committerLabel(run)}
38
+ what: ${match.patternId} - ${match.reason}
39
+ when: ${sourceLabel(command)}
40
+ where: ${match.proof}
41
+ why: ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n")}
39
42
  Search mode is warn-only.`;
40
43
  }
41
44
 
@@ -94,3 +97,18 @@ function sourceHint(command: SearchCommand): string {
94
97
  if (command.kind === "commits") return `--commits ${command.count}`;
95
98
  return "--stdin";
96
99
  }
100
+
101
+ function sourceLabel(command: SearchCommand): string {
102
+ if (command.kind === "staged") return "staged changes";
103
+ if (command.kind === "since") return `since ${command.since}`;
104
+ if (command.kind === "commit") return `commit ${command.commit}`;
105
+ if (command.kind === "commits") return `last ${command.count} commits`;
106
+ return "stdin diff";
107
+ }
108
+
109
+ function committerLabel(run: SearchRunJson): string {
110
+ const committers = (run.stats.committers ?? []).filter(Boolean);
111
+ if (committers.length === 0) return "unknown committer";
112
+ if (committers.length <= 3) return committers.join(", ");
113
+ return `${committers.slice(0, 3).join(", ")} +${committers.length - 3} more`;
114
+ }
@@ -7,6 +7,7 @@ import { promisify } from "node:util";
7
7
  import { cachedJson, fingerprint } from "./cache.ts";
8
8
  import { readDiffFromStdin } from "./diff.ts";
9
9
  import {
10
+ gitUserLabel,
10
11
  sourceRangeForCommit,
11
12
  sourceRangeForRecentCommits,
12
13
  sourceRangeSince,
@@ -26,11 +27,12 @@ const execFileAsync = promisify(execFile);
26
27
  export async function semChangeSetForCommand(
27
28
  command: SearchCommand,
28
29
  ): Promise<SemChangeSet> {
29
- if (command.kind === "stdin") return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem);
30
+ if (command.kind === "stdin") return semChangeSetFromPatch(await readDiffFromStdin(), command.debugSem, "stdin", ["stdin"]);
30
31
  if (command.kind === "staged") {
31
- const diff = await stagedDiff();
32
- if (!diff.text.trim()) return emptyChangeSet("staged", diff.stats);
33
- return semChangeSetFromPatch(diff.text, command.debugSem, "staged");
32
+ const [diff, committer] = await Promise.all([stagedDiff(), gitUserLabel()]);
33
+ const committers = [committer];
34
+ if (!diff.text.trim()) return emptyChangeSet("staged", diff.stats, committers);
35
+ return semChangeSetFromPatch(diff.text, command.debugSem, "staged", committers);
34
36
  }
35
37
  if (command.kind === "commit") {
36
38
  const range = await sourceRangeForCommit(command.commit);
@@ -51,12 +53,17 @@ export async function semChangeSetForCommand(
51
53
  return withContextWorkspace(normalizeSemDiff(raw, range), command.debugSem);
52
54
  }
53
55
 
54
- function emptyChangeSet(label: string, stats: SourceRange["stats"]): SemChangeSet {
56
+ function emptyChangeSet(
57
+ label: string,
58
+ stats: SourceRange["stats"],
59
+ committers?: readonly string[],
60
+ ): SemChangeSet {
55
61
  return {
56
62
  id: sourceId(label),
57
63
  label,
58
64
  base: label,
59
65
  target: label,
66
+ committers,
60
67
  contextCwd: process.cwd(),
61
68
  cleanup: async () => undefined,
62
69
  changes: [],
@@ -79,7 +86,12 @@ async function semRangeForCommand(command: SearchCommand): Promise<SourceRange>
79
86
  throw new Error("sem cannot resolve stdin as a git range.");
80
87
  }
81
88
 
82
- async function semChangeSetFromPatch(patch: string, debugSem: boolean, label = "stdin"): Promise<SemChangeSet> {
89
+ async function semChangeSetFromPatch(
90
+ patch: string,
91
+ debugSem: boolean,
92
+ label = "stdin",
93
+ committers?: readonly string[],
94
+ ): Promise<SemChangeSet> {
83
95
  if (!patch.trim()) throw new Error("No diff received on stdin.");
84
96
  const raw = await cachedJson(
85
97
  "sem-diff",
@@ -93,11 +105,12 @@ async function semChangeSetFromPatch(patch: string, debugSem: boolean, label = "
93
105
  );
94
106
  return {
95
107
  ...normalizeSemDiff(raw, {
96
- id: sourceId(label),
97
- label,
98
- base: label,
99
- target: label,
100
- stats: { filesChanged: 0, additions: 0, deletions: 0 },
108
+ id: sourceId(label),
109
+ label,
110
+ base: label,
111
+ target: label,
112
+ committers,
113
+ stats: { filesChanged: 0, additions: 0, deletions: 0 },
101
114
  }),
102
115
  contextCwd: process.cwd(),
103
116
  cleanup: async () => undefined,
@@ -225,6 +238,7 @@ function normalizeSemDiff(value: unknown, range: SourceRange): SemChangeSet {
225
238
  label: range.label,
226
239
  base: range.base,
227
240
  target: range.target,
241
+ committers: range.committers,
228
242
  contextCwd: process.cwd(),
229
243
  cleanup: async () => undefined,
230
244
  changes,
package/src/stupify.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from "./search-profile.ts";
21
21
  import { semChangeSetForCommand } from "./sem-provider.ts";
22
22
  import { createTracer } from "./trace.ts";
23
- import type { SearchCommand, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
23
+ import type { SearchCommand, SearchMatch, SearchProfile, SearchRunJson, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
24
24
 
25
25
  export async function main(argv = process.argv.slice(2)): Promise<number> {
26
26
  const startedAt = Date.now();
@@ -93,6 +93,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
93
93
  stats: {
94
94
  elapsedMs: Date.now() - startedAt,
95
95
  modelCalls: 0,
96
+ committers: changeSet.committers,
96
97
  skipped: true,
97
98
  skipReason: "no_candidates",
98
99
  filesChanged: changeSet.summary.fileCount,
@@ -134,6 +135,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
134
135
  stats: {
135
136
  elapsedMs: Date.now() - startedAt,
136
137
  modelCalls: 0,
138
+ committers: changeSet.committers,
137
139
  skipped: true,
138
140
  skipReason: "no_candidates",
139
141
  filesChanged: changeSet.summary.fileCount,
@@ -177,6 +179,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
177
179
  modelCalls: 0,
178
180
  inputTokens: batches.estimatedInputTokens,
179
181
  inputTokenCap: maxSearchInputTokens,
182
+ committers: changeSet.committers,
180
183
  skipped: true,
181
184
  skipReason: "input_too_large",
182
185
  filesChanged: changeSet.summary.fileCount,
@@ -225,7 +228,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
225
228
  { count: (v) => v.length },
226
229
  );
227
230
  modelCalls += 1;
228
- matches.push(...value);
231
+ matches.push(...withCheckWhy(value, checks));
229
232
  }
230
233
  const uniqueMatches = dedupeMatches(matches);
231
234
 
@@ -240,6 +243,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
240
243
  modelCalls,
241
244
  inputTokens,
242
245
  inputTokenCap: maxSearchInputTokens,
246
+ committers: changeSet.committers,
243
247
  filesChanged: changeSet.summary.fileCount,
244
248
  entitiesScanned: changeSet.summary.total,
245
249
  candidates: contexts.length,
@@ -270,6 +274,14 @@ function dedupeMatches<T extends { targetId: string; patternId: string; proof: s
270
274
  });
271
275
  }
272
276
 
277
+ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyCheck[]): readonly SearchMatch[] {
278
+ const checksById = new Map(checks.map((check) => [check.id, check]));
279
+ return matches.map((match) => ({
280
+ ...match,
281
+ checkWhy: checksById.get(match.patternId)?.why,
282
+ }));
283
+ }
284
+
273
285
  type SearchBatch = Readonly<{
274
286
  contexts: readonly SemContext[];
275
287
  pack: SemContextPack;
package/src/types.ts CHANGED
@@ -51,6 +51,7 @@ export type StupifyCheck = Readonly<{
51
51
  id: CheckId;
52
52
  name: string;
53
53
  question: string;
54
+ why: string;
54
55
  lookFor: readonly string[];
55
56
  ignoreWhen: readonly string[];
56
57
  enabledByDefault?: boolean;
@@ -91,6 +92,7 @@ export type SourceRange = Readonly<{
91
92
  label: string;
92
93
  base: string;
93
94
  target: string;
95
+ committers?: readonly string[];
94
96
  stats: NetDiffStats;
95
97
  }>;
96
98
 
@@ -119,6 +121,7 @@ export type SemChangeSet = Readonly<{
119
121
  label: string;
120
122
  base: string;
121
123
  target: string;
124
+ committers?: readonly string[];
122
125
  contextCwd: string;
123
126
  cleanup: () => Promise<void>;
124
127
  changes: readonly SemChange[];
@@ -192,6 +195,7 @@ export type SearchProfile = Readonly<{
192
195
  export type SearchMatch = Readonly<{
193
196
  targetId: string;
194
197
  patternId: CheckId;
198
+ checkWhy?: string;
195
199
  reason: string;
196
200
  proof: string;
197
201
  }>;
@@ -209,6 +213,7 @@ export type SearchRunJson = Readonly<{
209
213
  inputTokenCap?: number;
210
214
  skipped?: boolean;
211
215
  skipReason?: "input_too_large" | "no_candidates";
216
+ committers?: readonly string[];
212
217
  filesChanged?: number;
213
218
  entitiesScanned?: number;
214
219
  candidates?: number;