@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stupify contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Local-only diagnostic CLI for checking whether AI is making you dumber.
4
4
 
5
+ Released under the MIT License.
6
+
5
7
  Stupify has one analysis path:
6
8
 
7
9
  ```text
@@ -48,7 +50,8 @@ stupify --staged --max-search-input-tokens 24000
48
50
  ```
49
51
 
50
52
  The package is prepared for the public `@stupify` npm scope. Publishing should
51
- run the TypeScript build first so the executable points at `dist/stupify.js`.
53
+ use the repository release workflow so npm receives Trusted Publishing
54
+ provenance. See the repository release docs.
52
55
 
53
56
  This iteration intentionally does not run findings audit, validators, judges,
54
57
  baselines, hosted LLM APIs, GitHub integration, dashboards, or repo-wide
package/dist/analysis.js CHANGED
@@ -74,6 +74,9 @@ function uncheckedSearchMatches(value, contexts) {
74
74
  reason: match.reason ?? "",
75
75
  proof: sourcePointer(context),
76
76
  snapshot: sourceSnapshot(context),
77
+ filePath: context.filePath,
78
+ entityName: context.entityName,
79
+ entityKind: context.entityKind,
77
80
  }];
78
81
  });
79
82
  }
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "0.0.14";
1
+ export declare const VERSION = "0.0.16";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
  export declare const DEFAULT_MODEL_ID: ModelId;
4
4
  export declare const MODEL_REGISTRY: Record<ModelId, ModelConfig>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.14";
1
+ export const VERSION = "0.0.16";
2
2
  export const DEFAULT_MODEL_ID = "gemma-4-e2b";
3
3
  export const MODEL_REGISTRY = {
4
4
  "gemma-4-e2b": {
package/dist/doctor.d.ts CHANGED
@@ -1,4 +1,16 @@
1
- export declare function runDoctor(): Promise<Readonly<{
1
+ import type { CliUi } from "./ui.ts";
2
+ type CheckStatus = "ok" | "missing" | "info";
3
+ type DoctorCheck = Readonly<{
4
+ label: string;
5
+ status: CheckStatus;
6
+ detail: string;
7
+ required?: boolean;
8
+ }>;
9
+ export type DoctorResult = Readonly<{
2
10
  exitCode: number;
3
11
  text: string;
4
- }>>;
12
+ checks: readonly DoctorCheck[];
13
+ }>;
14
+ export declare function runDoctor(): Promise<DoctorResult>;
15
+ export declare function renderDoctorToUi(result: DoctorResult, ui: CliUi): void;
16
+ export {};
package/dist/doctor.js CHANGED
@@ -19,8 +19,20 @@ export async function runDoctor() {
19
19
  return {
20
20
  exitCode: requiredMissing ? 1 : 0,
21
21
  text: renderDoctor(checks),
22
+ checks,
22
23
  };
23
24
  }
25
+ export function renderDoctorToUi(result, ui) {
26
+ const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
27
+ if (missingRequired.length > 0) {
28
+ ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
29
+ }
30
+ else {
31
+ ui.success("Doctor checks complete.");
32
+ }
33
+ ui.note(result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"), "Doctor");
34
+ ui.note("Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.", "Privacy");
35
+ }
24
36
  async function gitCheck() {
25
37
  try {
26
38
  const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
package/dist/git.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
1
+ import { type BlameSummary, type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
2
2
  export declare function netDiffSince(since: string): Promise<NetDiff>;
3
3
  export declare function netDiffForCommit(commit: string): Promise<NetDiff>;
4
4
  export declare function netDiffForRecentCommits(count: number): Promise<NetDiff>;
@@ -10,3 +10,8 @@ export declare function stagedDiff(): Promise<StagedDiff>;
10
10
  export declare function gitRoot(): Promise<string>;
11
11
  export declare function gitPath(pathspec: string): Promise<string>;
12
12
  export declare function gitUserLabel(): Promise<string>;
13
+ export declare function blameEntity(input: Readonly<{
14
+ filePath: string;
15
+ entityName: string;
16
+ rev: string;
17
+ }>): Promise<BlameSummary | null>;
package/dist/git.js CHANGED
@@ -98,6 +98,23 @@ export async function gitUserLabel() {
98
98
  return `${name} <${email}>`;
99
99
  return name || email || "working tree";
100
100
  }
101
+ export async function blameEntity(input) {
102
+ try {
103
+ const { stdout } = await execFileAsync("git", [
104
+ "blame",
105
+ "--line-porcelain",
106
+ "-L",
107
+ `:${input.entityName}`,
108
+ input.rev,
109
+ "--",
110
+ input.filePath,
111
+ ], { maxBuffer: 16 * 1024 * 1024 });
112
+ return summarizeBlame(stdout);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
101
118
  async function netDiff(base, target, label, id) {
102
119
  const [text, stats, shortBase, shortTarget] = await Promise.all([
103
120
  diff(base, target),
@@ -115,11 +132,12 @@ async function netDiff(base, target, label, id) {
115
132
  };
116
133
  }
117
134
  async function sourceRange(base, target, label, id) {
118
- const [stats, shortBase, shortTarget, committers] = await Promise.all([
135
+ const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
119
136
  diffStats(base, target),
120
137
  shortCommit(base),
121
138
  shortCommit(target),
122
139
  committersForRange(base, target),
140
+ commitSubjectsForRange(base, target),
123
141
  ]);
124
142
  return {
125
143
  id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
@@ -127,6 +145,7 @@ async function sourceRange(base, target, label, id) {
127
145
  base,
128
146
  target,
129
147
  committers,
148
+ commitSubjects,
130
149
  stats,
131
150
  };
132
151
  }
@@ -150,6 +169,17 @@ async function committersForRange(base, target) {
150
169
  return [];
151
170
  }
152
171
  }
172
+ async function commitSubjectsForRange(base, target) {
173
+ try {
174
+ const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
175
+ maxBuffer: 4 * 1024 * 1024,
176
+ });
177
+ return uniqueLines(stdout);
178
+ }
179
+ catch {
180
+ return [];
181
+ }
182
+ }
153
183
  function uniqueLines(value) {
154
184
  const seen = new Set();
155
185
  const lines = [];
@@ -296,3 +326,43 @@ async function commitMessage(commit) {
296
326
  function firstLine(value) {
297
327
  return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
298
328
  }
329
+ function summarizeBlame(output) {
330
+ const entries = new Map();
331
+ let currentCommit = "";
332
+ let currentAuthor = "";
333
+ let currentSubject = "";
334
+ for (const line of output.split(/\r?\n/)) {
335
+ const header = /^([0-9a-f]{40})\s+/.exec(line);
336
+ if (header?.[1]) {
337
+ currentCommit = header[1];
338
+ currentAuthor = "";
339
+ currentSubject = "";
340
+ continue;
341
+ }
342
+ if (line.startsWith("author ")) {
343
+ currentAuthor = line.slice("author ".length).trim();
344
+ continue;
345
+ }
346
+ if (line.startsWith("summary ")) {
347
+ currentSubject = line.slice("summary ".length).trim();
348
+ continue;
349
+ }
350
+ if (!line.startsWith("\t") || !currentCommit)
351
+ continue;
352
+ const previous = entries.get(currentCommit);
353
+ entries.set(currentCommit, {
354
+ commit: currentCommit,
355
+ author: currentAuthor || previous?.author || "unknown author",
356
+ subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
357
+ count: (previous?.count ?? 0) + 1,
358
+ });
359
+ }
360
+ const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
361
+ if (!best)
362
+ return null;
363
+ return {
364
+ commit: best.commit.slice(0, 7),
365
+ author: best.author,
366
+ subject: best.subject,
367
+ };
368
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  import type { HookAction } from "./types.ts";
2
+ import type { CliUi } from "./ui.ts";
2
3
  export declare function runHookCommand(action: HookAction): Promise<string>;
4
+ export declare function renderHookResultToUi(result: string, ui: CliUi): void;
3
5
  export declare function hookSnippet(): string;
package/dist/hooks.js CHANGED
@@ -14,6 +14,24 @@ export async function runHookCommand(action) {
14
14
  return installHook();
15
15
  return uninstallHook();
16
16
  }
17
+ export function renderHookResultToUi(result, ui) {
18
+ const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
19
+ if (firstLine.includes("not installed")) {
20
+ ui.info(firstLine);
21
+ }
22
+ else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
23
+ ui.success(firstLine);
24
+ }
25
+ else if (firstLine.includes("existing non-Stupify")) {
26
+ ui.warn(firstLine);
27
+ }
28
+ else {
29
+ ui.info(firstLine);
30
+ }
31
+ const detail = rest.join("\n").trim();
32
+ if (detail)
33
+ ui.note(detail, "Hook");
34
+ }
17
35
  export function hookSnippet() {
18
36
  return managedBlock("stupify --staged");
19
37
  }
package/dist/render.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  import type { SearchCommand, SearchRunJson } from "./types.ts";
2
+ import { type CliUi } from "./ui.ts";
2
3
  export declare function renderSearchRun(run: SearchRunJson, command: SearchCommand): string;
4
+ export declare function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void;
5
+ export declare function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string;
3
6
  export declare function helpText(): string;
package/dist/render.js CHANGED
@@ -3,16 +3,43 @@ import { format } from "./ui.js";
3
3
  export function renderSearchRun(run, command) {
4
4
  if (command.json)
5
5
  return JSON.stringify(run, null, 2);
6
+ return renderSearchHumanText(run, command);
7
+ }
8
+ export function renderSearchRunToUi(run, command, ui) {
9
+ if (command.json) {
10
+ ui.writeStdout(renderSearchRun(run, command));
11
+ return;
12
+ }
13
+ if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
14
+ ui.warn("Search skipped: input is too large for precise local search.");
15
+ ui.note(oversizedText(run, command), "Skipped");
16
+ ui.outro("Warn-only. Nothing blocked.");
17
+ return;
18
+ }
19
+ if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
20
+ ui.success("Search complete: no search targets found.");
21
+ ui.note(cleanSummaryText(run), "Summary");
22
+ ui.outro("No judgment-offload signals found.");
23
+ return;
24
+ }
25
+ if (run.matches.length === 0) {
26
+ ui.success("Search complete: no judgment-offload signals found.");
27
+ ui.note(cleanSummaryText(run), "Summary");
28
+ ui.outro("Warn-only. Nothing blocked.");
29
+ return;
30
+ }
31
+ ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
32
+ ui.note(matchSummaryText(run, command), "Summary");
33
+ for (const group of groupMatchesByFile(run.matches)) {
34
+ ui.note(renderMatchGroup(group, run), group.filePath);
35
+ }
36
+ ui.outro(summaryLine(run));
37
+ }
38
+ export function renderSearchHumanText(run, command) {
6
39
  if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
7
- return `${format.heading("Search input is too large for precise local search.")}
8
- ${format.heading("Size:")}
9
- ~${run.stats.inputTokens ?? "unknown"} tokens
10
- ${format.heading("Limit:")}
11
- ${run.stats.inputTokenCap ?? "unknown"} tokens
12
- Stupify skipped the search rather than review truncated context.
13
- Nothing was blocked.
14
- ${format.heading("Try:")}
15
- rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
40
+ return `${format.heading("Search skipped")}
41
+ ${oversizedText(run, command)}
42
+ Warn-only. Nothing blocked.`;
16
43
  }
17
44
  if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
18
45
  return `${format.heading("Search complete.")}
@@ -24,18 +51,12 @@ ${format.success("No search targets found.")}`;
24
51
  ${format.label("Patterns:")} ${run.patterns.join(", ")}
25
52
  ${format.success("No judgment-offload signals found.")}`;
26
53
  }
54
+ const groups = groupMatchesByFile(run.matches);
27
55
  return `${slopHeading()}
28
- ${committerLabel(run)} (${sourceLabel(command)})
29
-
30
- ${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
31
- ${match.reason}
32
-
33
- \`\`\`
34
- ${match.snapshot ?? match.proof}
35
- \`\`\`
36
- ${format.muted(match.proof)}
56
+ ${matchSummaryText(run, command)}
37
57
 
38
- ${match.checkWhy ?? "This pattern may indicate judgment-offload."}`).join("\n\n")}
58
+ ${groups.map((group) => `${format.heading(group.filePath)}
59
+ ${renderMatchGroup(group, run)}`).join("\n\n")}
39
60
  ${format.muted(summaryLine(run))}`;
40
61
  }
41
62
  export function helpText() {
@@ -85,6 +106,127 @@ Not included:
85
106
  Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
86
107
  `;
87
108
  }
109
+ function oversizedText(run, command) {
110
+ const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
111
+ return [
112
+ `Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
113
+ `Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
114
+ "Stupify skipped the search rather than review truncated context.",
115
+ `Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
116
+ ].join("\n");
117
+ }
118
+ function cleanSummaryText(run) {
119
+ return [
120
+ `Patterns: ${run.patterns.join(", ")}`,
121
+ run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
122
+ ].filter(Boolean).join("\n");
123
+ }
124
+ function matchSummaryText(run, command) {
125
+ const fileCount = groupMatchesByFile(run.matches).length;
126
+ const fileNoun = fileCount === 1 ? "file" : "files";
127
+ return [
128
+ `${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
129
+ `${committerLabel(run)} · ${sourceLabel(command)}`,
130
+ "Warn-only. Nothing blocked.",
131
+ "",
132
+ patternSummaryLine(run),
133
+ ].filter((line) => line !== null).join("\n");
134
+ }
135
+ function patternSummaryLine(run) {
136
+ const counts = new Map();
137
+ for (const match of run.matches)
138
+ counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
139
+ return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
140
+ }
141
+ function groupMatchesByFile(matches) {
142
+ const groups = new Map();
143
+ for (const match of matches) {
144
+ const filePath = proofFilePath(match.proof);
145
+ const group = groups.get(filePath) ?? [];
146
+ group.push(match);
147
+ groups.set(filePath, group);
148
+ }
149
+ return [...groups.entries()].map(([filePath, groupedMatches]) => ({
150
+ filePath,
151
+ matches: groupedMatches,
152
+ }));
153
+ }
154
+ function renderMatchGroup(group, run) {
155
+ return group.matches.map((match, index) => {
156
+ const lines = [
157
+ matchHeadline(match, run, index),
158
+ match.reason,
159
+ match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
160
+ format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
161
+ match.checkWhy ?? "This pattern may indicate judgment-offload.",
162
+ ];
163
+ return lines.filter(Boolean).join("\n");
164
+ }).join("\n\n");
165
+ }
166
+ function matchHeadline(match, run, index) {
167
+ return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
168
+ }
169
+ function patternLabel(match) {
170
+ return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
171
+ }
172
+ function headlineArgs(match) {
173
+ const destination = entityNameFromProof(match.proof);
174
+ const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
175
+ if (source && destination && source !== destination)
176
+ return `${codeLabel(source)} -> ${codeLabel(destination)}`;
177
+ if (destination)
178
+ return codeLabel(destination);
179
+ return codeLabel(match.targetId);
180
+ }
181
+ function matchBlameLabel(match, run) {
182
+ return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
183
+ }
184
+ function blameSummaryLabel(blame) {
185
+ return `${blame.author} (${blame.subject})`;
186
+ }
187
+ function runLevelBlameLabel(run) {
188
+ const author = committerLabel(run);
189
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
190
+ return subject ? `${author} (${subject})` : author;
191
+ }
192
+ function commitSubjectSuffix(run) {
193
+ const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
194
+ return subject ? ` · commit: ${subject}` : "";
195
+ }
196
+ function firstHumanSubject(subjects) {
197
+ return subjects.map((subject) => subject.trim()).find(Boolean);
198
+ }
199
+ function codeLabel(value) {
200
+ return `\`${value}\``;
201
+ }
202
+ function titleCase(value) {
203
+ return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
204
+ }
205
+ function entityNameFromProof(proof) {
206
+ const parts = proof.split("::");
207
+ return parts[2] || parts[1] || undefined;
208
+ }
209
+ function firstBacktickedToken(value) {
210
+ const match = /`([^`]+)`/.exec(value);
211
+ return cleanToken(match?.[1]);
212
+ }
213
+ function firstLikelySource(value, destination) {
214
+ const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
215
+ .map((match) => cleanToken(match[0]))
216
+ .filter((token) => Boolean(token));
217
+ return tokens.find((token) => token !== destination && token !== "The");
218
+ }
219
+ function cleanToken(value) {
220
+ const token = value?.trim().replace(/[.,;:]+$/, "");
221
+ return token || undefined;
222
+ }
223
+ function proofFilePath(proof) {
224
+ return proof.split("::")[0] || proof;
225
+ }
226
+ function proofDetail(proof) {
227
+ const [, ...rest] = proof.split("::");
228
+ return rest.length > 0 ? `::${rest.join("::")}` : proof;
229
+ }
88
230
  function sourceHint(command) {
89
231
  if (command.kind === "staged")
90
232
  return "--staged";
@@ -146,6 +288,8 @@ function sinceLabel(since) {
146
288
  return `last ${count} ${unit}s`;
147
289
  }
148
290
  function summaryLine(run) {
149
- const noun = run.matches.length === 1 ? "signal" : "signals";
150
- return `${run.matches.length} ${noun}. Warn-only. Nothing blocked.`;
291
+ return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
292
+ }
293
+ function signalNoun(count) {
294
+ return count === 1 ? "signal" : "signals";
151
295
  }
@@ -36,6 +36,7 @@ function emptyChangeSet(label, stats, committers) {
36
36
  base: label,
37
37
  target: label,
38
38
  committers,
39
+ commitSubjects: undefined,
39
40
  contextCwd: process.cwd(),
40
41
  cleanup: async () => undefined,
41
42
  changes: [],
@@ -75,6 +76,7 @@ async function semChangeSetFromPatch(patch, debugSem, label = "stdin", committer
75
76
  base: label,
76
77
  target: label,
77
78
  committers,
79
+ commitSubjects: undefined,
78
80
  stats: { filesChanged: 0, additions: 0, deletions: 0 },
79
81
  }),
80
82
  contextCwd: process.cwd(),
@@ -199,6 +201,7 @@ function normalizeSemDiff(value, range) {
199
201
  base: range.base,
200
202
  target: range.target,
201
203
  committers: range.committers,
204
+ commitSubjects: range.commitSubjects,
202
205
  contextCwd: process.cwd(),
203
206
  cleanup: async () => undefined,
204
207
  changes,