@stupify/cli 0.0.1 → 0.0.3

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.
Files changed (63) hide show
  1. package/README.md +60 -0
  2. package/dist/analysis.d.ts +14 -0
  3. package/dist/analysis.js +276 -0
  4. package/dist/batcher.d.ts +3 -0
  5. package/dist/batcher.js +142 -0
  6. package/dist/cache.d.ts +2 -0
  7. package/dist/cache.js +59 -0
  8. package/dist/candidate-context.d.ts +2 -0
  9. package/dist/candidate-context.js +40 -0
  10. package/dist/checks.d.ts +3 -0
  11. package/dist/checks.js +131 -0
  12. package/dist/command.d.ts +2 -0
  13. package/dist/command.js +183 -0
  14. package/dist/constants.d.ts +4 -0
  15. package/dist/constants.js +53 -0
  16. package/dist/counter-scout.d.ts +14 -0
  17. package/dist/counter-scout.js +97 -0
  18. package/dist/diff.d.ts +1 -0
  19. package/dist/diff.js +10 -0
  20. package/dist/experiment.d.ts +1 -0
  21. package/dist/experiment.js +225 -0
  22. package/dist/git.d.ts +8 -0
  23. package/dist/git.js +219 -0
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.js +1 -0
  26. package/dist/model.d.ts +24 -0
  27. package/dist/model.js +281 -0
  28. package/dist/prompts.d.ts +5 -0
  29. package/dist/prompts.js +197 -0
  30. package/dist/render.d.ts +3 -0
  31. package/dist/render.js +101 -0
  32. package/dist/repomix-provider.d.ts +4 -0
  33. package/dist/repomix-provider.js +145 -0
  34. package/dist/sem-provider.d.ts +2 -0
  35. package/dist/sem-provider.js +221 -0
  36. package/dist/stupify.d.ts +2 -0
  37. package/dist/stupify.js +387 -0
  38. package/dist/trace.d.ts +29 -0
  39. package/dist/trace.js +64 -0
  40. package/dist/types.d.ts +236 -0
  41. package/dist/types.js +6 -0
  42. package/package.json +42 -5
  43. package/src/analysis.ts +408 -0
  44. package/src/batcher.ts +198 -0
  45. package/src/cache.ts +65 -0
  46. package/src/candidate-context.ts +43 -0
  47. package/src/checks.ts +132 -0
  48. package/src/command.ts +218 -0
  49. package/src/constants.ts +56 -0
  50. package/src/counter-scout.ts +119 -0
  51. package/src/diff.ts +9 -0
  52. package/src/experiment.ts +317 -0
  53. package/src/git.ts +228 -0
  54. package/src/index.ts +1 -0
  55. package/src/model.ts +360 -0
  56. package/src/prompts.ts +234 -0
  57. package/src/render.ts +107 -0
  58. package/src/repomix-provider.ts +163 -0
  59. package/src/sem-provider.ts +255 -0
  60. package/src/stupify.ts +598 -0
  61. package/src/trace.ts +103 -0
  62. package/src/types.ts +264 -0
  63. package/bin/stupify.mjs +0 -3
package/src/prompts.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type {
2
+ AuditPromptName,
3
+ CandidateContext,
4
+ DiffBatch,
5
+ SemChangeSet,
6
+ SemContext,
7
+ SemContextPack,
8
+ StupifyCheck,
9
+ } from "./types.ts";
10
+
11
+ export function scoutPrompt(batch: DiffBatch, checks: readonly StupifyCheck[], sourceLabel: string): string {
12
+ return `Pick diff hunks that match enabled checks.
13
+ Return JSON only:
14
+ { "candidates": ["exact POINTER"] }
15
+
16
+ Rules:
17
+ - Use POINTER values exactly as shown.
18
+ - Return at most 3 candidates.
19
+ - Return { "candidates": [] } if clean.
20
+ - Pick definitions over usage sites.
21
+
22
+ ${formatCompactChecks(checks)}
23
+
24
+ SOURCE:
25
+ ${sourceLabel}
26
+
27
+ DIFF BATCH ${batch.id}:
28
+ ${batch.text}`;
29
+ }
30
+
31
+ export function auditPrompt(
32
+ contexts: readonly CandidateContext[],
33
+ checks: readonly StupifyCheck[],
34
+ sourceLabel: string,
35
+ ): string {
36
+ return `Audit candidate diff regions against enabled checks.
37
+ Return JSON only:
38
+ {
39
+ "findings": [{ "checkId": "check_id", "why": "one sentence", "proof": "exact POINTER" }],
40
+ "summary": "one short sentence"
41
+ }
42
+
43
+ Rules:
44
+ - Use only checks listed below.
45
+ - checkId must be a check ID, never a POINTER.
46
+ - proof must be one exact POINTER from candidate regions.
47
+ - why describes the suspicious structure, not an identifier.
48
+ - Do not describe an issue in summary unless it is also in findings.
49
+ - If no findings, return { "findings": [], "summary": "No clear judgment-offload signal found." }.
50
+
51
+ Allowed proof pointers:
52
+ ${contexts.map((context) => `- ${context.pointer}`).join("\n")}
53
+
54
+ ${formatFullChecks(checks)}
55
+
56
+ SOURCE:
57
+ ${sourceLabel}
58
+
59
+ CANDIDATE REGIONS:
60
+ ${contexts.map(formatContext).join("\n\n")}`;
61
+ }
62
+
63
+ export function semScoutPrompt(
64
+ changeSet: SemChangeSet,
65
+ checks: readonly StupifyCheck[],
66
+ maxCandidates: number,
67
+ ): string {
68
+ return `Pick changed entity/check targets worth auditing.
69
+ Return JSON only:
70
+ { "targets": [{ "entityId": "exact entityId", "checkId": "check_id", "reason": "short scout reason" }] }
71
+
72
+ Rules:
73
+ - Use entityId values exactly as shown.
74
+ - Each target has exactly one checkId.
75
+ - Return at most ${maxCandidates} targets.
76
+ - Return { "targets": [] } if clean.
77
+ - Pick definitions over usage sites.
78
+ - Prefer high recall, but do not attach unrelated checks.
79
+
80
+ ${formatCompactChecks(checks)}
81
+
82
+ SOURCE:
83
+ ${changeSet.label}
84
+
85
+ SEM CHANGE SUMMARY:
86
+ ${JSON.stringify(changeSet.summary, null, 2)}
87
+
88
+ SEM ENTITY CHANGES:
89
+ ${changeSet.changes.map(formatSemChange).join("\n\n")}`;
90
+ }
91
+
92
+ export function findingsAuditPrompt(
93
+ contexts: readonly SemContext[],
94
+ pack: SemContextPack,
95
+ checks: readonly StupifyCheck[],
96
+ sourceLabel: string,
97
+ promptName: AuditPromptName,
98
+ ): string {
99
+ const task =
100
+ promptName === "high_bar"
101
+ ? `You are Stupify's audit model.
102
+ You are reviewing candidate/check targets for signs that AI-assisted coding may have replaced engineering judgment.
103
+ Only emit a finding if it is clearly useful to a developer.
104
+ A useful finding must:
105
+ - match the target's check exactly
106
+ - point to a concrete change pattern
107
+ - explain why the change may reflect judgment-offload
108
+ - avoid generic code-review commentary
109
+ If the target is normal engineering work, omit it.
110
+ If the target is merely plausible but not strong, omit it.
111
+ If the target does not exactly match its assigned check, omit it.`
112
+ : `You are Stupify's auditor.
113
+ Audit only the listed target/check pairs.
114
+ Emit only exceptions.`;
115
+
116
+ const highBarRules =
117
+ promptName === "high_bar"
118
+ ? `- Prefer clean over weak.
119
+ - Prefer no finding over generic finding.
120
+ - Do not emit style feedback unless the assigned check is truly about style.
121
+ - Do not turn functional refactors into style mismatch findings.`
122
+ : "";
123
+
124
+ return `${task}
125
+ Return JSON only:
126
+ {
127
+ "findings": [
128
+ {
129
+ "targetId": "t001",
130
+ "why": "one sentence",
131
+ "proof": "short pointer"
132
+ }
133
+ ],
134
+ "uncertain": [
135
+ {
136
+ "targetId": "t002",
137
+ "why": "one sentence"
138
+ }
139
+ ]
140
+ }
141
+
142
+ Rules:
143
+ - Inspect every target.
144
+ - Each target has exactly one check.
145
+ - Emit a finding only when the target clearly matches its check.
146
+ - Emit uncertain only when the target may match, but evidence is insufficient.
147
+ - If a target is clean, emit nothing for it.
148
+ - Omitted target means clean.
149
+ - Do not output clean reviews.
150
+ - Do not explain clean targets.
151
+ - Do not write "no evidence" as a finding.
152
+ - Do not put negative statements in findings.
153
+ - Prefer omission over weak findings.
154
+ - Use only provided targetIds.
155
+ - Do not search for other checks.
156
+ - Do not quote source code.
157
+ - Use packed file context only as supporting evidence for these candidate entities.
158
+ ${highBarRules}
159
+
160
+ Targets:
161
+ ${contexts.map((context) => formatAuditTarget(context, checks)).join("\n\n")}
162
+
163
+ SOURCE:
164
+ ${sourceLabel}
165
+
166
+ CANDIDATE ENTITY DELTAS:
167
+ ${contexts.map(formatSemContext).join("\n\n")}
168
+
169
+ PACKED FILE CONTEXT (${pack.provider}, ${pack.filePaths.length} files, ${pack.totalTokens} tokens):
170
+ ${pack.text || "(none)"}`;
171
+ }
172
+
173
+ function formatCompactChecks(checks: readonly StupifyCheck[]): string {
174
+ return `Checks:
175
+ ${checks.map((check) => `- ${check.id}: ${check.lookFor.join("; ")}`).join("\n")}`;
176
+ }
177
+
178
+ function formatFullChecks(checks: readonly StupifyCheck[]): string {
179
+ return checks.map(formatCheck).join("\n\n");
180
+ }
181
+
182
+ function formatCheck(check: StupifyCheck): string {
183
+ return `# ${check.name}
184
+ ID: ${check.id}
185
+ Q: ${check.question}
186
+ Look for:
187
+ ${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
188
+ Ignore when:
189
+ ${check.ignoreWhen.map((signal) => `- ${signal}`).join("\n")}
190
+ Match examples:
191
+ ${(check.examples?.match ?? []).map((example) => `- ${example}`).join("\n")}
192
+ No-match examples:
193
+ ${(check.examples?.noMatch ?? []).map((example) => `- ${example}`).join("\n")}`;
194
+ }
195
+
196
+ function formatContext(context: CandidateContext): string {
197
+ return `POINTER ${context.pointer}
198
+ ${context.text}`;
199
+ }
200
+
201
+ function formatSemChange(change: SemChangeSet["changes"][number]): string {
202
+ return `ENTITY ${change.entityId}
203
+ TYPE ${change.entityType}
204
+ CHANGE ${change.changeType}
205
+ PATH ${change.filePath}`;
206
+ }
207
+
208
+ function formatSemContext(context: SemContext): string {
209
+ return `TARGET ${context.targetId}
210
+ ENTITY ${context.entityId}
211
+ NAME ${context.entityName}
212
+ KIND ${context.entityKind}
213
+ CHANGE ${context.changeKind}
214
+ CHECK ${context.checkId}
215
+ SCOUT_REASON ${context.reason}
216
+ CONTEXT:
217
+ ${context.text}`;
218
+ }
219
+
220
+ function formatAuditTarget(context: SemContext, checks: readonly StupifyCheck[]): string {
221
+ const check = checks.find((item) => item.id === context.checkId);
222
+ return `- targetId=${context.targetId} checkId=${context.checkId} entityId=${context.entityId}
223
+ scoutReason=${context.reason}
224
+ ${check ? formatCheck(check) : ""}`;
225
+ }
226
+
227
+ function shortenCode(value: string | null): string {
228
+ if (!value) return "(none)";
229
+ const lines = value.split(/\r?\n/);
230
+ const limit = 80;
231
+ if (lines.length <= limit) return value;
232
+ return `${lines.slice(0, limit).join("\n")}
233
+ [stupify: sem entity content shortened after ${limit} lines]`;
234
+ }
package/src/render.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { VERSION } from "./constants.ts";
2
+ import type { AnalysisReport, AnalyzeCommand } from "./types.ts";
3
+
4
+ export function renderReport(report: AnalysisReport, command: AnalyzeCommand): string {
5
+ if (command.json) {
6
+ return JSON.stringify({
7
+ schemaVersion: "0.4",
8
+ model: { id: report.run.modelId },
9
+ checks: report.run.checkIds,
10
+ run: report.run,
11
+ findings: report.result.findings,
12
+ summary: report.result.summary,
13
+ }, null, 2);
14
+ }
15
+
16
+ if (report.run.engine === "sem") {
17
+ return `Search:
18
+ ${report.run.entitiesScanned} entities scanned
19
+ ${report.run.candidateCount} candidate entities found
20
+ Audit:
21
+ ${report.run.auditedCandidateCount} candidates inspected
22
+ ${report.result.findings.length} findings
23
+ ${renderAuditStats(report)}
24
+ ${renderWarnings(report)}
25
+ Findings:
26
+ ${renderFindings(report)}
27
+ Timing:
28
+ total_ms=${report.run.timingsMs.total} entity_diff_ms=${report.run.timingsMs.diff} model_ms=${report.run.timingsMs.modelLoad} scout_ms=${report.run.timingsMs.search} context_audit_ms=${report.run.timingsMs.audit}`;
29
+ }
30
+
31
+ return `Search:
32
+ ${report.run.batchesScanned} batches scanned
33
+ ${report.run.candidateCount} candidate regions found
34
+ Audit:
35
+ ${report.run.auditedCandidateCount} candidates inspected
36
+ ${report.result.findings.length} findings
37
+ ${renderWarnings(report)}
38
+ Findings:
39
+ ${renderFindings(report)}
40
+ Timing:
41
+ total_ms=${report.run.timingsMs.total} diff_ms=${report.run.timingsMs.diff} model_ms=${report.run.timingsMs.modelLoad} search_ms=${report.run.timingsMs.search} audit_ms=${report.run.timingsMs.audit}`;
42
+ }
43
+
44
+ export function helpText(): string {
45
+ return `Stupify ${VERSION}
46
+
47
+ Usage:
48
+ stupify
49
+ stupify --since "2 weeks ago"
50
+ stupify --commit <commit>
51
+ stupify --commits <count>
52
+ stupify experiment <config.json>
53
+ git diff HEAD~1..HEAD | stupify --stdin
54
+
55
+ Options:
56
+ --since <date> Analyze the net diff from the first commit before this git date to HEAD.
57
+ --commit <commit> Analyze one commit as a net diff.
58
+ --commits <count> Analyze the net diff across the last N non-merge commits.
59
+ --stdin Read a git diff from stdin.
60
+ --engine <engine> raw-diff or sem. Default: raw-diff.
61
+ --scout <mode> llm or counter for --engine sem. Default: counter.
62
+ --audit-context <mode>
63
+ none or repomix for --engine sem. Default: repomix.
64
+ --audit-prompt <name> strict or high_bar for --engine sem. Default: high_bar.
65
+ --debug-sem Print sem commands and stderr.
66
+ --debug-targets Include audited sem target details in JSON output.
67
+ --max-candidates <n> Max semantic candidates for --engine sem. Default: 10.
68
+ --audit-batch-size <n>
69
+ Semantic candidates per audit model call. Default: 25.
70
+ --max-audit-input-tokens <n>
71
+ Max findings-audit input tokens before splitting. Default: 20000.
72
+ --audit-concurrency <n>
73
+ Parallel findings-audit model calls. Default: 2.
74
+ --checks <ids> Comma-separated check ids.
75
+ --model <id> gemma-4-e2b, gemma-4-e4b, gemma-4-26b-a4b, qwen3-4b-magicquant, qwen2.5-coder-1.5b, qwen2.5-coder-7b, or qwen2.5-coder-32b.
76
+ --json Print JSON only.
77
+
78
+ Default:
79
+ stupify is equivalent to stupify --since "2 weeks ago".
80
+
81
+ Not included:
82
+ Baselines, sharing, hosted server calls, Ollama, GitHub, dashboards, or repo-wide scanning.
83
+ `;
84
+ }
85
+
86
+ function renderFindings(report: AnalysisReport): string {
87
+ if (report.result.findings.length === 0) return " None.";
88
+
89
+ return report.result.findings
90
+ .map((finding) => `- ${finding.checkId}
91
+ ${finding.why}
92
+ Proof: ${finding.proof}`)
93
+ .join("\n");
94
+ }
95
+
96
+ function renderWarnings(report: AnalysisReport): string {
97
+ if (report.run.warnings.length === 0) return "";
98
+ return `Warnings:
99
+ ${report.run.warnings.map((warning) => ` ${warning}`).join("\n")}
100
+ `;
101
+ }
102
+
103
+ function renderAuditStats(report: AnalysisReport): string {
104
+ const stats = report.run.auditStats;
105
+ if (!stats) return "";
106
+ return ` ${stats.totalTargets} targets reviewed: ${stats.finding} finding, ${stats.uncertain} uncertain, ${stats.clean} clean, ${stats.invalid} invalid`;
107
+ }
@@ -0,0 +1,163 @@
1
+ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { pack, setLogLevel } from "repomix";
5
+ import type { SemCandidate, SemChange, SemContext, SemContextPack } from "./types.ts";
6
+
7
+ const MAX_PACK_FILE_SIZE_BYTES = 48 * 1024;
8
+ const MAX_PACK_TOTAL_SIZE_BYTES = 128 * 1024;
9
+
10
+ export function emptyContextPack(): SemContextPack {
11
+ return {
12
+ provider: "repomix",
13
+ filePaths: [],
14
+ totalCharacters: 0,
15
+ totalTokens: 0,
16
+ text: "",
17
+ };
18
+ }
19
+
20
+ export async function repomixContextPack(
21
+ cwd: string,
22
+ contexts: readonly SemContext[],
23
+ changes: readonly SemChange[],
24
+ ): Promise<SemContextPack> {
25
+ const filePaths = await candidateFilePaths(cwd, contexts, changes);
26
+ if (filePaths.length === 0) {
27
+ return emptyContextPack();
28
+ }
29
+
30
+ setLogLevel(-1);
31
+ const tempDir = await mkdtemp(path.join(tmpdir(), "stupify-repomix-"));
32
+ const outputPath = path.join(tempDir, "context.xml");
33
+ try {
34
+ const result = await pack(
35
+ [cwd],
36
+ {
37
+ cwd,
38
+ input: { maxFileSize: MAX_PACK_FILE_SIZE_BYTES },
39
+ output: {
40
+ filePath: outputPath,
41
+ style: "xml",
42
+ parsableStyle: false,
43
+ fileSummary: false,
44
+ directoryStructure: false,
45
+ files: true,
46
+ removeComments: false,
47
+ removeEmptyLines: true,
48
+ compress: true,
49
+ topFilesLength: 0,
50
+ showLineNumbers: true,
51
+ truncateBase64: true,
52
+ copyToClipboard: false,
53
+ includeFullDirectoryStructure: false,
54
+ tokenCountTree: false,
55
+ git: {
56
+ sortByChanges: false,
57
+ sortByChangesMaxCommits: 1,
58
+ includeDiffs: false,
59
+ includeLogs: false,
60
+ includeLogsCount: 1,
61
+ },
62
+ },
63
+ include: [],
64
+ ignore: {
65
+ useGitignore: true,
66
+ useDotIgnore: true,
67
+ useDefaultPatterns: true,
68
+ customPatterns: [],
69
+ },
70
+ security: { enableSecurityCheck: false },
71
+ tokenCount: { encoding: "o200k_base" },
72
+ } satisfies Parameters<typeof pack>[1],
73
+ () => undefined,
74
+ {},
75
+ [...filePaths],
76
+ );
77
+ return {
78
+ provider: "repomix",
79
+ filePaths,
80
+ totalCharacters: result.totalCharacters,
81
+ totalTokens: result.totalTokens,
82
+ text: await readFile(outputPath, "utf8"),
83
+ };
84
+ } finally {
85
+ await rm(tempDir, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ export function entityContextsFromChanges(
90
+ candidates: readonly SemCandidate[],
91
+ changes: readonly SemChange[],
92
+ ): readonly SemContext[] {
93
+ const byEntityId = new Map(changes.map((change) => [change.entityId, change]));
94
+ return candidates.flatMap((candidate): readonly SemContext[] => {
95
+ const change = byEntityId.get(candidate.entityId);
96
+ if (!change) return [];
97
+ return [{
98
+ targetId: candidate.targetId,
99
+ entityId: change.entityId,
100
+ entityName: change.entityName,
101
+ entityKind: change.entityType,
102
+ changeKind: change.changeType,
103
+ checkId: candidate.checkId,
104
+ reason: candidate.reason,
105
+ filePath: change.filePath,
106
+ text: JSON.stringify({
107
+ source: "sem diff",
108
+ file: change.filePath,
109
+ type: change.entityType,
110
+ name: change.entityName,
111
+ change: change.changeType,
112
+ before: shortenCode(change.beforeContent),
113
+ after: shortenCode(change.afterContent),
114
+ }, null, 2),
115
+ }];
116
+ });
117
+ }
118
+
119
+ async function candidateFilePaths(
120
+ cwd: string,
121
+ contexts: readonly SemContext[],
122
+ changes: readonly SemChange[],
123
+ ): Promise<readonly string[]> {
124
+ const byEntityId = new Map(changes.map((change) => [change.entityId, change.filePath]));
125
+ const paths = contexts.flatMap((context) => context.filePath ?? byEntityId.get(context.entityId) ?? []);
126
+ const safePaths = [...new Set(paths)].filter(isSafeRelativeFilePath);
127
+ const selected = [];
128
+ let totalBytes = 0;
129
+ for (const filePath of safePaths) {
130
+ const bytes = await fileSize(cwd, filePath);
131
+ if (bytes === null || bytes > MAX_PACK_FILE_SIZE_BYTES) continue;
132
+ if (totalBytes + bytes > MAX_PACK_TOTAL_SIZE_BYTES) continue;
133
+ totalBytes += bytes;
134
+ selected.push(filePath);
135
+ }
136
+ return selected;
137
+ }
138
+
139
+ function isSafeRelativeFilePath(value: string): boolean {
140
+ if (!value || path.isAbsolute(value)) return false;
141
+ const normalized = path.normalize(value);
142
+ return normalized !== "." && !normalized.startsWith("..") && !path.isAbsolute(normalized);
143
+ }
144
+
145
+ async function fileSize(cwd: string, filePath: string): Promise<number | null> {
146
+ try {
147
+ const fullPath = path.join(cwd, filePath);
148
+ if (!fullPath.startsWith(`${cwd}${path.sep}`)) return null;
149
+ const result = await stat(fullPath);
150
+ return result.isFile() ? result.size : null;
151
+ } catch {
152
+ return null;
153
+ }
154
+ }
155
+
156
+ function shortenCode(value: string | null): string {
157
+ if (!value) return "(none)";
158
+ const lines = value.split(/\r?\n/);
159
+ const limit = 120;
160
+ if (lines.length <= limit) return value;
161
+ return `${lines.slice(0, limit).join("\n")}
162
+ [stupify: sem entity content shortened after ${limit} lines]`;
163
+ }