@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/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @stupify/cli
|
|
2
|
+
|
|
3
|
+
Local-only diagnostic CLI for checking whether AI is making you dumber.
|
|
4
|
+
|
|
5
|
+
This iteration analyzes a recent net diff locally. The default engine uses
|
|
6
|
+
line-sized diff batches. The sem engine uses entity-level changes, scouts
|
|
7
|
+
candidate entity IDs, optionally packs selected candidate files with Repomix,
|
|
8
|
+
and prints findings.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npx @stupify/cli --commit HEAD
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx @stupify/cli --engine sem --commit HEAD
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The sem engine splits findings-audit batches before local inference when the
|
|
19
|
+
prompt exceeds `--max-audit-input-tokens`. Use `--audit-concurrency` to tune
|
|
20
|
+
parallel local audit calls. The default sem scout uses fast deterministic
|
|
21
|
+
signal counters; use `--scout llm` to compare against the older local model
|
|
22
|
+
scout. Use `--audit-context none|repomix` and `--audit-prompt strict|high_bar`
|
|
23
|
+
to run audit ablations without changing code.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
stupify experiment experiments/bevyl-last-week.json
|
|
27
|
+
stupify experiment experiments/bevyl-per-check.json
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The experiment runner shells out to the CLI and writes local JSON plus
|
|
31
|
+
manual-label markdown files under `experiments/results/`.
|
|
32
|
+
|
|
33
|
+
By default, `stupify` is equivalent to `stupify --since "2 weeks ago"`.
|
|
34
|
+
Commit mode analyzes `<commit>^..commit` as a net diff.
|
|
35
|
+
The default registry currently runs nine concise checks for duplicated schemas,
|
|
36
|
+
unnecessary complexity, fake precision, noisy metadata, mega-files,
|
|
37
|
+
over-commenting, lint bypasses, inconsistent patterns, and reinvented
|
|
38
|
+
utilities. `operator_style_mismatch` remains available with `--checks`, but is
|
|
39
|
+
not enabled by default in the sem audit path.
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
npx @stupify/cli --commits 20
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Recent-commits mode analyzes the selected range as one change. Findings are
|
|
46
|
+
range-level for now, not per-commit blame.
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
git diff HEAD~1..HEAD | npx @stupify/cli --stdin
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
stupify --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The package is prepared for the public `@stupify` npm scope. Publishing should
|
|
57
|
+
run the TypeScript build first so the executable points at `dist/stupify.js`.
|
|
58
|
+
|
|
59
|
+
This iteration intentionally does not compare baselines, share data, call hosted
|
|
60
|
+
LLM APIs, integrate with Ollama, or scan the whole repo.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LocalModel } from "./model.ts";
|
|
2
|
+
import type { CandidateContext, DiffBatch, AuditPromptName, AuditReviewResult, FindingsResult, NetDiff, SemCandidate, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
|
|
3
|
+
export declare function scoutBatch(model: LocalModel, batch: DiffBatch, checks: readonly StupifyCheck[], sourceLabel: string): Promise<readonly string[]>;
|
|
4
|
+
export declare function auditCandidates(model: LocalModel, diff: NetDiff, contexts: readonly CandidateContext[], checks: readonly StupifyCheck[]): Promise<FindingsResult>;
|
|
5
|
+
export declare function scoutSemChanges(model: LocalModel, changeSet: SemChangeSet, checks: readonly StupifyCheck[], maxCandidates: number): Promise<readonly SemCandidate[]>;
|
|
6
|
+
export declare function runFindingsAudit(model: LocalModel, changeSet: SemChangeSet, contexts: readonly SemContext[], pack: SemContextPack, checks: readonly StupifyCheck[], request?: Readonly<{
|
|
7
|
+
prompt: string;
|
|
8
|
+
schema: unknown;
|
|
9
|
+
}>): Promise<AuditReviewResult>;
|
|
10
|
+
export declare function findingsAuditRequest(changeSet: SemChangeSet, contexts: readonly SemContext[], pack: SemContextPack, checks: readonly StupifyCheck[], promptName?: AuditPromptName): Readonly<{
|
|
11
|
+
prompt: string;
|
|
12
|
+
schema: unknown;
|
|
13
|
+
}>;
|
|
14
|
+
export declare function countPromptTokens(model: LocalModel, prompt: string): Promise<number>;
|
package/dist/analysis.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { auditPrompt, findingsAuditPrompt, scoutPrompt, semScoutPrompt } from "./prompts.js";
|
|
2
|
+
import { cachedJson, fingerprint } from "./cache.js";
|
|
3
|
+
export async function scoutBatch(model, batch, checks, sourceLabel) {
|
|
4
|
+
const raw = await runJsonPrompt(model, scoutPrompt(batch, checks, sourceLabel), scoutSchema(batch), 0);
|
|
5
|
+
return uncheckedCandidates(raw);
|
|
6
|
+
}
|
|
7
|
+
export async function auditCandidates(model, diff, contexts, checks) {
|
|
8
|
+
if (contexts.length === 0)
|
|
9
|
+
return { findings: [], summary: "No candidate regions found." };
|
|
10
|
+
const raw = await runJsonPrompt(model, auditPrompt(contexts, checks, diff.label), auditSchema(contexts), 0);
|
|
11
|
+
return uncheckedRawAuditResult(raw, diff.id);
|
|
12
|
+
}
|
|
13
|
+
export async function scoutSemChanges(model, changeSet, checks, maxCandidates) {
|
|
14
|
+
const raw = await runJsonPrompt(model, semScoutPrompt(changeSet, checks, maxCandidates), semScoutSchema(changeSet, checks, maxCandidates), 0);
|
|
15
|
+
return uncheckedSemCandidates(raw, changeSet.id);
|
|
16
|
+
}
|
|
17
|
+
export async function runFindingsAudit(model, changeSet, contexts, pack, checks, request = findingsAuditRequest(changeSet, contexts, pack, checks)) {
|
|
18
|
+
if (contexts.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
findings: [],
|
|
21
|
+
summary: "No candidate entities found.",
|
|
22
|
+
stats: { totalTargets: 0, finding: 0, clean: 0, uncertain: 0, invalid: 0 },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const raw = await runJsonPrompt(model, request.prompt, request.schema, 0);
|
|
26
|
+
return uncheckedFindingsAuditResult(raw, changeSet.id, contexts);
|
|
27
|
+
}
|
|
28
|
+
export function findingsAuditRequest(changeSet, contexts, pack, checks, promptName = "strict") {
|
|
29
|
+
return {
|
|
30
|
+
prompt: findingsAuditPrompt(contexts, pack, checks, changeSet.label, promptName),
|
|
31
|
+
schema: findingsAuditSchema(contexts),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function countPromptTokens(model, prompt) {
|
|
35
|
+
const cached = await cachedJson("prompt-tokens", fingerprint({
|
|
36
|
+
version: 1,
|
|
37
|
+
modelId: model.id,
|
|
38
|
+
profile: model.profile,
|
|
39
|
+
prompt,
|
|
40
|
+
}), async () => {
|
|
41
|
+
const response = await fetch(`${model.baseUrl}/tokenize`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ content: prompt }),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok) {
|
|
47
|
+
throw new Error(`llama-server tokenize failed: HTTP ${response.status} ${await response.text()}`);
|
|
48
|
+
}
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
if (!Array.isArray(body.tokens))
|
|
51
|
+
throw new Error("llama-server tokenize returned no tokens.");
|
|
52
|
+
return { count: body.tokens.length };
|
|
53
|
+
});
|
|
54
|
+
return cached.count;
|
|
55
|
+
}
|
|
56
|
+
function findingsAuditSchema(contexts) {
|
|
57
|
+
const targetIds = contexts.map((context) => context.targetId);
|
|
58
|
+
const findingItem = {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
targetId: { type: "string", enum: targetIds },
|
|
62
|
+
why: { type: "string" },
|
|
63
|
+
proof: { type: "string" },
|
|
64
|
+
},
|
|
65
|
+
required: ["targetId", "why", "proof"],
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
};
|
|
68
|
+
const uncertainItem = {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: {
|
|
71
|
+
targetId: { type: "string", enum: targetIds },
|
|
72
|
+
why: { type: "string" },
|
|
73
|
+
},
|
|
74
|
+
required: ["targetId", "why"],
|
|
75
|
+
additionalProperties: false,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
type: "object",
|
|
79
|
+
properties: {
|
|
80
|
+
findings: {
|
|
81
|
+
type: "array",
|
|
82
|
+
items: findingItem,
|
|
83
|
+
},
|
|
84
|
+
uncertain: {
|
|
85
|
+
type: "array",
|
|
86
|
+
items: uncertainItem,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: false,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function auditSchema(contexts) {
|
|
93
|
+
return auditSchemaFromProofs(contexts.map((context) => context.pointer));
|
|
94
|
+
}
|
|
95
|
+
function auditSchemaFromProofs(proofs) {
|
|
96
|
+
return {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
findings: {
|
|
100
|
+
type: "array",
|
|
101
|
+
items: {
|
|
102
|
+
type: "object",
|
|
103
|
+
properties: {
|
|
104
|
+
checkId: { type: "string" },
|
|
105
|
+
why: { type: "string" },
|
|
106
|
+
proof: { type: "string", enum: proofs },
|
|
107
|
+
},
|
|
108
|
+
required: ["checkId", "why", "proof"],
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
summary: { type: "string" },
|
|
113
|
+
},
|
|
114
|
+
required: ["findings", "summary"],
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function semScoutSchema(changeSet, checks, maxCandidates) {
|
|
119
|
+
return {
|
|
120
|
+
type: "object",
|
|
121
|
+
properties: {
|
|
122
|
+
targets: {
|
|
123
|
+
type: "array",
|
|
124
|
+
maxItems: maxCandidates,
|
|
125
|
+
items: {
|
|
126
|
+
type: "object",
|
|
127
|
+
properties: {
|
|
128
|
+
entityId: { type: "string", enum: changeSet.changes.map((change) => change.entityId) },
|
|
129
|
+
checkId: { type: "string", enum: checks.map((check) => check.id) },
|
|
130
|
+
reason: { type: "string" },
|
|
131
|
+
},
|
|
132
|
+
required: ["entityId", "checkId", "reason"],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
required: ["targets"],
|
|
138
|
+
additionalProperties: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function scoutSchema(batch) {
|
|
142
|
+
return {
|
|
143
|
+
type: "object",
|
|
144
|
+
properties: {
|
|
145
|
+
candidates: {
|
|
146
|
+
type: "array",
|
|
147
|
+
maxItems: 3,
|
|
148
|
+
items: { type: "string", enum: batch.hunks.map((hunk) => hunk.pointer) },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ["candidates"],
|
|
152
|
+
additionalProperties: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function uncheckedCandidates(value) {
|
|
156
|
+
return [...(value.candidates ?? [])];
|
|
157
|
+
}
|
|
158
|
+
function uncheckedRawAuditResult(value, sourceId) {
|
|
159
|
+
const output = value;
|
|
160
|
+
const findings = (output.findings ?? []).map((finding) => ({
|
|
161
|
+
sourceId,
|
|
162
|
+
checkId: (finding.checkId ?? ""),
|
|
163
|
+
why: finding.why ?? "",
|
|
164
|
+
proof: finding.proof ?? "",
|
|
165
|
+
}));
|
|
166
|
+
return { findings, summary: output.summary ?? defaultSummary(findings.length) };
|
|
167
|
+
}
|
|
168
|
+
function uncheckedSemCandidates(value, sourceId) {
|
|
169
|
+
const output = value;
|
|
170
|
+
const rawTargets = output.targets ?? output.candidates ?? [];
|
|
171
|
+
return rawTargets.flatMap((candidate) => {
|
|
172
|
+
if (candidate.checkId) {
|
|
173
|
+
return [{
|
|
174
|
+
sourceId,
|
|
175
|
+
targetId: candidate.targetId ?? "",
|
|
176
|
+
entityId: candidate.entityId ?? "",
|
|
177
|
+
checkId: candidate.checkId,
|
|
178
|
+
reason: candidate.reason ?? "",
|
|
179
|
+
}];
|
|
180
|
+
}
|
|
181
|
+
return (candidate.checkIds ?? []).map((checkId) => ({
|
|
182
|
+
sourceId,
|
|
183
|
+
targetId: candidate.targetId ?? "",
|
|
184
|
+
entityId: candidate.entityId ?? "",
|
|
185
|
+
checkId: checkId,
|
|
186
|
+
reason: candidate.reason ?? "",
|
|
187
|
+
}));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
function uncheckedFindingsAuditResult(value, sourceId, contexts) {
|
|
191
|
+
const output = value;
|
|
192
|
+
const targetsById = new Map(contexts.map((context) => [context.targetId, context]));
|
|
193
|
+
const findings = (output.findings ?? []).map((finding) => {
|
|
194
|
+
const target = targetsById.get(finding.targetId ?? "");
|
|
195
|
+
return {
|
|
196
|
+
sourceId,
|
|
197
|
+
checkId: (target?.checkId ?? ""),
|
|
198
|
+
why: finding.why ?? "",
|
|
199
|
+
proof: finding.proof ?? "",
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
const uncertain = output.uncertain?.length ?? 0;
|
|
203
|
+
const totalTargets = contexts.length;
|
|
204
|
+
return {
|
|
205
|
+
findings,
|
|
206
|
+
summary: defaultSummary(findings.length),
|
|
207
|
+
stats: {
|
|
208
|
+
totalTargets,
|
|
209
|
+
finding: findings.length,
|
|
210
|
+
clean: Math.max(0, totalTargets - findings.length - uncertain),
|
|
211
|
+
uncertain,
|
|
212
|
+
invalid: 0,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function defaultSummary(findingCount) {
|
|
217
|
+
return findingCount === 0
|
|
218
|
+
? "No clear judgment-offload signal found."
|
|
219
|
+
: `${findingCount} finding review${findingCount === 1 ? "" : "s"} accepted.`;
|
|
220
|
+
}
|
|
221
|
+
async function runJsonPrompt(model, prompt, schema, temperature) {
|
|
222
|
+
return cachedJson("model-json", fingerprint({
|
|
223
|
+
version: 1,
|
|
224
|
+
modelId: model.id,
|
|
225
|
+
profile: model.profile,
|
|
226
|
+
prompt,
|
|
227
|
+
schema,
|
|
228
|
+
temperature,
|
|
229
|
+
}), () => runJsonPromptUncached(model, prompt, schema, temperature));
|
|
230
|
+
}
|
|
231
|
+
async function runJsonPromptUncached(model, prompt, schema, temperature) {
|
|
232
|
+
const first = await complete(model, prompt, schema, temperature);
|
|
233
|
+
const parsed = parseJson(first);
|
|
234
|
+
if (parsed.ok)
|
|
235
|
+
return parsed.value;
|
|
236
|
+
const retry = await complete(model, `${prompt}
|
|
237
|
+
|
|
238
|
+
Your previous response was not valid JSON. Return the requested JSON object only.`, schema, temperature);
|
|
239
|
+
const retryParsed = parseJson(retry);
|
|
240
|
+
if (retryParsed.ok)
|
|
241
|
+
return retryParsed.value;
|
|
242
|
+
console.error("Raw model output:");
|
|
243
|
+
console.error(retry);
|
|
244
|
+
throw new Error("Model returned invalid JSON.");
|
|
245
|
+
}
|
|
246
|
+
async function complete(model, prompt, schema, temperature) {
|
|
247
|
+
const response = await fetch(`${model.baseUrl}/v1/chat/completions`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: { "content-type": "application/json" },
|
|
250
|
+
body: JSON.stringify({
|
|
251
|
+
model: model.id,
|
|
252
|
+
messages: [{ role: "user", content: prompt }],
|
|
253
|
+
temperature,
|
|
254
|
+
response_format: {
|
|
255
|
+
type: "json_object",
|
|
256
|
+
schema,
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
});
|
|
260
|
+
if (!response.ok)
|
|
261
|
+
throw new Error(`llama-server request failed: HTTP ${response.status} ${await response.text()}`);
|
|
262
|
+
const body = await response.json();
|
|
263
|
+
const content = body.choices?.[0]?.message?.content;
|
|
264
|
+
if (typeof content !== "string")
|
|
265
|
+
throw new Error("llama-server returned no message content.");
|
|
266
|
+
return content;
|
|
267
|
+
}
|
|
268
|
+
function parseJson(raw) {
|
|
269
|
+
try {
|
|
270
|
+
const value = JSON.parse(raw);
|
|
271
|
+
return { ok: true, value };
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return { ok: false };
|
|
275
|
+
}
|
|
276
|
+
}
|
package/dist/batcher.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const DEFAULT_BATCH_LINES = 1_000;
|
|
2
|
+
export function batchDiff(diff, linesPerBatch = DEFAULT_BATCH_LINES) {
|
|
3
|
+
const hunks = parseHunks(diff).flatMap((item) => splitLargeHunk(item, linesPerBatch));
|
|
4
|
+
const initialState = {
|
|
5
|
+
batches: [],
|
|
6
|
+
current: [],
|
|
7
|
+
currentLines: 0,
|
|
8
|
+
batchNumber: 1,
|
|
9
|
+
};
|
|
10
|
+
const finalized = hunks.reduce((state, hunk) => {
|
|
11
|
+
const needsFlush = state.current.length > 0 &&
|
|
12
|
+
state.currentLines + hunk.lineCount > linesPerBatch;
|
|
13
|
+
const flushed = needsFlush ? flush(state) : state;
|
|
14
|
+
return {
|
|
15
|
+
...flushed,
|
|
16
|
+
current: [...flushed.current, toDiffHunk(hunk, flushed.batchNumber)],
|
|
17
|
+
currentLines: flushed.currentLines + hunk.lineCount,
|
|
18
|
+
};
|
|
19
|
+
}, initialState);
|
|
20
|
+
return finalized.current.length > 0
|
|
21
|
+
? [...finalized.batches, toBatch(finalized.batchNumber, finalized.current)]
|
|
22
|
+
: finalized.batches;
|
|
23
|
+
function flush(state) {
|
|
24
|
+
return {
|
|
25
|
+
batches: [...state.batches, toBatch(state.batchNumber, state.current)],
|
|
26
|
+
current: [],
|
|
27
|
+
currentLines: 0,
|
|
28
|
+
batchNumber: state.batchNumber + 1,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function allHunks(batches) {
|
|
33
|
+
return batches.flatMap((batch) => batch.hunks);
|
|
34
|
+
}
|
|
35
|
+
function parseHunks(diff) {
|
|
36
|
+
const lines = diff.split(/\r?\n/);
|
|
37
|
+
const initialState = {
|
|
38
|
+
hunks: [],
|
|
39
|
+
filePath: "unknown",
|
|
40
|
+
fileIndex: 0,
|
|
41
|
+
hunkIndex: 0,
|
|
42
|
+
fileHeader: [],
|
|
43
|
+
hunkLines: null,
|
|
44
|
+
};
|
|
45
|
+
const finalState = lines.reduce((state, line) => {
|
|
46
|
+
const fileMatch = /^diff --git a\/.+ b\/(.+)$/.exec(line);
|
|
47
|
+
if (fileMatch) {
|
|
48
|
+
const flushed = flush(state);
|
|
49
|
+
return {
|
|
50
|
+
...flushed,
|
|
51
|
+
fileIndex: flushed.fileIndex + 1,
|
|
52
|
+
hunkIndex: 0,
|
|
53
|
+
filePath: fileMatch[1],
|
|
54
|
+
fileHeader: [line],
|
|
55
|
+
hunkLines: null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (line.startsWith("@@ ")) {
|
|
59
|
+
const flushed = flush(state);
|
|
60
|
+
return {
|
|
61
|
+
...flushed,
|
|
62
|
+
hunkIndex: flushed.hunkIndex + 1,
|
|
63
|
+
hunkLines: [...flushed.fileHeader, line],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (state.hunkLines)
|
|
67
|
+
return { ...state, hunkLines: [...state.hunkLines, line] };
|
|
68
|
+
if (state.fileHeader.length > 0)
|
|
69
|
+
return { ...state, fileHeader: [...state.fileHeader, line] };
|
|
70
|
+
return state;
|
|
71
|
+
}, initialState);
|
|
72
|
+
return flush(finalState).hunks;
|
|
73
|
+
function flush(state) {
|
|
74
|
+
if (!state.hunkLines)
|
|
75
|
+
return state;
|
|
76
|
+
const fileId = `file-${pad(state.fileIndex)}`;
|
|
77
|
+
const hunkId = `hunk-${pad(state.hunkIndex)}`;
|
|
78
|
+
const text = state.hunkLines.join("\n").trimEnd();
|
|
79
|
+
const nextHunk = {
|
|
80
|
+
fileId,
|
|
81
|
+
hunkId,
|
|
82
|
+
filePath: state.filePath,
|
|
83
|
+
text,
|
|
84
|
+
lineCount: countLines(text),
|
|
85
|
+
};
|
|
86
|
+
return { ...state, hunks: [...state.hunks, nextHunk], hunkLines: null };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function splitLargeHunk(hunk, linesPerBatch) {
|
|
90
|
+
if (hunk.lineCount <= linesPerBatch)
|
|
91
|
+
return [hunk];
|
|
92
|
+
const lines = hunk.text.split(/\r?\n/);
|
|
93
|
+
const chunkCount = Math.ceil(lines.length / linesPerBatch);
|
|
94
|
+
return Array.from({ length: chunkCount }, (_, chunkIndex) => {
|
|
95
|
+
const start = chunkIndex * linesPerBatch;
|
|
96
|
+
const text = lines.slice(start, start + linesPerBatch).join("\n");
|
|
97
|
+
return {
|
|
98
|
+
...hunk,
|
|
99
|
+
hunkId: `${hunk.hunkId}-part-${pad(chunkIndex + 1)}`,
|
|
100
|
+
text,
|
|
101
|
+
lineCount: countLines(text),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function toDiffHunk(hunk, batchNumber) {
|
|
106
|
+
const batchId = `batch-${pad(batchNumber)}`;
|
|
107
|
+
return {
|
|
108
|
+
...hunk,
|
|
109
|
+
batchId,
|
|
110
|
+
pointer: `${batchId}:${hunk.fileId}:${hunk.hunkId}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function toBatch(batchNumber, hunks) {
|
|
114
|
+
const id = `batch-${pad(batchNumber)}`;
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
hunks,
|
|
118
|
+
text: hunks.map(formatHunkForSearch).join("\n\n"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function formatHunkForSearch(hunk) {
|
|
122
|
+
return `POINTER ${hunk.pointer}
|
|
123
|
+
FILE ${hunk.fileId}
|
|
124
|
+
PATH ${hunk.filePath}
|
|
125
|
+
${searchView(hunk.text)}`;
|
|
126
|
+
}
|
|
127
|
+
function searchView(text) {
|
|
128
|
+
return text
|
|
129
|
+
.split(/\r?\n/)
|
|
130
|
+
.filter((line) => line.startsWith("diff --git ") ||
|
|
131
|
+
line.startsWith("--- ") ||
|
|
132
|
+
line.startsWith("+++ ") ||
|
|
133
|
+
line.startsWith("@@ ") ||
|
|
134
|
+
(line.startsWith("+") && !line.startsWith("+++")))
|
|
135
|
+
.join("\n");
|
|
136
|
+
}
|
|
137
|
+
function countLines(value) {
|
|
138
|
+
return value.length === 0 ? 0 : value.split(/\r?\n/).length;
|
|
139
|
+
}
|
|
140
|
+
function pad(value) {
|
|
141
|
+
return String(value).padStart(3, "0");
|
|
142
|
+
}
|
package/dist/cache.d.ts
ADDED
package/dist/cache.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
export function fingerprint(value) {
|
|
6
|
+
const text = typeof value === "string" ? value : stableStringify(value);
|
|
7
|
+
return createHash("sha256").update(text).digest("hex");
|
|
8
|
+
}
|
|
9
|
+
export async function cachedJson(namespace, key, compute) {
|
|
10
|
+
const filePath = cachePath(namespace, key);
|
|
11
|
+
try {
|
|
12
|
+
const value = JSON.parse(await readFile(filePath, "utf8"));
|
|
13
|
+
console.error(`cache hit ${namespace} ${key.slice(0, 12)}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
console.error(`cache miss ${namespace} ${key.slice(0, 12)}`);
|
|
18
|
+
}
|
|
19
|
+
const value = await compute();
|
|
20
|
+
await writeCache(filePath, value).catch(() => undefined);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function cachePath(namespace, key) {
|
|
24
|
+
return path.join(cacheRoot(), "intermediate-v1", safeNamespace(namespace), `${key}.json`);
|
|
25
|
+
}
|
|
26
|
+
async function writeCache(filePath, value) {
|
|
27
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
28
|
+
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(tempPath, JSON.stringify(value), "utf8");
|
|
31
|
+
await rename(tempPath, filePath);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
await rm(tempPath, { force: true });
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function cacheRoot() {
|
|
39
|
+
if (process.env.STUPIFY_CACHE_DIR)
|
|
40
|
+
return process.env.STUPIFY_CACHE_DIR;
|
|
41
|
+
if (process.env.XDG_CACHE_HOME)
|
|
42
|
+
return path.join(process.env.XDG_CACHE_HOME, "stupify");
|
|
43
|
+
if (platform() === "darwin")
|
|
44
|
+
return path.join(homedir(), "Library", "Caches", "stupify");
|
|
45
|
+
if (platform() === "win32" && process.env.LOCALAPPDATA)
|
|
46
|
+
return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
|
|
47
|
+
return path.join(homedir(), ".cache", "stupify");
|
|
48
|
+
}
|
|
49
|
+
function safeNamespace(value) {
|
|
50
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
51
|
+
}
|
|
52
|
+
function stableStringify(value) {
|
|
53
|
+
if (value === null || typeof value !== "object")
|
|
54
|
+
return JSON.stringify(value);
|
|
55
|
+
if (Array.isArray(value))
|
|
56
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
57
|
+
const record = value;
|
|
58
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
|
|
59
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { allHunks } from "./batcher.js";
|
|
2
|
+
const MAX_CONTEXT_LINES = 80;
|
|
3
|
+
export function candidateContexts(batches, candidatePointers) {
|
|
4
|
+
const hunks = allHunks(batches);
|
|
5
|
+
const byPointer = new Map(hunks.map((hunk) => [hunk.pointer, hunk]));
|
|
6
|
+
const uniquePointers = [...new Set(candidatePointers)]
|
|
7
|
+
.sort((left, right) => hunkPriority(byPointer.get(right)) - hunkPriority(byPointer.get(left)));
|
|
8
|
+
return uniquePointers.flatMap((pointer) => {
|
|
9
|
+
const hunk = byPointer.get(pointer);
|
|
10
|
+
if (!hunk)
|
|
11
|
+
return [];
|
|
12
|
+
return [{ pointer, text: formatHunk(hunk) }];
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function formatHunk(hunk) {
|
|
16
|
+
return `PATH ${hunk.filePath}
|
|
17
|
+
${shorten(hunk.text)}`;
|
|
18
|
+
}
|
|
19
|
+
function shorten(text) {
|
|
20
|
+
const lines = text.split(/\r?\n/);
|
|
21
|
+
if (lines.length <= MAX_CONTEXT_LINES)
|
|
22
|
+
return text;
|
|
23
|
+
return `${lines.slice(0, MAX_CONTEXT_LINES).join("\n")}
|
|
24
|
+
[stupify: hunk shortened after ${MAX_CONTEXT_LINES} lines]`;
|
|
25
|
+
}
|
|
26
|
+
function hunkPriority(hunk) {
|
|
27
|
+
if (!hunk)
|
|
28
|
+
return 0;
|
|
29
|
+
const text = hunk.text;
|
|
30
|
+
let priority = 0;
|
|
31
|
+
if (/^\+export\s+type\s|\+export\s+interface\s|\+type\s|\+interface\s/m.test(text))
|
|
32
|
+
priority += 3;
|
|
33
|
+
if (/^\+export\s+function\s|\+function\s/m.test(text))
|
|
34
|
+
priority += 2;
|
|
35
|
+
if (/\.map\(|=>\s*\(\{|=>\s*\{/m.test(text))
|
|
36
|
+
priority += 2;
|
|
37
|
+
if (/payload|schema|dto|response|result/i.test(text))
|
|
38
|
+
priority += 1;
|
|
39
|
+
return priority;
|
|
40
|
+
}
|
package/dist/checks.d.ts
ADDED