@stupify/cli 0.0.14 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -1
- package/dist/analysis.js +3 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/doctor.d.ts +14 -2
- package/dist/doctor.js +12 -0
- package/dist/git.d.ts +6 -1
- package/dist/git.js +71 -1
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +18 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +165 -21
- package/dist/sem-provider.js +3 -0
- package/dist/stupify.js +106 -13
- package/dist/trace.d.ts +2 -0
- package/dist/trace.js +22 -0
- package/dist/types.d.ts +13 -0
- package/package.json +6 -4
- package/src/analysis.ts +3 -0
- package/src/constants.ts +1 -1
- package/src/doctor.ts +27 -1
- package/src/git.ts +76 -2
- package/src/hooks.ts +17 -0
- package/src/render.ts +196 -22
- package/src/sem-provider.ts +6 -3
- package/src/stupify.ts +113 -23
- package/src/trace.ts +23 -0
- package/src/types.ts +14 -0
package/src/render.ts
CHANGED
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
import { VERSION } from "./constants.ts";
|
|
2
2
|
import type { SearchCommand, SearchRunJson } from "./types.ts";
|
|
3
|
-
import { format } from "./ui.ts";
|
|
3
|
+
import { format, type CliUi } from "./ui.ts";
|
|
4
4
|
|
|
5
5
|
export function renderSearchRun(run: SearchRunJson, command: SearchCommand): string {
|
|
6
6
|
if (command.json) return JSON.stringify(run, null, 2);
|
|
7
|
+
return renderSearchHumanText(run, command);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void {
|
|
11
|
+
if (command.json) {
|
|
12
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
ui.warn("Search skipped: input is too large for precise local search.");
|
|
18
|
+
ui.note(oversizedText(run, command), "Skipped");
|
|
19
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
24
|
+
ui.success("Search complete: no search targets found.");
|
|
25
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
26
|
+
ui.outro("No judgment-offload signals found.");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (run.matches.length === 0) {
|
|
31
|
+
ui.success("Search complete: no judgment-offload signals found.");
|
|
32
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
33
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
|
|
38
|
+
ui.note(matchSummaryText(run, command), "Summary");
|
|
39
|
+
for (const group of groupMatchesByFile(run.matches)) {
|
|
40
|
+
ui.note(renderMatchGroup(group, run), group.filePath);
|
|
41
|
+
}
|
|
42
|
+
ui.outro(summaryLine(run));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string {
|
|
46
|
+
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
47
|
+
return `${format.heading("Search skipped")}
|
|
48
|
+
${oversizedText(run, command)}
|
|
49
|
+
Warn-only. Nothing blocked.`;
|
|
18
50
|
}
|
|
19
51
|
|
|
20
52
|
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
@@ -29,18 +61,12 @@ ${format.label("Patterns:")} ${run.patterns.join(", ")}
|
|
|
29
61
|
${format.success("No judgment-offload signals found.")}`;
|
|
30
62
|
}
|
|
31
63
|
|
|
64
|
+
const groups = groupMatchesByFile(run.matches);
|
|
32
65
|
return `${slopHeading()}
|
|
33
|
-
${
|
|
34
|
-
|
|
35
|
-
${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
|
|
36
|
-
${match.reason}
|
|
37
|
-
|
|
38
|
-
\`\`\`
|
|
39
|
-
${match.snapshot ?? match.proof}
|
|
40
|
-
\`\`\`
|
|
41
|
-
${format.muted(match.proof)}
|
|
66
|
+
${matchSummaryText(run, command)}
|
|
42
67
|
|
|
43
|
-
${
|
|
68
|
+
${groups.map((group) => `${format.heading(group.filePath)}
|
|
69
|
+
${renderMatchGroup(group, run)}`).join("\n\n")}
|
|
44
70
|
${format.muted(summaryLine(run))}`;
|
|
45
71
|
}
|
|
46
72
|
|
|
@@ -92,6 +118,151 @@ Not included:
|
|
|
92
118
|
`;
|
|
93
119
|
}
|
|
94
120
|
|
|
121
|
+
type MatchGroup = Readonly<{
|
|
122
|
+
filePath: string;
|
|
123
|
+
matches: SearchRunJson["matches"];
|
|
124
|
+
}>;
|
|
125
|
+
|
|
126
|
+
function oversizedText(run: SearchRunJson, command: SearchCommand): string {
|
|
127
|
+
const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
|
|
128
|
+
return [
|
|
129
|
+
`Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
|
|
130
|
+
`Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
|
|
131
|
+
"Stupify skipped the search rather than review truncated context.",
|
|
132
|
+
`Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function cleanSummaryText(run: SearchRunJson): string {
|
|
137
|
+
return [
|
|
138
|
+
`Patterns: ${run.patterns.join(", ")}`,
|
|
139
|
+
run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
|
|
140
|
+
].filter(Boolean).join("\n");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function matchSummaryText(run: SearchRunJson, command: SearchCommand): string {
|
|
144
|
+
const fileCount = groupMatchesByFile(run.matches).length;
|
|
145
|
+
const fileNoun = fileCount === 1 ? "file" : "files";
|
|
146
|
+
return [
|
|
147
|
+
`${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
|
|
148
|
+
`${committerLabel(run)} · ${sourceLabel(command)}`,
|
|
149
|
+
"Warn-only. Nothing blocked.",
|
|
150
|
+
"",
|
|
151
|
+
patternSummaryLine(run),
|
|
152
|
+
].filter((line) => line !== null).join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function patternSummaryLine(run: SearchRunJson): string {
|
|
156
|
+
const counts = new Map<string, number>();
|
|
157
|
+
for (const match of run.matches) counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
|
|
158
|
+
return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function groupMatchesByFile(matches: SearchRunJson["matches"]): readonly MatchGroup[] {
|
|
162
|
+
const groups = new Map<string, SearchRunJson["matches"][number][]>();
|
|
163
|
+
for (const match of matches) {
|
|
164
|
+
const filePath = proofFilePath(match.proof);
|
|
165
|
+
const group = groups.get(filePath) ?? [];
|
|
166
|
+
group.push(match);
|
|
167
|
+
groups.set(filePath, group);
|
|
168
|
+
}
|
|
169
|
+
return [...groups.entries()].map(([filePath, groupedMatches]) => ({
|
|
170
|
+
filePath,
|
|
171
|
+
matches: groupedMatches,
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderMatchGroup(group: MatchGroup, run: SearchRunJson): string {
|
|
176
|
+
return group.matches.map((match, index) => {
|
|
177
|
+
const lines = [
|
|
178
|
+
matchHeadline(match, run, index),
|
|
179
|
+
match.reason,
|
|
180
|
+
match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
|
|
181
|
+
format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
|
|
182
|
+
match.checkWhy ?? "This pattern may indicate judgment-offload.",
|
|
183
|
+
];
|
|
184
|
+
return lines.filter(Boolean).join("\n");
|
|
185
|
+
}).join("\n\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function matchHeadline(match: SearchRunJson["matches"][number], run: SearchRunJson, index: number): string {
|
|
189
|
+
return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function patternLabel(match: SearchRunJson["matches"][number]): string {
|
|
193
|
+
return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function headlineArgs(match: SearchRunJson["matches"][number]): string {
|
|
197
|
+
const destination = entityNameFromProof(match.proof);
|
|
198
|
+
const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
|
|
199
|
+
if (source && destination && source !== destination) return `${codeLabel(source)} -> ${codeLabel(destination)}`;
|
|
200
|
+
if (destination) return codeLabel(destination);
|
|
201
|
+
return codeLabel(match.targetId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function matchBlameLabel(match: SearchRunJson["matches"][number], run: SearchRunJson): string {
|
|
205
|
+
return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function blameSummaryLabel(blame: NonNullable<SearchRunJson["matches"][number]["blame"]>): string {
|
|
209
|
+
return `${blame.author} (${blame.subject})`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function runLevelBlameLabel(run: SearchRunJson): string {
|
|
213
|
+
const author = committerLabel(run);
|
|
214
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
215
|
+
return subject ? `${author} (${subject})` : author;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function commitSubjectSuffix(run: SearchRunJson): string {
|
|
219
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
220
|
+
return subject ? ` · commit: ${subject}` : "";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function firstHumanSubject(subjects: readonly string[]): string | undefined {
|
|
224
|
+
return subjects.map((subject) => subject.trim()).find(Boolean);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function codeLabel(value: string): string {
|
|
228
|
+
return `\`${value}\``;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function titleCase(value: string): string {
|
|
232
|
+
return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function entityNameFromProof(proof: string): string | undefined {
|
|
236
|
+
const parts = proof.split("::");
|
|
237
|
+
return parts[2] || parts[1] || undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function firstBacktickedToken(value: string): string | undefined {
|
|
241
|
+
const match = /`([^`]+)`/.exec(value);
|
|
242
|
+
return cleanToken(match?.[1]);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function firstLikelySource(value: string, destination?: string): string | undefined {
|
|
246
|
+
const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
|
|
247
|
+
.map((match) => cleanToken(match[0]))
|
|
248
|
+
.filter((token): token is string => Boolean(token));
|
|
249
|
+
return tokens.find((token) => token !== destination && token !== "The");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function cleanToken(value: string | undefined): string | undefined {
|
|
253
|
+
const token = value?.trim().replace(/[.,;:]+$/, "");
|
|
254
|
+
return token || undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function proofFilePath(proof: string): string {
|
|
258
|
+
return proof.split("::")[0] || proof;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function proofDetail(proof: string): string {
|
|
262
|
+
const [, ...rest] = proof.split("::");
|
|
263
|
+
return rest.length > 0 ? `::${rest.join("::")}` : proof;
|
|
264
|
+
}
|
|
265
|
+
|
|
95
266
|
function sourceHint(command: SearchCommand): string {
|
|
96
267
|
if (command.kind === "staged") return "--staged";
|
|
97
268
|
if (command.kind === "since") return `--since "${command.since}"`;
|
|
@@ -149,6 +320,9 @@ function sinceLabel(since: string): string {
|
|
|
149
320
|
}
|
|
150
321
|
|
|
151
322
|
function summaryLine(run: SearchRunJson): string {
|
|
152
|
-
|
|
153
|
-
|
|
323
|
+
return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function signalNoun(count: number): string {
|
|
327
|
+
return count === 1 ? "signal" : "signals";
|
|
154
328
|
}
|
package/src/sem-provider.ts
CHANGED
|
@@ -65,6 +65,7 @@ function emptyChangeSet(
|
|
|
65
65
|
base: label,
|
|
66
66
|
target: label,
|
|
67
67
|
committers,
|
|
68
|
+
commitSubjects: undefined,
|
|
68
69
|
contextCwd: process.cwd(),
|
|
69
70
|
cleanup: async () => undefined,
|
|
70
71
|
changes: [],
|
|
@@ -109,9 +110,10 @@ async function semChangeSetFromPatch(
|
|
|
109
110
|
id: sourceId(label),
|
|
110
111
|
label,
|
|
111
112
|
base: label,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
113
|
+
target: label,
|
|
114
|
+
committers,
|
|
115
|
+
commitSubjects: undefined,
|
|
116
|
+
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
115
117
|
}),
|
|
116
118
|
contextCwd: process.cwd(),
|
|
117
119
|
cleanup: async () => undefined,
|
|
@@ -240,6 +242,7 @@ function normalizeSemDiff(value: unknown, range: SourceRange): SemChangeSet {
|
|
|
240
242
|
base: range.base,
|
|
241
243
|
target: range.target,
|
|
242
244
|
committers: range.committers,
|
|
245
|
+
commitSubjects: range.commitSubjects,
|
|
243
246
|
contextCwd: process.cwd(),
|
|
244
247
|
cleanup: async () => undefined,
|
|
245
248
|
changes,
|
package/src/stupify.ts
CHANGED
|
@@ -6,11 +6,12 @@ import { countPromptTokens, runSearch, searchRequest, type SearchRequest } from
|
|
|
6
6
|
import { searchChecks } from "./checks.ts";
|
|
7
7
|
import { parseCommand } from "./command.ts";
|
|
8
8
|
import { counterScoutPlan } from "./counter-scout.ts";
|
|
9
|
-
import { runDoctor } from "./doctor.ts";
|
|
10
|
-
import {
|
|
9
|
+
import { renderDoctorToUi, runDoctor } from "./doctor.ts";
|
|
10
|
+
import { blameEntity } from "./git.ts";
|
|
11
|
+
import { renderHookResultToUi, runHookCommand } from "./hooks.ts";
|
|
11
12
|
import { firstRunModelBootstrap, loadLocalModel } from "./model.ts";
|
|
12
13
|
import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.ts";
|
|
13
|
-
import { helpText, renderSearchRun } from "./render.ts";
|
|
14
|
+
import { helpText, renderSearchRun, renderSearchRunToUi } from "./render.ts";
|
|
14
15
|
import {
|
|
15
16
|
effectiveMaxCandidates,
|
|
16
17
|
effectiveMaxSearchInputTokens,
|
|
@@ -30,27 +31,36 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
|
30
31
|
try {
|
|
31
32
|
const command = parseCommand(argv);
|
|
32
33
|
if (command.kind === "help") {
|
|
33
|
-
ui.
|
|
34
|
+
ui.intro("stupify");
|
|
35
|
+
ui.note(helpText().trim(), "Help");
|
|
36
|
+
ui.outro("Local-only. Warn-only.");
|
|
34
37
|
return 0;
|
|
35
38
|
}
|
|
36
39
|
if (command.kind === "hook") {
|
|
37
|
-
ui.
|
|
40
|
+
ui.intro("stupify");
|
|
41
|
+
renderHookResultToUi(await runHookCommand(command.action), ui);
|
|
42
|
+
ui.outro("Hook mode is warn-only. Commits are not blocked.");
|
|
38
43
|
return 0;
|
|
39
44
|
}
|
|
40
45
|
if (command.kind === "doctor") {
|
|
41
46
|
const result = await runDoctor();
|
|
42
|
-
ui.
|
|
47
|
+
ui.intro("stupify");
|
|
48
|
+
renderDoctorToUi(result, ui);
|
|
49
|
+
ui.outro(result.exitCode === 0 ? "Ready." : "Fix missing required dependencies, then rerun doctor.");
|
|
43
50
|
return result.exitCode;
|
|
44
51
|
}
|
|
45
52
|
if (command.kind === "bench-search") {
|
|
46
53
|
const { runSearchBench } = await import("./search-bench.ts");
|
|
47
|
-
ui.
|
|
54
|
+
ui.intro("stupify");
|
|
55
|
+
ui.note(await runSearchBench(command.configPath), "Search bench");
|
|
56
|
+
ui.outro("Bench complete.");
|
|
48
57
|
return 0;
|
|
49
58
|
}
|
|
50
59
|
|
|
51
60
|
ui = createCliUi({ quiet: command.json });
|
|
52
61
|
const run = await runSearchCommand(command, startedAt, ui);
|
|
53
|
-
ui.writeStdout(renderSearchRun(run, command));
|
|
62
|
+
if (command.json) ui.writeStdout(renderSearchRun(run, command));
|
|
63
|
+
else renderSearchRunToUi(run, command, ui);
|
|
54
64
|
return 0;
|
|
55
65
|
} catch (error) {
|
|
56
66
|
ui.error(error instanceof Error ? error.message : String(error), { force: true });
|
|
@@ -59,11 +69,28 @@ export async function main(argv = process.argv.slice(2)): Promise<number> {
|
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
export async function runSearchCommand(command: SearchCommand, startedAt: number, ui = createCliUi({ quiet: command.json })): Promise<SearchRunJson> {
|
|
72
|
+
const activeSpans = new Map<string, ReturnType<CliUi["spinner"]>>();
|
|
62
73
|
const t = createTracer({
|
|
63
74
|
writeLine: () => undefined,
|
|
64
75
|
onEvent: (event) => {
|
|
65
76
|
if (command.json) return;
|
|
66
|
-
|
|
77
|
+
if (event.phase === "start") {
|
|
78
|
+
activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const active = activeSpans.get(event.name);
|
|
83
|
+
activeSpans.delete(event.name);
|
|
84
|
+
const message = event.phase === "error"
|
|
85
|
+
? formatErrorStep(event.name, event.ms)
|
|
86
|
+
: formatStep(event.name, event.ms, event.count, event.detail);
|
|
87
|
+
if (!active) {
|
|
88
|
+
if (event.phase === "error") ui.error(message);
|
|
89
|
+
else ui.step(message);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (event.phase === "error") active.error(message);
|
|
93
|
+
else active.stop(message);
|
|
67
94
|
},
|
|
68
95
|
});
|
|
69
96
|
|
|
@@ -100,6 +127,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
100
127
|
elapsedMs: Date.now() - startedAt,
|
|
101
128
|
modelCalls: 0,
|
|
102
129
|
committers: changeSet.committers,
|
|
130
|
+
commitSubjects: changeSet.commitSubjects,
|
|
103
131
|
skipped: true,
|
|
104
132
|
skipReason: "no_candidates",
|
|
105
133
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -143,6 +171,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
143
171
|
elapsedMs: Date.now() - startedAt,
|
|
144
172
|
modelCalls: 0,
|
|
145
173
|
committers: changeSet.committers,
|
|
174
|
+
commitSubjects: changeSet.commitSubjects,
|
|
146
175
|
skipped: true,
|
|
147
176
|
skipReason: "no_candidates",
|
|
148
177
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -162,17 +191,27 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
162
191
|
const pack = profile?.context === "sem" || searchContexts.length === contexts.length
|
|
163
192
|
? initialPack
|
|
164
193
|
: await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
|
|
165
|
-
const batches = await
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
194
|
+
const { value: batches } = await t.trace(
|
|
195
|
+
"search.batches",
|
|
196
|
+
() => buildSearchBatches({
|
|
197
|
+
command,
|
|
198
|
+
changeSet,
|
|
199
|
+
contexts: searchContexts,
|
|
200
|
+
initialPack: pack,
|
|
201
|
+
checks,
|
|
202
|
+
profile,
|
|
203
|
+
includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
|
|
204
|
+
maxSearchInputTokens,
|
|
205
|
+
baseRepomixConfig,
|
|
206
|
+
}),
|
|
207
|
+
{
|
|
208
|
+
startDetail: `${searchContexts.length} targets`,
|
|
209
|
+
count: (result) => result.batches.length,
|
|
210
|
+
detail: (result) => result.wasSplit
|
|
211
|
+
? `${result.skippedTargets} oversized targets skipped`
|
|
212
|
+
: `${result.estimatedInputTokens} estimated tokens`,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
176
215
|
|
|
177
216
|
if (batches.batches.length === 0) {
|
|
178
217
|
return {
|
|
@@ -187,6 +226,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
187
226
|
inputTokens: batches.estimatedInputTokens,
|
|
188
227
|
inputTokenCap: maxSearchInputTokens,
|
|
189
228
|
committers: changeSet.committers,
|
|
229
|
+
commitSubjects: changeSet.commitSubjects,
|
|
190
230
|
skipped: true,
|
|
191
231
|
skipReason: "input_too_large",
|
|
192
232
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -222,7 +262,14 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
222
262
|
let inputTokens = 0;
|
|
223
263
|
let exactSkippedTargets = batches.skippedTargets;
|
|
224
264
|
for (const batch of batches.batches) {
|
|
225
|
-
const batchInputTokens = await
|
|
265
|
+
const { value: batchInputTokens } = await t.trace(
|
|
266
|
+
"prompt.tokens",
|
|
267
|
+
() => countPromptTokens(model, batch.request.prompt),
|
|
268
|
+
{
|
|
269
|
+
startDetail: `${batch.contexts.length} targets`,
|
|
270
|
+
count: (tokens) => tokens,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
226
273
|
inputTokens += batchInputTokens;
|
|
227
274
|
if (batchInputTokens > maxSearchInputTokens) {
|
|
228
275
|
exactSkippedTargets += batch.contexts.length;
|
|
@@ -234,12 +281,15 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
234
281
|
const { value } = await t.trace(
|
|
235
282
|
"search.model",
|
|
236
283
|
() => runSearch(model, batch.request),
|
|
237
|
-
{
|
|
284
|
+
{
|
|
285
|
+
startDetail: `${batch.contexts.length} targets`,
|
|
286
|
+
count: (v) => v.length,
|
|
287
|
+
},
|
|
238
288
|
);
|
|
239
289
|
modelCalls += 1;
|
|
240
290
|
matches.push(...withCheckWhy(value, checks));
|
|
241
291
|
}
|
|
242
|
-
const uniqueMatches = dedupeMatches(matches);
|
|
292
|
+
const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
|
|
243
293
|
|
|
244
294
|
return {
|
|
245
295
|
schemaVersion: "search.v1",
|
|
@@ -253,6 +303,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
253
303
|
inputTokens,
|
|
254
304
|
inputTokenCap: maxSearchInputTokens,
|
|
255
305
|
committers: changeSet.committers,
|
|
306
|
+
commitSubjects: changeSet.commitSubjects,
|
|
256
307
|
filesChanged: changeSet.summary.fileCount,
|
|
257
308
|
entitiesScanned: changeSet.summary.total,
|
|
258
309
|
candidates: contexts.length,
|
|
@@ -287,10 +338,29 @@ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyC
|
|
|
287
338
|
const checksById = new Map(checks.map((check) => [check.id, check]));
|
|
288
339
|
return matches.map((match) => ({
|
|
289
340
|
...match,
|
|
341
|
+
patternName: checksById.get(match.patternId)?.name,
|
|
290
342
|
checkWhy: checksById.get(match.patternId)?.why,
|
|
291
343
|
}));
|
|
292
344
|
}
|
|
293
345
|
|
|
346
|
+
async function withEntityBlame(
|
|
347
|
+
matches: readonly SearchMatch[],
|
|
348
|
+
targetRev: string,
|
|
349
|
+
command: SearchCommand,
|
|
350
|
+
): Promise<readonly SearchMatch[]> {
|
|
351
|
+
if (command.kind === "staged" || command.kind === "stdin") return matches;
|
|
352
|
+
|
|
353
|
+
return Promise.all(matches.map(async (match) => {
|
|
354
|
+
if (!match.filePath || !match.entityName) return match;
|
|
355
|
+
const blame = await blameEntity({
|
|
356
|
+
filePath: match.filePath,
|
|
357
|
+
entityName: match.entityName,
|
|
358
|
+
rev: targetRev,
|
|
359
|
+
});
|
|
360
|
+
return blame ? { ...match, blame } : match;
|
|
361
|
+
}));
|
|
362
|
+
}
|
|
363
|
+
|
|
294
364
|
type SearchBatch = Readonly<{
|
|
295
365
|
contexts: readonly SemContext[];
|
|
296
366
|
pack: SemContextPack;
|
|
@@ -441,13 +511,33 @@ function printRunPlan(
|
|
|
441
511
|
);
|
|
442
512
|
}
|
|
443
513
|
|
|
514
|
+
function formatStartStep(name: string, detail?: string): string {
|
|
515
|
+
if (name === "entity.diff") return "Diff: running sem over the selected git range";
|
|
516
|
+
if (name === "context.pack") return "Context: packing selected target files with Repomix";
|
|
517
|
+
if (name === "search.batches") return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
|
|
518
|
+
if (name === "prompt.tokens") return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
|
|
519
|
+
if (name === "search.model") return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
|
|
520
|
+
return `${name}: working`;
|
|
521
|
+
}
|
|
522
|
+
|
|
444
523
|
function formatStep(name: string, ms: number, count?: number, detail?: string): string {
|
|
445
524
|
if (name === "entity.diff") return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
|
|
446
525
|
if (name === "context.pack") return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
|
|
526
|
+
if (name === "search.batches") return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
|
|
527
|
+
if (name === "prompt.tokens") return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
|
|
447
528
|
if (name === "search.model") return `Model: ${count ?? 0} matches (${ms}ms)`;
|
|
448
529
|
return `${name}: ${ms}ms`;
|
|
449
530
|
}
|
|
450
531
|
|
|
532
|
+
function formatErrorStep(name: string, ms: number): string {
|
|
533
|
+
if (name === "entity.diff") return `Diff failed after ${ms}ms`;
|
|
534
|
+
if (name === "context.pack") return `Context packing failed after ${ms}ms`;
|
|
535
|
+
if (name === "search.batches") return `Search batch preparation failed after ${ms}ms`;
|
|
536
|
+
if (name === "prompt.tokens") return `Token counting failed after ${ms}ms`;
|
|
537
|
+
if (name === "search.model") return `Model search failed after ${ms}ms`;
|
|
538
|
+
return `${name} failed after ${ms}ms`;
|
|
539
|
+
}
|
|
540
|
+
|
|
451
541
|
function scoutPlanLine(plan: CounterScoutPlan, entitiesScanned: number): string {
|
|
452
542
|
if (plan.targets.length === 0) {
|
|
453
543
|
return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
|
package/src/trace.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type Tracer = {
|
|
|
9
9
|
|
|
10
10
|
export type SpanTraceEvent = Readonly<{
|
|
11
11
|
name: string;
|
|
12
|
+
phase: "start" | "end" | "error";
|
|
12
13
|
ms: number;
|
|
13
14
|
count?: number;
|
|
14
15
|
detail?: string;
|
|
@@ -16,6 +17,7 @@ export type SpanTraceEvent = Readonly<{
|
|
|
16
17
|
|
|
17
18
|
export type SpanTraceOptions<T> = Readonly<{
|
|
18
19
|
fields?: TraceFields;
|
|
20
|
+
startDetail?: string | (() => string);
|
|
19
21
|
count?: (value: T) => number;
|
|
20
22
|
detail?: (value: T) => string;
|
|
21
23
|
}>;
|
|
@@ -53,6 +55,12 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
|
|
|
53
55
|
options?: SpanTraceOptions<T>,
|
|
54
56
|
): Promise<{ value: T; ms: number }> | { value: T; ms: number } {
|
|
55
57
|
const startedAtMs = nowMs();
|
|
58
|
+
onEvent?.({
|
|
59
|
+
name: span,
|
|
60
|
+
phase: "start",
|
|
61
|
+
ms: 0,
|
|
62
|
+
detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
|
|
63
|
+
});
|
|
56
64
|
try {
|
|
57
65
|
const out = fn();
|
|
58
66
|
if (isPromiseLike(out)) {
|
|
@@ -63,12 +71,21 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
|
|
|
63
71
|
durationMs = nowMs() - startedAtMs;
|
|
64
72
|
const event: SpanTraceEvent = {
|
|
65
73
|
name: span,
|
|
74
|
+
phase: "end",
|
|
66
75
|
ms: Math.round(durationMs),
|
|
67
76
|
count: options?.count?.(value),
|
|
68
77
|
detail: options?.detail?.(value),
|
|
69
78
|
};
|
|
70
79
|
onEvent?.(event);
|
|
71
80
|
return { value, ms: event.ms };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
durationMs = nowMs() - startedAtMs;
|
|
83
|
+
onEvent?.({
|
|
84
|
+
name: span,
|
|
85
|
+
phase: "error",
|
|
86
|
+
ms: Math.round(durationMs),
|
|
87
|
+
});
|
|
88
|
+
throw error;
|
|
72
89
|
} finally {
|
|
73
90
|
durationMs ??= nowMs() - startedAtMs;
|
|
74
91
|
emit(span, durationMs, options?.fields);
|
|
@@ -80,6 +97,7 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
|
|
|
80
97
|
emit(span, durationMs, options?.fields);
|
|
81
98
|
const event: SpanTraceEvent = {
|
|
82
99
|
name: span,
|
|
100
|
+
phase: "end",
|
|
83
101
|
ms: Math.round(durationMs),
|
|
84
102
|
count: options?.count?.(out),
|
|
85
103
|
detail: options?.detail?.(out),
|
|
@@ -88,6 +106,11 @@ export function createTracer(options?: CreateTracerOptions): Tracer {
|
|
|
88
106
|
return { value: out, ms: event.ms };
|
|
89
107
|
} catch (error) {
|
|
90
108
|
const durationMs = nowMs() - startedAtMs;
|
|
109
|
+
onEvent?.({
|
|
110
|
+
name: span,
|
|
111
|
+
phase: "error",
|
|
112
|
+
ms: Math.round(durationMs),
|
|
113
|
+
});
|
|
91
114
|
emit(span, durationMs, options?.fields);
|
|
92
115
|
throw error;
|
|
93
116
|
}
|
package/src/types.ts
CHANGED
|
@@ -78,6 +78,12 @@ export type StagedDiff = Readonly<{
|
|
|
78
78
|
stats: NetDiffStats;
|
|
79
79
|
}>;
|
|
80
80
|
|
|
81
|
+
export type BlameSummary = Readonly<{
|
|
82
|
+
commit: string;
|
|
83
|
+
author: string;
|
|
84
|
+
subject: string;
|
|
85
|
+
}>;
|
|
86
|
+
|
|
81
87
|
export type NetDiff = Readonly<{
|
|
82
88
|
id: SourceId;
|
|
83
89
|
label: string;
|
|
@@ -93,6 +99,7 @@ export type SourceRange = Readonly<{
|
|
|
93
99
|
base: string;
|
|
94
100
|
target: string;
|
|
95
101
|
committers?: readonly string[];
|
|
102
|
+
commitSubjects?: readonly string[];
|
|
96
103
|
stats: NetDiffStats;
|
|
97
104
|
}>;
|
|
98
105
|
|
|
@@ -122,6 +129,7 @@ export type SemChangeSet = Readonly<{
|
|
|
122
129
|
base: string;
|
|
123
130
|
target: string;
|
|
124
131
|
committers?: readonly string[];
|
|
132
|
+
commitSubjects?: readonly string[];
|
|
125
133
|
contextCwd: string;
|
|
126
134
|
cleanup: () => Promise<void>;
|
|
127
135
|
changes: readonly SemChange[];
|
|
@@ -195,10 +203,15 @@ export type SearchProfile = Readonly<{
|
|
|
195
203
|
export type SearchMatch = Readonly<{
|
|
196
204
|
targetId: string;
|
|
197
205
|
patternId: CheckId;
|
|
206
|
+
patternName?: string;
|
|
198
207
|
checkWhy?: string;
|
|
199
208
|
reason: string;
|
|
200
209
|
proof: string;
|
|
201
210
|
snapshot?: string;
|
|
211
|
+
filePath?: string;
|
|
212
|
+
entityName?: string;
|
|
213
|
+
entityKind?: string;
|
|
214
|
+
blame?: BlameSummary;
|
|
202
215
|
}>;
|
|
203
216
|
|
|
204
217
|
export type SearchRunJson = Readonly<{
|
|
@@ -215,6 +228,7 @@ export type SearchRunJson = Readonly<{
|
|
|
215
228
|
skipped?: boolean;
|
|
216
229
|
skipReason?: "input_too_large" | "no_candidates";
|
|
217
230
|
committers?: readonly string[];
|
|
231
|
+
commitSubjects?: readonly string[];
|
|
218
232
|
filesChanged?: number;
|
|
219
233
|
entitiesScanned?: number;
|
|
220
234
|
candidates?: number;
|