@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 +5 -1
- package/dist/checks.js +10 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/git.d.ts +1 -0
- package/dist/git.js +44 -1
- package/dist/prompts.js +2 -0
- package/dist/render.js +26 -4
- package/dist/sem-provider.js +11 -7
- package/dist/stupify.js +12 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
- package/src/analysis.ts +6 -1
- package/src/checks.ts +10 -0
- package/src/constants.ts +1 -1
- package/src/git.ts +44 -1
- package/src/prompts.ts +2 -0
- package/src/render.ts +22 -4
- package/src/sem-provider.ts +25 -11
- package/src/stupify.ts +14 -2
- package/src/types.ts +5 -0
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:
|
|
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
|
],
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
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
|
-
|
|
31
|
-
${run.matches.map((match, index) => `${index + 1}.
|
|
32
|
-
${
|
|
33
|
-
|
|
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
|
+
}
|
package/dist/sem-provider.js
CHANGED
|
@@ -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
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:
|
|
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
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
|
-
|
|
36
|
-
${run.matches.map((match, index) => `${index + 1}.
|
|
37
|
-
${
|
|
38
|
-
|
|
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
|
+
}
|
package/src/sem-provider.ts
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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;
|