@stupify/cli 0.0.2 → 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/src/batcher.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { DiffBatch, DiffHunk } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BATCH_LINES = 1_000;
|
|
4
|
+
|
|
5
|
+
type ParsedHunk = Readonly<{
|
|
6
|
+
fileId: string;
|
|
7
|
+
hunkId: string;
|
|
8
|
+
filePath: string;
|
|
9
|
+
text: string;
|
|
10
|
+
lineCount: number;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
export function batchDiff(
|
|
14
|
+
diff: string,
|
|
15
|
+
linesPerBatch = DEFAULT_BATCH_LINES,
|
|
16
|
+
): readonly DiffBatch[] {
|
|
17
|
+
const hunks = parseHunks(diff).flatMap((item) =>
|
|
18
|
+
splitLargeHunk(item, linesPerBatch),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
type BatchState = Readonly<{
|
|
22
|
+
batches: readonly DiffBatch[];
|
|
23
|
+
current: readonly DiffHunk[];
|
|
24
|
+
currentLines: number;
|
|
25
|
+
batchNumber: number;
|
|
26
|
+
}>;
|
|
27
|
+
|
|
28
|
+
const initialState: BatchState = {
|
|
29
|
+
batches: [],
|
|
30
|
+
current: [],
|
|
31
|
+
currentLines: 0,
|
|
32
|
+
batchNumber: 1,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const finalized = hunks.reduce<BatchState>((state, hunk) => {
|
|
36
|
+
const needsFlush =
|
|
37
|
+
state.current.length > 0 &&
|
|
38
|
+
state.currentLines + hunk.lineCount > linesPerBatch;
|
|
39
|
+
|
|
40
|
+
const flushed = needsFlush ? flush(state) : state;
|
|
41
|
+
return {
|
|
42
|
+
...flushed,
|
|
43
|
+
current: [...flushed.current, toDiffHunk(hunk, flushed.batchNumber)],
|
|
44
|
+
currentLines: flushed.currentLines + hunk.lineCount,
|
|
45
|
+
};
|
|
46
|
+
}, initialState);
|
|
47
|
+
|
|
48
|
+
return finalized.current.length > 0
|
|
49
|
+
? [...finalized.batches, toBatch(finalized.batchNumber, finalized.current)]
|
|
50
|
+
: finalized.batches;
|
|
51
|
+
|
|
52
|
+
function flush(state: BatchState): BatchState {
|
|
53
|
+
return {
|
|
54
|
+
batches: [...state.batches, toBatch(state.batchNumber, state.current)],
|
|
55
|
+
current: [],
|
|
56
|
+
currentLines: 0,
|
|
57
|
+
batchNumber: state.batchNumber + 1,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function allHunks(batches: readonly DiffBatch[]): readonly DiffHunk[] {
|
|
63
|
+
return batches.flatMap((batch) => batch.hunks);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseHunks(diff: string): readonly ParsedHunk[] {
|
|
67
|
+
type ParseState = Readonly<{
|
|
68
|
+
hunks: readonly ParsedHunk[];
|
|
69
|
+
filePath: string;
|
|
70
|
+
fileIndex: number;
|
|
71
|
+
hunkIndex: number;
|
|
72
|
+
fileHeader: readonly string[];
|
|
73
|
+
hunkLines: readonly string[] | null;
|
|
74
|
+
}>;
|
|
75
|
+
|
|
76
|
+
const lines = diff.split(/\r?\n/);
|
|
77
|
+
|
|
78
|
+
const initialState: ParseState = {
|
|
79
|
+
hunks: [],
|
|
80
|
+
filePath: "unknown",
|
|
81
|
+
fileIndex: 0,
|
|
82
|
+
hunkIndex: 0,
|
|
83
|
+
fileHeader: [],
|
|
84
|
+
hunkLines: null,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const finalState = lines.reduce<ParseState>((state, line) => {
|
|
88
|
+
const fileMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
|
|
89
|
+
if (fileMatch) {
|
|
90
|
+
const flushed = flush(state);
|
|
91
|
+
return {
|
|
92
|
+
...flushed,
|
|
93
|
+
fileIndex: flushed.fileIndex + 1,
|
|
94
|
+
hunkIndex: 0,
|
|
95
|
+
filePath: fileMatch[1],
|
|
96
|
+
fileHeader: [line],
|
|
97
|
+
hunkLines: null,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (line.startsWith("@@ ")) {
|
|
102
|
+
const flushed = flush(state);
|
|
103
|
+
return {
|
|
104
|
+
...flushed,
|
|
105
|
+
hunkIndex: flushed.hunkIndex + 1,
|
|
106
|
+
hunkLines: [...flushed.fileHeader, line],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (state.hunkLines) return { ...state, hunkLines: [...state.hunkLines, line] };
|
|
111
|
+
if (state.fileHeader.length > 0) return { ...state, fileHeader: [...state.fileHeader, line] };
|
|
112
|
+
return state;
|
|
113
|
+
}, initialState);
|
|
114
|
+
|
|
115
|
+
return flush(finalState).hunks;
|
|
116
|
+
|
|
117
|
+
function flush(state: ParseState): ParseState {
|
|
118
|
+
if (!state.hunkLines) return state;
|
|
119
|
+
const fileId = `file-${pad(state.fileIndex)}`;
|
|
120
|
+
const hunkId = `hunk-${pad(state.hunkIndex)}`;
|
|
121
|
+
const text = state.hunkLines.join("\n").trimEnd();
|
|
122
|
+
const nextHunk: ParsedHunk = {
|
|
123
|
+
fileId,
|
|
124
|
+
hunkId,
|
|
125
|
+
filePath: state.filePath,
|
|
126
|
+
text,
|
|
127
|
+
lineCount: countLines(text),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return { ...state, hunks: [...state.hunks, nextHunk], hunkLines: null };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function splitLargeHunk(
|
|
135
|
+
hunk: ParsedHunk,
|
|
136
|
+
linesPerBatch: number,
|
|
137
|
+
): readonly ParsedHunk[] {
|
|
138
|
+
if (hunk.lineCount <= linesPerBatch) return [hunk];
|
|
139
|
+
const lines = hunk.text.split(/\r?\n/);
|
|
140
|
+
const chunkCount = Math.ceil(lines.length / linesPerBatch);
|
|
141
|
+
return Array.from({ length: chunkCount }, (_, chunkIndex) => {
|
|
142
|
+
const start = chunkIndex * linesPerBatch;
|
|
143
|
+
const text = lines.slice(start, start + linesPerBatch).join("\n");
|
|
144
|
+
return {
|
|
145
|
+
...hunk,
|
|
146
|
+
hunkId: `${hunk.hunkId}-part-${pad(chunkIndex + 1)}`,
|
|
147
|
+
text,
|
|
148
|
+
lineCount: countLines(text),
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toDiffHunk(hunk: ParsedHunk, batchNumber: number): DiffHunk {
|
|
154
|
+
const batchId = `batch-${pad(batchNumber)}`;
|
|
155
|
+
return {
|
|
156
|
+
...hunk,
|
|
157
|
+
batchId,
|
|
158
|
+
pointer: `${batchId}:${hunk.fileId}:${hunk.hunkId}`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toBatch(batchNumber: number, hunks: readonly DiffHunk[]): DiffBatch {
|
|
163
|
+
const id = `batch-${pad(batchNumber)}`;
|
|
164
|
+
return {
|
|
165
|
+
id,
|
|
166
|
+
hunks,
|
|
167
|
+
text: hunks.map(formatHunkForSearch).join("\n\n"),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatHunkForSearch(hunk: DiffHunk): string {
|
|
172
|
+
return `POINTER ${hunk.pointer}
|
|
173
|
+
FILE ${hunk.fileId}
|
|
174
|
+
PATH ${hunk.filePath}
|
|
175
|
+
${searchView(hunk.text)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function searchView(text: string): string {
|
|
179
|
+
return text
|
|
180
|
+
.split(/\r?\n/)
|
|
181
|
+
.filter(
|
|
182
|
+
(line) =>
|
|
183
|
+
line.startsWith("diff --git ") ||
|
|
184
|
+
line.startsWith("--- ") ||
|
|
185
|
+
line.startsWith("+++ ") ||
|
|
186
|
+
line.startsWith("@@ ") ||
|
|
187
|
+
(line.startsWith("+") && !line.startsWith("+++")),
|
|
188
|
+
)
|
|
189
|
+
.join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function countLines(value: string): number {
|
|
193
|
+
return value.length === 0 ? 0 : value.split(/\r?\n/).length;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pad(value: number): string {
|
|
197
|
+
return String(value).padStart(3, "0");
|
|
198
|
+
}
|
package/src/cache.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export function fingerprint(value: unknown): string {
|
|
7
|
+
const text = typeof value === "string" ? value : stableStringify(value);
|
|
8
|
+
return createHash("sha256").update(text).digest("hex");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function cachedJson<T>(
|
|
12
|
+
namespace: string,
|
|
13
|
+
key: string,
|
|
14
|
+
compute: () => Promise<T>,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
const filePath = cachePath(namespace, key);
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(await readFile(filePath, "utf8")) as T;
|
|
19
|
+
console.error(`cache hit ${namespace} ${key.slice(0, 12)}`);
|
|
20
|
+
return value;
|
|
21
|
+
} catch {
|
|
22
|
+
console.error(`cache miss ${namespace} ${key.slice(0, 12)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const value = await compute();
|
|
26
|
+
await writeCache(filePath, value).catch(() => undefined);
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cachePath(namespace: string, key: string): string {
|
|
31
|
+
return path.join(cacheRoot(), "intermediate-v1", safeNamespace(namespace), `${key}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function writeCache(filePath: string, value: unknown): Promise<void> {
|
|
35
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
36
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
await writeFile(tempPath, JSON.stringify(value), "utf8");
|
|
39
|
+
await rename(tempPath, filePath);
|
|
40
|
+
} catch (error) {
|
|
41
|
+
await rm(tempPath, { force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function cacheRoot(): string {
|
|
47
|
+
if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
|
|
48
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, "stupify");
|
|
49
|
+
if (platform() === "darwin") return path.join(homedir(), "Library", "Caches", "stupify");
|
|
50
|
+
if (platform() === "win32" && process.env.LOCALAPPDATA) return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
|
|
51
|
+
return path.join(homedir(), ".cache", "stupify");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeNamespace(value: string): string {
|
|
55
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stableStringify(value: unknown): string {
|
|
59
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
60
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
61
|
+
const record = value as Record<string, unknown>;
|
|
62
|
+
return `{${Object.keys(record).sort().map((key) =>
|
|
63
|
+
`${JSON.stringify(key)}:${stableStringify(record[key])}`
|
|
64
|
+
).join(",")}}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { allHunks } from "./batcher.ts";
|
|
2
|
+
import type { CandidateContext, DiffBatch, DiffHunk } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const MAX_CONTEXT_LINES = 80;
|
|
5
|
+
|
|
6
|
+
export function candidateContexts(
|
|
7
|
+
batches: readonly DiffBatch[],
|
|
8
|
+
candidatePointers: readonly string[],
|
|
9
|
+
): readonly CandidateContext[] {
|
|
10
|
+
const hunks = allHunks(batches);
|
|
11
|
+
const byPointer = new Map(hunks.map((hunk) => [hunk.pointer, hunk]));
|
|
12
|
+
const uniquePointers = [...new Set(candidatePointers)]
|
|
13
|
+
.sort((left, right) => hunkPriority(byPointer.get(right)) - hunkPriority(byPointer.get(left)));
|
|
14
|
+
|
|
15
|
+
return uniquePointers.flatMap((pointer) => {
|
|
16
|
+
const hunk = byPointer.get(pointer);
|
|
17
|
+
if (!hunk) return [];
|
|
18
|
+
return [{ pointer, text: formatHunk(hunk) }];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatHunk(hunk: DiffHunk): string {
|
|
23
|
+
return `PATH ${hunk.filePath}
|
|
24
|
+
${shorten(hunk.text)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function shorten(text: string): string {
|
|
28
|
+
const lines = text.split(/\r?\n/);
|
|
29
|
+
if (lines.length <= MAX_CONTEXT_LINES) return text;
|
|
30
|
+
return `${lines.slice(0, MAX_CONTEXT_LINES).join("\n")}
|
|
31
|
+
[stupify: hunk shortened after ${MAX_CONTEXT_LINES} lines]`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hunkPriority(hunk: DiffHunk | undefined): number {
|
|
35
|
+
if (!hunk) return 0;
|
|
36
|
+
const text = hunk.text;
|
|
37
|
+
let priority = 0;
|
|
38
|
+
if (/^\+export\s+type\s|\+export\s+interface\s|\+type\s|\+interface\s/m.test(text)) priority += 3;
|
|
39
|
+
if (/^\+export\s+function\s|\+function\s/m.test(text)) priority += 2;
|
|
40
|
+
if (/\.map\(|=>\s*\(\{|=>\s*\{/m.test(text)) priority += 2;
|
|
41
|
+
if (/payload|schema|dto|response|result/i.test(text)) priority += 1;
|
|
42
|
+
return priority;
|
|
43
|
+
}
|
package/src/checks.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { checkId, type StupifyCheck } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const defaultChecks: readonly StupifyCheck[] = [
|
|
4
|
+
{
|
|
5
|
+
id: checkId("duplicated_schema"),
|
|
6
|
+
name: "Duplicated schema",
|
|
7
|
+
question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
|
|
8
|
+
lookFor: [
|
|
9
|
+
"local shape mirrors existing fields and maps them one-for-one",
|
|
10
|
+
"new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
|
|
11
|
+
],
|
|
12
|
+
ignoreWhen: [
|
|
13
|
+
"test fixture, mock, or intentional external contract",
|
|
14
|
+
],
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: checkId("unnecessary_complexity"),
|
|
18
|
+
name: "Unnecessary complexity",
|
|
19
|
+
question: "Did the change add structure without buying clarity?",
|
|
20
|
+
lookFor: [
|
|
21
|
+
"helper, wrapper, service, layer, or extra file around simple logic without reuse",
|
|
22
|
+
],
|
|
23
|
+
ignoreWhen: [
|
|
24
|
+
"isolates dependency, removes duplication, or improves testability",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: checkId("fake_precision_windowing"),
|
|
29
|
+
name: "Fake precision windowing",
|
|
30
|
+
question: "Did the change add fake precision around model context?",
|
|
31
|
+
lookFor: [
|
|
32
|
+
"precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
|
|
33
|
+
],
|
|
34
|
+
ignoreWhen: [
|
|
35
|
+
"simple fixed cap or chunking",
|
|
36
|
+
"external API requirement",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: checkId("coauthored_slop"),
|
|
41
|
+
name: "Coauthored slop",
|
|
42
|
+
question: "Does author metadata contain co-author text?",
|
|
43
|
+
lookFor: [
|
|
44
|
+
"author signal contains coauhtoried, coauthored, or co-authored text",
|
|
45
|
+
],
|
|
46
|
+
ignoreWhen: [
|
|
47
|
+
"normal Co-authored-by trailer in the commit body",
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: checkId("mega_file"),
|
|
52
|
+
name: "Mega file",
|
|
53
|
+
question: "Is a touched non-config file over 1000 LOC?",
|
|
54
|
+
lookFor: [
|
|
55
|
+
"touched non-config source file over 1000 LOC",
|
|
56
|
+
],
|
|
57
|
+
ignoreWhen: [
|
|
58
|
+
"config, lock, generated, fixture, or vendored file",
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: checkId("over_commenting"),
|
|
63
|
+
name: "Over commenting",
|
|
64
|
+
question: "Did the change add noisy comments?",
|
|
65
|
+
lookFor: [
|
|
66
|
+
"comments restate obvious code or narrate simple logic",
|
|
67
|
+
],
|
|
68
|
+
ignoreWhen: [
|
|
69
|
+
"comment explains intent, constraint, workaround, or public API behavior",
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: checkId("lint_bypass"),
|
|
74
|
+
name: "Lint bypass",
|
|
75
|
+
question: "Did the change bypass lint or type rules?",
|
|
76
|
+
lookFor: [
|
|
77
|
+
"adds suppressions, any, broad casts, or weakens lint/typecheck config",
|
|
78
|
+
],
|
|
79
|
+
ignoreWhen: [
|
|
80
|
+
"narrow suppression with a reason",
|
|
81
|
+
"type-level test",
|
|
82
|
+
"generated file convention",
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: checkId("inconsistent_patterns"),
|
|
87
|
+
name: "Inconsistent patterns",
|
|
88
|
+
question: "Does the change clash with nearby patterns?",
|
|
89
|
+
lookFor: [
|
|
90
|
+
"same job uses different naming, errors, state, imports, or layout than nearby files",
|
|
91
|
+
],
|
|
92
|
+
ignoreWhen: [
|
|
93
|
+
"external API requires it",
|
|
94
|
+
"change follows a newer local convention",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
id: checkId("reinvented_utils"),
|
|
99
|
+
name: "Reinvented utils",
|
|
100
|
+
question: "Did the change recreate an existing utility?",
|
|
101
|
+
lookFor: [
|
|
102
|
+
"new helper duplicates local utility or standard library behavior",
|
|
103
|
+
],
|
|
104
|
+
ignoreWhen: [
|
|
105
|
+
"existing utility has wrong contract",
|
|
106
|
+
"new helper is clearer as a tiny private expression",
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: checkId("operator_style_mismatch"),
|
|
111
|
+
name: "Operator style mismatch",
|
|
112
|
+
question: "Does the change read unlike the surrounding code?",
|
|
113
|
+
lookFor: [
|
|
114
|
+
"generic or template-like names, abstractions, comments, or control flow clash with local style",
|
|
115
|
+
],
|
|
116
|
+
ignoreWhen: [
|
|
117
|
+
"generated, vendored, framework-required, or newer established local style",
|
|
118
|
+
],
|
|
119
|
+
enabledByDefault: false,
|
|
120
|
+
},
|
|
121
|
+
] as const;
|
|
122
|
+
|
|
123
|
+
export function enabledChecks(checkIds: readonly string[] | null): readonly StupifyCheck[] {
|
|
124
|
+
if (!checkIds) return defaultChecks.filter((check) => check.enabledByDefault !== false);
|
|
125
|
+
|
|
126
|
+
const checksById = new Map<string, StupifyCheck>(defaultChecks.map((check) => [check.id, check]));
|
|
127
|
+
return checkIds.map((id) => {
|
|
128
|
+
const check = checksById.get(id);
|
|
129
|
+
if (!check) throw new Error(`Unknown check: ${id}`);
|
|
130
|
+
return check;
|
|
131
|
+
});
|
|
132
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
|
|
2
|
+
import type { AuditContextMode, AuditPromptName, Command, Engine, ModelId, ScoutMode } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SINCE = "2 weeks ago";
|
|
5
|
+
const DEFAULT_MAX_CANDIDATES = 10;
|
|
6
|
+
const DEFAULT_SCOUT_MODE: ScoutMode = "counter";
|
|
7
|
+
const DEFAULT_AUDIT_CONTEXT: AuditContextMode = "repomix";
|
|
8
|
+
const DEFAULT_AUDIT_PROMPT: AuditPromptName = "high_bar";
|
|
9
|
+
const DEFAULT_AUDIT_BATCH_SIZE = 25;
|
|
10
|
+
const DEFAULT_MAX_AUDIT_INPUT_TOKENS = 20_000;
|
|
11
|
+
const DEFAULT_AUDIT_CONCURRENCY = 2;
|
|
12
|
+
type InputMode =
|
|
13
|
+
| Readonly<{ kind: "since"; since: string }>
|
|
14
|
+
| Readonly<{ kind: "stdin" }>
|
|
15
|
+
| Readonly<{ kind: "commit"; commit: string }>
|
|
16
|
+
| Readonly<{ kind: "commits"; count: number }>;
|
|
17
|
+
|
|
18
|
+
export function parseCommand(argv: readonly string[]): Command {
|
|
19
|
+
if (argv.length === 1 && isHelp(argv[0])) return { kind: "help" };
|
|
20
|
+
if (argv[0] === "experiment") {
|
|
21
|
+
const configPath = argv[1];
|
|
22
|
+
if (!configPath || argv.length > 2) throw new Error("Usage: stupify experiment <config.json>");
|
|
23
|
+
return { kind: "experiment", configPath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type ParseState = Readonly<{
|
|
27
|
+
inputMode: InputMode;
|
|
28
|
+
explicitInputMode: boolean;
|
|
29
|
+
checkIds: readonly string[] | null;
|
|
30
|
+
json: boolean;
|
|
31
|
+
model: ModelId;
|
|
32
|
+
engine: Engine;
|
|
33
|
+
scout: ScoutMode;
|
|
34
|
+
auditContext: AuditContextMode;
|
|
35
|
+
auditPrompt: AuditPromptName;
|
|
36
|
+
debugSem: boolean;
|
|
37
|
+
debugTargets: boolean;
|
|
38
|
+
maxCandidates: number;
|
|
39
|
+
auditBatchSize: number;
|
|
40
|
+
maxAuditInputTokens: number;
|
|
41
|
+
auditConcurrency: number;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
const initialState: ParseState = {
|
|
45
|
+
inputMode: { kind: "since", since: DEFAULT_SINCE },
|
|
46
|
+
explicitInputMode: false,
|
|
47
|
+
checkIds: null,
|
|
48
|
+
json: false,
|
|
49
|
+
model: DEFAULT_MODEL_ID,
|
|
50
|
+
engine: "raw-diff",
|
|
51
|
+
scout: DEFAULT_SCOUT_MODE,
|
|
52
|
+
auditContext: DEFAULT_AUDIT_CONTEXT,
|
|
53
|
+
auditPrompt: DEFAULT_AUDIT_PROMPT,
|
|
54
|
+
debugSem: false,
|
|
55
|
+
debugTargets: false,
|
|
56
|
+
maxCandidates: DEFAULT_MAX_CANDIDATES,
|
|
57
|
+
auditBatchSize: DEFAULT_AUDIT_BATCH_SIZE,
|
|
58
|
+
maxAuditInputTokens: DEFAULT_MAX_AUDIT_INPUT_TOKENS,
|
|
59
|
+
auditConcurrency: DEFAULT_AUDIT_CONCURRENCY,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const finalState = parseFrom(0, initialState);
|
|
63
|
+
return {
|
|
64
|
+
...finalState.inputMode,
|
|
65
|
+
checkIds: finalState.checkIds,
|
|
66
|
+
json: finalState.json,
|
|
67
|
+
model: finalState.model,
|
|
68
|
+
engine: finalState.engine,
|
|
69
|
+
scout: finalState.scout,
|
|
70
|
+
auditContext: finalState.auditContext,
|
|
71
|
+
auditPrompt: finalState.auditPrompt,
|
|
72
|
+
debugSem: finalState.debugSem,
|
|
73
|
+
debugTargets: finalState.debugTargets,
|
|
74
|
+
maxCandidates: finalState.maxCandidates,
|
|
75
|
+
auditBatchSize: finalState.auditBatchSize,
|
|
76
|
+
maxAuditInputTokens: finalState.maxAuditInputTokens,
|
|
77
|
+
auditConcurrency: finalState.auditConcurrency,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function parseFrom(index: number, state: ParseState): ParseState {
|
|
81
|
+
if (index >= argv.length) return state;
|
|
82
|
+
|
|
83
|
+
const arg = argv[index];
|
|
84
|
+
|
|
85
|
+
if (arg === "--stdin") return parseFrom(index + 1, setInputMode(state, { kind: "stdin" }));
|
|
86
|
+
if (arg === "--json") return parseFrom(index + 1, { ...state, json: true });
|
|
87
|
+
if (arg === "--debug-sem") return parseFrom(index + 1, { ...state, debugSem: true });
|
|
88
|
+
if (arg === "--debug-targets") return parseFrom(index + 1, { ...state, debugTargets: true });
|
|
89
|
+
|
|
90
|
+
if (arg === "--since") {
|
|
91
|
+
const value = argv[index + 1];
|
|
92
|
+
if (!value || value.startsWith("-")) throw new Error("--since requires a git date, such as \"2 weeks ago\".");
|
|
93
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (arg === "--commit") {
|
|
97
|
+
const value = argv[index + 1];
|
|
98
|
+
if (!value || !isSafeCommitArg(value)) throw new Error("Invalid commit.");
|
|
99
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value }));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (arg === "--commits") {
|
|
103
|
+
const value = argv[index + 1];
|
|
104
|
+
const count = Number(value);
|
|
105
|
+
if (!Number.isInteger(count) || count < 1) throw new Error("--commits requires a positive integer.");
|
|
106
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commits", count }));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (arg === "--checks") {
|
|
110
|
+
const value = argv[index + 1];
|
|
111
|
+
if (!value || value.startsWith("-")) throw new Error("--checks requires a comma-separated list.");
|
|
112
|
+
const checkIds = value.split(",").map((id) => id.trim()).filter(Boolean);
|
|
113
|
+
if (checkIds.length === 0) throw new Error("--checks requires at least one check id.");
|
|
114
|
+
return parseFrom(index + 2, { ...state, checkIds });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (arg === "--model") {
|
|
118
|
+
const value = argv[index + 1];
|
|
119
|
+
if (!value || !isModelId(value)) throw new Error(`--model must be one of: ${Object.keys(MODEL_REGISTRY).join(", ")}`);
|
|
120
|
+
return parseFrom(index + 2, { ...state, model: value });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (arg === "--engine") {
|
|
124
|
+
const value = argv[index + 1];
|
|
125
|
+
if (!value || !isEngine(value)) throw new Error("--engine must be raw-diff or sem.");
|
|
126
|
+
return parseFrom(index + 2, { ...state, engine: value });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (arg === "--scout") {
|
|
130
|
+
const value = argv[index + 1];
|
|
131
|
+
if (!value || !isScoutMode(value)) throw new Error("--scout must be counter or llm.");
|
|
132
|
+
return parseFrom(index + 2, { ...state, scout: value });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (arg === "--audit-context") {
|
|
136
|
+
const value = argv[index + 1];
|
|
137
|
+
if (!value || !isAuditContextMode(value)) throw new Error("--audit-context must be none or repomix.");
|
|
138
|
+
return parseFrom(index + 2, { ...state, auditContext: value });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (arg === "--audit-prompt") {
|
|
142
|
+
const value = argv[index + 1];
|
|
143
|
+
if (!value || !isAuditPromptName(value)) throw new Error("--audit-prompt must be strict or high_bar.");
|
|
144
|
+
return parseFrom(index + 2, { ...state, auditPrompt: value });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (arg === "--max-candidates") {
|
|
148
|
+
const value = argv[index + 1];
|
|
149
|
+
const maxCandidates = Number(value);
|
|
150
|
+
if (!Number.isInteger(maxCandidates) || maxCandidates < 1) {
|
|
151
|
+
throw new Error("--max-candidates requires a positive integer.");
|
|
152
|
+
}
|
|
153
|
+
return parseFrom(index + 2, { ...state, maxCandidates });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (arg === "--audit-batch-size") {
|
|
157
|
+
const value = argv[index + 1];
|
|
158
|
+
const auditBatchSize = Number(value);
|
|
159
|
+
if (!Number.isInteger(auditBatchSize) || auditBatchSize < 1) {
|
|
160
|
+
throw new Error("--audit-batch-size requires a positive integer.");
|
|
161
|
+
}
|
|
162
|
+
return parseFrom(index + 2, { ...state, auditBatchSize });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (arg === "--max-audit-input-tokens") {
|
|
166
|
+
const value = argv[index + 1];
|
|
167
|
+
const maxAuditInputTokens = Number(value);
|
|
168
|
+
if (!Number.isInteger(maxAuditInputTokens) || maxAuditInputTokens < 1) {
|
|
169
|
+
throw new Error("--max-audit-input-tokens requires a positive integer.");
|
|
170
|
+
}
|
|
171
|
+
return parseFrom(index + 2, { ...state, maxAuditInputTokens });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (arg === "--audit-concurrency") {
|
|
175
|
+
const value = argv[index + 1];
|
|
176
|
+
const auditConcurrency = Number(value);
|
|
177
|
+
if (!Number.isInteger(auditConcurrency) || auditConcurrency < 1) {
|
|
178
|
+
throw new Error("--audit-concurrency requires a positive integer.");
|
|
179
|
+
}
|
|
180
|
+
return parseFrom(index + 2, { ...state, auditConcurrency });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setInputMode(state: ParseState, next: InputMode): ParseState {
|
|
187
|
+
if (state.explicitInputMode) throw new Error("Choose only one input mode: --since, --stdin, --commit, or --commits.");
|
|
188
|
+
return { ...state, inputMode: next, explicitInputMode: true };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isSafeCommitArg(value: string): boolean {
|
|
193
|
+
return value.length > 0 && !value.startsWith("-") && /^[A-Za-z0-9._/@~^:+-]+$/.test(value);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isHelp(value: string): boolean {
|
|
197
|
+
return value === "--help" || value === "-h";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function isModelId(value: string): value is ModelId {
|
|
201
|
+
return value in MODEL_REGISTRY;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isEngine(value: string): value is Engine {
|
|
205
|
+
return value === "raw-diff" || value === "sem";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function isScoutMode(value: string): value is ScoutMode {
|
|
209
|
+
return value === "counter" || value === "llm";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isAuditContextMode(value: string): value is AuditContextMode {
|
|
213
|
+
return value === "none" || value === "repomix";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isAuditPromptName(value: string): value is AuditPromptName {
|
|
217
|
+
return value === "strict" || value === "high_bar";
|
|
218
|
+
}
|