@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.
- package/README.md +60 -0
- package/dist/analysis.d.ts +14 -0
- package/dist/analysis.js +276 -0
- package/dist/batcher.d.ts +3 -0
- package/dist/batcher.js +142 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.js +59 -0
- package/dist/candidate-context.d.ts +2 -0
- package/dist/candidate-context.js +40 -0
- package/dist/checks.d.ts +3 -0
- package/dist/checks.js +131 -0
- package/dist/command.d.ts +2 -0
- package/dist/command.js +183 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +53 -0
- package/dist/counter-scout.d.ts +14 -0
- package/dist/counter-scout.js +97 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +10 -0
- package/dist/experiment.d.ts +1 -0
- package/dist/experiment.js +225 -0
- package/dist/git.d.ts +8 -0
- package/dist/git.js +219 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +24 -0
- package/dist/model.js +281 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +197 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +101 -0
- package/dist/repomix-provider.d.ts +4 -0
- package/dist/repomix-provider.js +145 -0
- package/dist/sem-provider.d.ts +2 -0
- package/dist/sem-provider.js +221 -0
- package/dist/stupify.d.ts +2 -0
- package/dist/stupify.js +387 -0
- package/dist/trace.d.ts +29 -0
- package/dist/trace.js +64 -0
- package/dist/types.d.ts +236 -0
- package/dist/types.js +6 -0
- package/package.json +42 -5
- package/src/analysis.ts +408 -0
- package/src/batcher.ts +198 -0
- package/src/cache.ts +65 -0
- package/src/candidate-context.ts +43 -0
- package/src/checks.ts +132 -0
- package/src/command.ts +218 -0
- package/src/constants.ts +56 -0
- package/src/counter-scout.ts +119 -0
- package/src/diff.ts +9 -0
- package/src/experiment.ts +317 -0
- package/src/git.ts +228 -0
- package/src/index.ts +1 -0
- package/src/model.ts +360 -0
- package/src/prompts.ts +234 -0
- package/src/render.ts +107 -0
- package/src/repomix-provider.ts +163 -0
- package/src/sem-provider.ts +255 -0
- package/src/stupify.ts +598 -0
- package/src/trace.ts +103 -0
- package/src/types.ts +264 -0
- package/bin/stupify.mjs +0 -3
package/dist/model.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir, platform } from "node:os";
|
|
5
|
+
import { stdin as input, stderr as statusOutput, stdout as output, } from "node:process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { MODEL_REGISTRY } from "./constants.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const LLAMA_SERVER_HOST = "127.0.0.1";
|
|
12
|
+
export async function loadLocalModels(modelId) {
|
|
13
|
+
const modelPath = await firstRunModelBootstrap(modelId);
|
|
14
|
+
const scoutModel = await loadLocalModel(modelPath, modelId, "scout");
|
|
15
|
+
const auditModel = await loadLocalModel(modelPath, modelId, "audit");
|
|
16
|
+
return { scoutModel, auditModel };
|
|
17
|
+
}
|
|
18
|
+
export async function firstRunModelBootstrap(modelId) {
|
|
19
|
+
const selectedModel = MODEL_REGISTRY[modelId];
|
|
20
|
+
const modelDir = path.join(cacheDir(), "models");
|
|
21
|
+
const modelPath = path.join(modelDir, selectedModel.file);
|
|
22
|
+
if (await exists(modelPath))
|
|
23
|
+
return modelPath;
|
|
24
|
+
console.error(`No local Stupify model found.
|
|
25
|
+
Stupify runs locally.
|
|
26
|
+
Download this model now?
|
|
27
|
+
Model: ${selectedModel.name}
|
|
28
|
+
Size: ${selectedModel.size}`);
|
|
29
|
+
if (!(await confirm("Continue? y/N ")))
|
|
30
|
+
throw new Error("Setup cancelled.");
|
|
31
|
+
await mkdir(modelDir, { recursive: true });
|
|
32
|
+
await downloadModel(modelPath, selectedModel.url);
|
|
33
|
+
if (!(await exists(modelPath)))
|
|
34
|
+
throw new Error("Model download failed: file was not created.");
|
|
35
|
+
return modelPath;
|
|
36
|
+
}
|
|
37
|
+
export async function loadLocalModel(modelPath, modelId, profile = "scout") {
|
|
38
|
+
const selectedModel = MODEL_REGISTRY[modelId];
|
|
39
|
+
const runtime = modelRuntime(profile);
|
|
40
|
+
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
41
|
+
if (runningModel) {
|
|
42
|
+
if (runningModel !== modelId)
|
|
43
|
+
await stopManagedServer(runtime);
|
|
44
|
+
if (runningModel === modelId) {
|
|
45
|
+
console.error(`Using already-loaded local ${profile} model: ${selectedModel.name}`);
|
|
46
|
+
return {
|
|
47
|
+
id: modelId,
|
|
48
|
+
name: selectedModel.name,
|
|
49
|
+
baseUrl: runtime.baseUrl,
|
|
50
|
+
profile,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await ensureLlamaServerBinary();
|
|
55
|
+
await startLlamaServer(modelPath, modelId, selectedModel.name, runtime);
|
|
56
|
+
await waitForServer(runtime.baseUrl, modelId);
|
|
57
|
+
return {
|
|
58
|
+
id: modelId,
|
|
59
|
+
name: selectedModel.name,
|
|
60
|
+
baseUrl: runtime.baseUrl,
|
|
61
|
+
profile,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function modelRuntime(profile) {
|
|
65
|
+
if (profile === "audit") {
|
|
66
|
+
const baseUrl = process.env.STUPIFY_AUDIT_LLAMA_SERVER_URL ?? "http://127.0.0.1:8092";
|
|
67
|
+
return {
|
|
68
|
+
profile,
|
|
69
|
+
baseUrl,
|
|
70
|
+
port: new URL(baseUrl).port || "8092",
|
|
71
|
+
reasoning: "on",
|
|
72
|
+
reasoningBudget: 4_096,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const baseUrl = process.env.STUPIFY_SCOUT_LLAMA_SERVER_URL ??
|
|
76
|
+
process.env.STUPIFY_LLAMA_SERVER_URL ??
|
|
77
|
+
"http://127.0.0.1:8091";
|
|
78
|
+
return {
|
|
79
|
+
profile,
|
|
80
|
+
baseUrl,
|
|
81
|
+
port: new URL(baseUrl).port || "8091",
|
|
82
|
+
reasoning: "off",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async function runningServerModel(baseUrl) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${baseUrl}/v1/models`, {
|
|
88
|
+
signal: AbortSignal.timeout(500),
|
|
89
|
+
});
|
|
90
|
+
if (!response.ok)
|
|
91
|
+
return null;
|
|
92
|
+
const body = (await response.json());
|
|
93
|
+
const id = body.data?.[0]?.id;
|
|
94
|
+
return typeof id === "string" ? id : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function ensureLlamaServerBinary() {
|
|
101
|
+
try {
|
|
102
|
+
await execFileAsync("llama-server", ["--version"], {
|
|
103
|
+
maxBuffer: 1024 * 1024,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
throw new Error(`Stupify needs llama-server for local inference.
|
|
108
|
+
Install llama.cpp first:
|
|
109
|
+
brew install llama.cpp`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
async function startLlamaServer(modelPath, modelId, modelName, runtime) {
|
|
113
|
+
const logDir = path.join(cacheDir(), "logs");
|
|
114
|
+
await mkdir(logDir, { recursive: true });
|
|
115
|
+
const logPath = path.join(logDir, "llama-server.log");
|
|
116
|
+
const out = await open(logPath, "a");
|
|
117
|
+
const err = await open(logPath, "a");
|
|
118
|
+
console.error(`Starting local ${runtime.profile} model server: ${modelName}`);
|
|
119
|
+
console.error(`llama-server log: ${logPath}`);
|
|
120
|
+
const args = [
|
|
121
|
+
"-m",
|
|
122
|
+
modelPath,
|
|
123
|
+
"-a",
|
|
124
|
+
modelId,
|
|
125
|
+
"--host",
|
|
126
|
+
LLAMA_SERVER_HOST,
|
|
127
|
+
"--port",
|
|
128
|
+
runtime.port,
|
|
129
|
+
"-c",
|
|
130
|
+
"65536",
|
|
131
|
+
"--reasoning",
|
|
132
|
+
runtime.reasoning,
|
|
133
|
+
"--no-warmup",
|
|
134
|
+
];
|
|
135
|
+
if (runtime.reasoningBudget !== undefined) {
|
|
136
|
+
args.push("--reasoning-budget", String(runtime.reasoningBudget));
|
|
137
|
+
}
|
|
138
|
+
const child = spawn("llama-server", args, {
|
|
139
|
+
detached: true,
|
|
140
|
+
stdio: ["ignore", out.fd, err.fd],
|
|
141
|
+
});
|
|
142
|
+
child.unref();
|
|
143
|
+
if (child.pid)
|
|
144
|
+
await writeFile(pidPath(runtime), String(child.pid));
|
|
145
|
+
await out.close();
|
|
146
|
+
await err.close();
|
|
147
|
+
}
|
|
148
|
+
async function stopManagedServer(runtime) {
|
|
149
|
+
const pid = await managedServerPid(runtime);
|
|
150
|
+
if (!pid) {
|
|
151
|
+
const runningModel = await runningServerModel(runtime.baseUrl);
|
|
152
|
+
throw new Error(`A llama-server is already running with ${runningModel ?? "another model"}.
|
|
153
|
+
Stop it before switching models, or use STUPIFY_LLAMA_SERVER_URL for that server.`);
|
|
154
|
+
}
|
|
155
|
+
console.error(`Restarting local ${runtime.profile} model server for selected model.`);
|
|
156
|
+
try {
|
|
157
|
+
process.kill(pid, "SIGTERM");
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
await rm(pidPath(runtime), { force: true });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const deadline = Date.now() + 15_000;
|
|
164
|
+
while (Date.now() < deadline) {
|
|
165
|
+
if (!(await runningServerModel(runtime.baseUrl))) {
|
|
166
|
+
await rm(pidPath(runtime), { force: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
await sleep(250);
|
|
170
|
+
}
|
|
171
|
+
throw new Error("Timed out while stopping existing llama-server.");
|
|
172
|
+
}
|
|
173
|
+
async function managedServerPid(runtime) {
|
|
174
|
+
try {
|
|
175
|
+
const value = Number((await readFile(pidPath(runtime), "utf8")).trim());
|
|
176
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function pidPath(runtime) {
|
|
183
|
+
const filename = runtime.profile === "scout"
|
|
184
|
+
? "llama-server.pid"
|
|
185
|
+
: `llama-server-${runtime.profile}.pid`;
|
|
186
|
+
return path.join(cacheDir(), filename);
|
|
187
|
+
}
|
|
188
|
+
async function waitForServer(baseUrl, modelId) {
|
|
189
|
+
const deadline = Date.now() + 120_000;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
191
|
+
const runningModel = await runningServerModel(baseUrl);
|
|
192
|
+
if (runningModel === modelId)
|
|
193
|
+
return;
|
|
194
|
+
await sleep(500);
|
|
195
|
+
}
|
|
196
|
+
throw new Error(`llama-server did not become ready for ${modelId}.`);
|
|
197
|
+
}
|
|
198
|
+
function sleep(ms) {
|
|
199
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
200
|
+
}
|
|
201
|
+
async function downloadModel(modelPath, modelUrl) {
|
|
202
|
+
const tempPath = `${modelPath}.download`;
|
|
203
|
+
await rm(tempPath, { force: true });
|
|
204
|
+
console.error("Downloading model...");
|
|
205
|
+
try {
|
|
206
|
+
const response = await fetch(modelUrl);
|
|
207
|
+
if (!response.ok || !response.body)
|
|
208
|
+
throw new Error(`Model download failed: HTTP ${response.status}`);
|
|
209
|
+
const total = Number(response.headers.get("content-length") ?? 0);
|
|
210
|
+
let received = 0;
|
|
211
|
+
let lastPrint = 0;
|
|
212
|
+
const reader = response.body.getReader();
|
|
213
|
+
const file = await open(tempPath, "wx");
|
|
214
|
+
try {
|
|
215
|
+
while (true) {
|
|
216
|
+
const { done, value } = await reader.read();
|
|
217
|
+
if (done)
|
|
218
|
+
break;
|
|
219
|
+
received += value.byteLength;
|
|
220
|
+
await file.write(value);
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
if (total > 0 && now - lastPrint > 500) {
|
|
223
|
+
lastPrint = now;
|
|
224
|
+
statusOutput.write(`\r${formatBytes(received)} / ${formatBytes(total)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
await file.close();
|
|
230
|
+
}
|
|
231
|
+
if (total > 0)
|
|
232
|
+
statusOutput.write(`\r${formatBytes(received)} / ${formatBytes(total)}\n`);
|
|
233
|
+
await rename(tempPath, modelPath);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
await rm(tempPath, { force: true });
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function confirm(question) {
|
|
241
|
+
const rl = createInterface(terminalIo());
|
|
242
|
+
try {
|
|
243
|
+
const answer = (await rl.question(question)).trim().toLowerCase();
|
|
244
|
+
return answer === "y" || answer === "yes";
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
rl.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function terminalIo() {
|
|
251
|
+
if (input.isTTY)
|
|
252
|
+
return { input, output };
|
|
253
|
+
if (platform() !== "win32")
|
|
254
|
+
return {
|
|
255
|
+
input: createReadStream("/dev/tty"),
|
|
256
|
+
output: createWriteStream("/dev/tty"),
|
|
257
|
+
};
|
|
258
|
+
throw new Error("No local Stupify model found. Run `stupify` once in an interactive terminal to set up the model.");
|
|
259
|
+
}
|
|
260
|
+
function cacheDir() {
|
|
261
|
+
if (process.env.STUPIFY_CACHE_DIR)
|
|
262
|
+
return process.env.STUPIFY_CACHE_DIR;
|
|
263
|
+
if (process.env.XDG_CACHE_HOME)
|
|
264
|
+
return path.join(process.env.XDG_CACHE_HOME, "stupify");
|
|
265
|
+
if (platform() === "darwin")
|
|
266
|
+
return path.join(homedir(), "Library", "Caches", "stupify");
|
|
267
|
+
if (platform() === "win32" && process.env.LOCALAPPDATA)
|
|
268
|
+
return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
|
|
269
|
+
return path.join(homedir(), ".cache", "stupify");
|
|
270
|
+
}
|
|
271
|
+
async function exists(filePath) {
|
|
272
|
+
try {
|
|
273
|
+
return (await stat(filePath)).isFile();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
function formatBytes(bytes) {
|
|
280
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
281
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AuditPromptName, CandidateContext, DiffBatch, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
|
|
2
|
+
export declare function scoutPrompt(batch: DiffBatch, checks: readonly StupifyCheck[], sourceLabel: string): string;
|
|
3
|
+
export declare function auditPrompt(contexts: readonly CandidateContext[], checks: readonly StupifyCheck[], sourceLabel: string): string;
|
|
4
|
+
export declare function semScoutPrompt(changeSet: SemChangeSet, checks: readonly StupifyCheck[], maxCandidates: number): string;
|
|
5
|
+
export declare function findingsAuditPrompt(contexts: readonly SemContext[], pack: SemContextPack, checks: readonly StupifyCheck[], sourceLabel: string, promptName: AuditPromptName): string;
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
export function scoutPrompt(batch, checks, sourceLabel) {
|
|
2
|
+
return `Pick diff hunks that match enabled checks.
|
|
3
|
+
Return JSON only:
|
|
4
|
+
{ "candidates": ["exact POINTER"] }
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
- Use POINTER values exactly as shown.
|
|
8
|
+
- Return at most 3 candidates.
|
|
9
|
+
- Return { "candidates": [] } if clean.
|
|
10
|
+
- Pick definitions over usage sites.
|
|
11
|
+
|
|
12
|
+
${formatCompactChecks(checks)}
|
|
13
|
+
|
|
14
|
+
SOURCE:
|
|
15
|
+
${sourceLabel}
|
|
16
|
+
|
|
17
|
+
DIFF BATCH ${batch.id}:
|
|
18
|
+
${batch.text}`;
|
|
19
|
+
}
|
|
20
|
+
export function auditPrompt(contexts, checks, sourceLabel) {
|
|
21
|
+
return `Audit candidate diff regions against enabled checks.
|
|
22
|
+
Return JSON only:
|
|
23
|
+
{
|
|
24
|
+
"findings": [{ "checkId": "check_id", "why": "one sentence", "proof": "exact POINTER" }],
|
|
25
|
+
"summary": "one short sentence"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
Rules:
|
|
29
|
+
- Use only checks listed below.
|
|
30
|
+
- checkId must be a check ID, never a POINTER.
|
|
31
|
+
- proof must be one exact POINTER from candidate regions.
|
|
32
|
+
- why describes the suspicious structure, not an identifier.
|
|
33
|
+
- Do not describe an issue in summary unless it is also in findings.
|
|
34
|
+
- If no findings, return { "findings": [], "summary": "No clear judgment-offload signal found." }.
|
|
35
|
+
|
|
36
|
+
Allowed proof pointers:
|
|
37
|
+
${contexts.map((context) => `- ${context.pointer}`).join("\n")}
|
|
38
|
+
|
|
39
|
+
${formatFullChecks(checks)}
|
|
40
|
+
|
|
41
|
+
SOURCE:
|
|
42
|
+
${sourceLabel}
|
|
43
|
+
|
|
44
|
+
CANDIDATE REGIONS:
|
|
45
|
+
${contexts.map(formatContext).join("\n\n")}`;
|
|
46
|
+
}
|
|
47
|
+
export function semScoutPrompt(changeSet, checks, maxCandidates) {
|
|
48
|
+
return `Pick changed entity/check targets worth auditing.
|
|
49
|
+
Return JSON only:
|
|
50
|
+
{ "targets": [{ "entityId": "exact entityId", "checkId": "check_id", "reason": "short scout reason" }] }
|
|
51
|
+
|
|
52
|
+
Rules:
|
|
53
|
+
- Use entityId values exactly as shown.
|
|
54
|
+
- Each target has exactly one checkId.
|
|
55
|
+
- Return at most ${maxCandidates} targets.
|
|
56
|
+
- Return { "targets": [] } if clean.
|
|
57
|
+
- Pick definitions over usage sites.
|
|
58
|
+
- Prefer high recall, but do not attach unrelated checks.
|
|
59
|
+
|
|
60
|
+
${formatCompactChecks(checks)}
|
|
61
|
+
|
|
62
|
+
SOURCE:
|
|
63
|
+
${changeSet.label}
|
|
64
|
+
|
|
65
|
+
SEM CHANGE SUMMARY:
|
|
66
|
+
${JSON.stringify(changeSet.summary, null, 2)}
|
|
67
|
+
|
|
68
|
+
SEM ENTITY CHANGES:
|
|
69
|
+
${changeSet.changes.map(formatSemChange).join("\n\n")}`;
|
|
70
|
+
}
|
|
71
|
+
export function findingsAuditPrompt(contexts, pack, checks, sourceLabel, promptName) {
|
|
72
|
+
const task = promptName === "high_bar"
|
|
73
|
+
? `You are Stupify's audit model.
|
|
74
|
+
You are reviewing candidate/check targets for signs that AI-assisted coding may have replaced engineering judgment.
|
|
75
|
+
Only emit a finding if it is clearly useful to a developer.
|
|
76
|
+
A useful finding must:
|
|
77
|
+
- match the target's check exactly
|
|
78
|
+
- point to a concrete change pattern
|
|
79
|
+
- explain why the change may reflect judgment-offload
|
|
80
|
+
- avoid generic code-review commentary
|
|
81
|
+
If the target is normal engineering work, omit it.
|
|
82
|
+
If the target is merely plausible but not strong, omit it.
|
|
83
|
+
If the target does not exactly match its assigned check, omit it.`
|
|
84
|
+
: `You are Stupify's auditor.
|
|
85
|
+
Audit only the listed target/check pairs.
|
|
86
|
+
Emit only exceptions.`;
|
|
87
|
+
const highBarRules = promptName === "high_bar"
|
|
88
|
+
? `- Prefer clean over weak.
|
|
89
|
+
- Prefer no finding over generic finding.
|
|
90
|
+
- Do not emit style feedback unless the assigned check is truly about style.
|
|
91
|
+
- Do not turn functional refactors into style mismatch findings.`
|
|
92
|
+
: "";
|
|
93
|
+
return `${task}
|
|
94
|
+
Return JSON only:
|
|
95
|
+
{
|
|
96
|
+
"findings": [
|
|
97
|
+
{
|
|
98
|
+
"targetId": "t001",
|
|
99
|
+
"why": "one sentence",
|
|
100
|
+
"proof": "short pointer"
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
"uncertain": [
|
|
104
|
+
{
|
|
105
|
+
"targetId": "t002",
|
|
106
|
+
"why": "one sentence"
|
|
107
|
+
}
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
Rules:
|
|
112
|
+
- Inspect every target.
|
|
113
|
+
- Each target has exactly one check.
|
|
114
|
+
- Emit a finding only when the target clearly matches its check.
|
|
115
|
+
- Emit uncertain only when the target may match, but evidence is insufficient.
|
|
116
|
+
- If a target is clean, emit nothing for it.
|
|
117
|
+
- Omitted target means clean.
|
|
118
|
+
- Do not output clean reviews.
|
|
119
|
+
- Do not explain clean targets.
|
|
120
|
+
- Do not write "no evidence" as a finding.
|
|
121
|
+
- Do not put negative statements in findings.
|
|
122
|
+
- Prefer omission over weak findings.
|
|
123
|
+
- Use only provided targetIds.
|
|
124
|
+
- Do not search for other checks.
|
|
125
|
+
- Do not quote source code.
|
|
126
|
+
- Use packed file context only as supporting evidence for these candidate entities.
|
|
127
|
+
${highBarRules}
|
|
128
|
+
|
|
129
|
+
Targets:
|
|
130
|
+
${contexts.map((context) => formatAuditTarget(context, checks)).join("\n\n")}
|
|
131
|
+
|
|
132
|
+
SOURCE:
|
|
133
|
+
${sourceLabel}
|
|
134
|
+
|
|
135
|
+
CANDIDATE ENTITY DELTAS:
|
|
136
|
+
${contexts.map(formatSemContext).join("\n\n")}
|
|
137
|
+
|
|
138
|
+
PACKED FILE CONTEXT (${pack.provider}, ${pack.filePaths.length} files, ${pack.totalTokens} tokens):
|
|
139
|
+
${pack.text || "(none)"}`;
|
|
140
|
+
}
|
|
141
|
+
function formatCompactChecks(checks) {
|
|
142
|
+
return `Checks:
|
|
143
|
+
${checks.map((check) => `- ${check.id}: ${check.lookFor.join("; ")}`).join("\n")}`;
|
|
144
|
+
}
|
|
145
|
+
function formatFullChecks(checks) {
|
|
146
|
+
return checks.map(formatCheck).join("\n\n");
|
|
147
|
+
}
|
|
148
|
+
function formatCheck(check) {
|
|
149
|
+
return `# ${check.name}
|
|
150
|
+
ID: ${check.id}
|
|
151
|
+
Q: ${check.question}
|
|
152
|
+
Look for:
|
|
153
|
+
${check.lookFor.map((signal) => `- ${signal}`).join("\n")}
|
|
154
|
+
Ignore when:
|
|
155
|
+
${check.ignoreWhen.map((signal) => `- ${signal}`).join("\n")}
|
|
156
|
+
Match examples:
|
|
157
|
+
${(check.examples?.match ?? []).map((example) => `- ${example}`).join("\n")}
|
|
158
|
+
No-match examples:
|
|
159
|
+
${(check.examples?.noMatch ?? []).map((example) => `- ${example}`).join("\n")}`;
|
|
160
|
+
}
|
|
161
|
+
function formatContext(context) {
|
|
162
|
+
return `POINTER ${context.pointer}
|
|
163
|
+
${context.text}`;
|
|
164
|
+
}
|
|
165
|
+
function formatSemChange(change) {
|
|
166
|
+
return `ENTITY ${change.entityId}
|
|
167
|
+
TYPE ${change.entityType}
|
|
168
|
+
CHANGE ${change.changeType}
|
|
169
|
+
PATH ${change.filePath}`;
|
|
170
|
+
}
|
|
171
|
+
function formatSemContext(context) {
|
|
172
|
+
return `TARGET ${context.targetId}
|
|
173
|
+
ENTITY ${context.entityId}
|
|
174
|
+
NAME ${context.entityName}
|
|
175
|
+
KIND ${context.entityKind}
|
|
176
|
+
CHANGE ${context.changeKind}
|
|
177
|
+
CHECK ${context.checkId}
|
|
178
|
+
SCOUT_REASON ${context.reason}
|
|
179
|
+
CONTEXT:
|
|
180
|
+
${context.text}`;
|
|
181
|
+
}
|
|
182
|
+
function formatAuditTarget(context, checks) {
|
|
183
|
+
const check = checks.find((item) => item.id === context.checkId);
|
|
184
|
+
return `- targetId=${context.targetId} checkId=${context.checkId} entityId=${context.entityId}
|
|
185
|
+
scoutReason=${context.reason}
|
|
186
|
+
${check ? formatCheck(check) : ""}`;
|
|
187
|
+
}
|
|
188
|
+
function shortenCode(value) {
|
|
189
|
+
if (!value)
|
|
190
|
+
return "(none)";
|
|
191
|
+
const lines = value.split(/\r?\n/);
|
|
192
|
+
const limit = 80;
|
|
193
|
+
if (lines.length <= limit)
|
|
194
|
+
return value;
|
|
195
|
+
return `${lines.slice(0, limit).join("\n")}
|
|
196
|
+
[stupify: sem entity content shortened after ${limit} lines]`;
|
|
197
|
+
}
|
package/dist/render.d.ts
ADDED
package/dist/render.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { VERSION } from "./constants.js";
|
|
2
|
+
export function renderReport(report, command) {
|
|
3
|
+
if (command.json) {
|
|
4
|
+
return JSON.stringify({
|
|
5
|
+
schemaVersion: "0.4",
|
|
6
|
+
model: { id: report.run.modelId },
|
|
7
|
+
checks: report.run.checkIds,
|
|
8
|
+
run: report.run,
|
|
9
|
+
findings: report.result.findings,
|
|
10
|
+
summary: report.result.summary,
|
|
11
|
+
}, null, 2);
|
|
12
|
+
}
|
|
13
|
+
if (report.run.engine === "sem") {
|
|
14
|
+
return `Search:
|
|
15
|
+
${report.run.entitiesScanned} entities scanned
|
|
16
|
+
${report.run.candidateCount} candidate entities found
|
|
17
|
+
Audit:
|
|
18
|
+
${report.run.auditedCandidateCount} candidates inspected
|
|
19
|
+
${report.result.findings.length} findings
|
|
20
|
+
${renderAuditStats(report)}
|
|
21
|
+
${renderWarnings(report)}
|
|
22
|
+
Findings:
|
|
23
|
+
${renderFindings(report)}
|
|
24
|
+
Timing:
|
|
25
|
+
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}`;
|
|
26
|
+
}
|
|
27
|
+
return `Search:
|
|
28
|
+
${report.run.batchesScanned} batches scanned
|
|
29
|
+
${report.run.candidateCount} candidate regions found
|
|
30
|
+
Audit:
|
|
31
|
+
${report.run.auditedCandidateCount} candidates inspected
|
|
32
|
+
${report.result.findings.length} findings
|
|
33
|
+
${renderWarnings(report)}
|
|
34
|
+
Findings:
|
|
35
|
+
${renderFindings(report)}
|
|
36
|
+
Timing:
|
|
37
|
+
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}`;
|
|
38
|
+
}
|
|
39
|
+
export function helpText() {
|
|
40
|
+
return `Stupify ${VERSION}
|
|
41
|
+
|
|
42
|
+
Usage:
|
|
43
|
+
stupify
|
|
44
|
+
stupify --since "2 weeks ago"
|
|
45
|
+
stupify --commit <commit>
|
|
46
|
+
stupify --commits <count>
|
|
47
|
+
stupify experiment <config.json>
|
|
48
|
+
git diff HEAD~1..HEAD | stupify --stdin
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--since <date> Analyze the net diff from the first commit before this git date to HEAD.
|
|
52
|
+
--commit <commit> Analyze one commit as a net diff.
|
|
53
|
+
--commits <count> Analyze the net diff across the last N non-merge commits.
|
|
54
|
+
--stdin Read a git diff from stdin.
|
|
55
|
+
--engine <engine> raw-diff or sem. Default: raw-diff.
|
|
56
|
+
--scout <mode> llm or counter for --engine sem. Default: counter.
|
|
57
|
+
--audit-context <mode>
|
|
58
|
+
none or repomix for --engine sem. Default: repomix.
|
|
59
|
+
--audit-prompt <name> strict or high_bar for --engine sem. Default: high_bar.
|
|
60
|
+
--debug-sem Print sem commands and stderr.
|
|
61
|
+
--debug-targets Include audited sem target details in JSON output.
|
|
62
|
+
--max-candidates <n> Max semantic candidates for --engine sem. Default: 10.
|
|
63
|
+
--audit-batch-size <n>
|
|
64
|
+
Semantic candidates per audit model call. Default: 25.
|
|
65
|
+
--max-audit-input-tokens <n>
|
|
66
|
+
Max findings-audit input tokens before splitting. Default: 20000.
|
|
67
|
+
--audit-concurrency <n>
|
|
68
|
+
Parallel findings-audit model calls. Default: 2.
|
|
69
|
+
--checks <ids> Comma-separated check ids.
|
|
70
|
+
--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.
|
|
71
|
+
--json Print JSON only.
|
|
72
|
+
|
|
73
|
+
Default:
|
|
74
|
+
stupify is equivalent to stupify --since "2 weeks ago".
|
|
75
|
+
|
|
76
|
+
Not included:
|
|
77
|
+
Baselines, sharing, hosted server calls, Ollama, GitHub, dashboards, or repo-wide scanning.
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
function renderFindings(report) {
|
|
81
|
+
if (report.result.findings.length === 0)
|
|
82
|
+
return " None.";
|
|
83
|
+
return report.result.findings
|
|
84
|
+
.map((finding) => `- ${finding.checkId}
|
|
85
|
+
${finding.why}
|
|
86
|
+
Proof: ${finding.proof}`)
|
|
87
|
+
.join("\n");
|
|
88
|
+
}
|
|
89
|
+
function renderWarnings(report) {
|
|
90
|
+
if (report.run.warnings.length === 0)
|
|
91
|
+
return "";
|
|
92
|
+
return `Warnings:
|
|
93
|
+
${report.run.warnings.map((warning) => ` ${warning}`).join("\n")}
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
function renderAuditStats(report) {
|
|
97
|
+
const stats = report.run.auditStats;
|
|
98
|
+
if (!stats)
|
|
99
|
+
return "";
|
|
100
|
+
return ` ${stats.totalTargets} targets reviewed: ${stats.finding} finding, ${stats.uncertain} uncertain, ${stats.clean} clean, ${stats.invalid} invalid`;
|
|
101
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SemCandidate, SemChange, SemContext, SemContextPack } from "./types.ts";
|
|
2
|
+
export declare function emptyContextPack(): SemContextPack;
|
|
3
|
+
export declare function repomixContextPack(cwd: string, contexts: readonly SemContext[], changes: readonly SemChange[]): Promise<SemContextPack>;
|
|
4
|
+
export declare function entityContextsFromChanges(candidates: readonly SemCandidate[], changes: readonly SemChange[]): readonly SemContext[];
|