@stupify/cli 0.0.15 → 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/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 +40 -9
- package/dist/types.d.ts +13 -0
- package/package.json +1 -1
- 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 +42 -9
- package/src/types.ts +14 -0
package/src/doctor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
|
|
7
7
|
import { runHookCommand } from "./hooks.ts";
|
|
8
|
+
import type { CliUi } from "./ui.ts";
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
|
|
@@ -17,7 +18,13 @@ type DoctorCheck = Readonly<{
|
|
|
17
18
|
required?: boolean;
|
|
18
19
|
}>;
|
|
19
20
|
|
|
20
|
-
export
|
|
21
|
+
export type DoctorResult = Readonly<{
|
|
22
|
+
exitCode: number;
|
|
23
|
+
text: string;
|
|
24
|
+
checks: readonly DoctorCheck[];
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
export async function runDoctor(): Promise<DoctorResult> {
|
|
21
28
|
const checks = await Promise.all([
|
|
22
29
|
gitCheck(),
|
|
23
30
|
hookCheck(),
|
|
@@ -30,9 +37,28 @@ export async function runDoctor(): Promise<Readonly<{ exitCode: number; text: st
|
|
|
30
37
|
return {
|
|
31
38
|
exitCode: requiredMissing ? 1 : 0,
|
|
32
39
|
text: renderDoctor(checks),
|
|
40
|
+
checks,
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
export function renderDoctorToUi(result: DoctorResult, ui: CliUi): void {
|
|
45
|
+
const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
|
|
46
|
+
if (missingRequired.length > 0) {
|
|
47
|
+
ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
|
|
48
|
+
} else {
|
|
49
|
+
ui.success("Doctor checks complete.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
ui.note(
|
|
53
|
+
result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"),
|
|
54
|
+
"Doctor",
|
|
55
|
+
);
|
|
56
|
+
ui.note(
|
|
57
|
+
"Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.",
|
|
58
|
+
"Privacy",
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
36
62
|
async function gitCheck(): Promise<DoctorCheck> {
|
|
37
63
|
try {
|
|
38
64
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
|
package/src/git.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { sourceId, type NetDiff, type NetDiffStats, type SourceRange, type StagedDiff } from "./types.ts";
|
|
3
|
+
import { sourceId, type BlameSummary, type NetDiff, type NetDiffStats, type SourceRange, type StagedDiff } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
|
|
@@ -106,6 +106,27 @@ export async function gitUserLabel(): Promise<string> {
|
|
|
106
106
|
return name || email || "working tree";
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
export async function blameEntity(input: Readonly<{
|
|
110
|
+
filePath: string;
|
|
111
|
+
entityName: string;
|
|
112
|
+
rev: string;
|
|
113
|
+
}>): Promise<BlameSummary | null> {
|
|
114
|
+
try {
|
|
115
|
+
const { stdout } = await execFileAsync("git", [
|
|
116
|
+
"blame",
|
|
117
|
+
"--line-porcelain",
|
|
118
|
+
"-L",
|
|
119
|
+
`:${input.entityName}`,
|
|
120
|
+
input.rev,
|
|
121
|
+
"--",
|
|
122
|
+
input.filePath,
|
|
123
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
124
|
+
return summarizeBlame(stdout);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
|
|
110
131
|
const [text, stats, shortBase, shortTarget] = await Promise.all([
|
|
111
132
|
diff(base, target),
|
|
@@ -124,11 +145,12 @@ async function netDiff(base: string, target: string, label: string, id?: NetDiff
|
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
async function sourceRange(base: string, target: string, label: string, id?: SourceRange["id"]): Promise<SourceRange> {
|
|
127
|
-
const [stats, shortBase, shortTarget, committers] = await Promise.all([
|
|
148
|
+
const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
|
|
128
149
|
diffStats(base, target),
|
|
129
150
|
shortCommit(base),
|
|
130
151
|
shortCommit(target),
|
|
131
152
|
committersForRange(base, target),
|
|
153
|
+
commitSubjectsForRange(base, target),
|
|
132
154
|
]);
|
|
133
155
|
return {
|
|
134
156
|
id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
|
|
@@ -136,6 +158,7 @@ async function sourceRange(base: string, target: string, label: string, id?: Sou
|
|
|
136
158
|
base,
|
|
137
159
|
target,
|
|
138
160
|
committers,
|
|
161
|
+
commitSubjects,
|
|
139
162
|
stats,
|
|
140
163
|
};
|
|
141
164
|
}
|
|
@@ -160,6 +183,17 @@ async function committersForRange(base: string, target: string): Promise<readonl
|
|
|
160
183
|
}
|
|
161
184
|
}
|
|
162
185
|
|
|
186
|
+
async function commitSubjectsForRange(base: string, target: string): Promise<readonly string[]> {
|
|
187
|
+
try {
|
|
188
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
|
|
189
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
190
|
+
});
|
|
191
|
+
return uniqueLines(stdout);
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
163
197
|
function uniqueLines(value: string): readonly string[] {
|
|
164
198
|
const seen = new Set<string>();
|
|
165
199
|
const lines: string[] = [];
|
|
@@ -304,3 +338,43 @@ async function commitMessage(commit: string): Promise<string> {
|
|
|
304
338
|
function firstLine(value: string): string {
|
|
305
339
|
return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
306
340
|
}
|
|
341
|
+
|
|
342
|
+
function summarizeBlame(output: string): BlameSummary | null {
|
|
343
|
+
const entries = new Map<string, { commit: string; author: string; subject: string; count: number }>();
|
|
344
|
+
let currentCommit = "";
|
|
345
|
+
let currentAuthor = "";
|
|
346
|
+
let currentSubject = "";
|
|
347
|
+
for (const line of output.split(/\r?\n/)) {
|
|
348
|
+
const header = /^([0-9a-f]{40})\s+/.exec(line);
|
|
349
|
+
if (header?.[1]) {
|
|
350
|
+
currentCommit = header[1];
|
|
351
|
+
currentAuthor = "";
|
|
352
|
+
currentSubject = "";
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (line.startsWith("author ")) {
|
|
356
|
+
currentAuthor = line.slice("author ".length).trim();
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (line.startsWith("summary ")) {
|
|
360
|
+
currentSubject = line.slice("summary ".length).trim();
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (!line.startsWith("\t") || !currentCommit) continue;
|
|
364
|
+
const previous = entries.get(currentCommit);
|
|
365
|
+
entries.set(currentCommit, {
|
|
366
|
+
commit: currentCommit,
|
|
367
|
+
author: currentAuthor || previous?.author || "unknown author",
|
|
368
|
+
subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
|
|
369
|
+
count: (previous?.count ?? 0) + 1,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
|
|
374
|
+
if (!best) return null;
|
|
375
|
+
return {
|
|
376
|
+
commit: best.commit.slice(0, 7),
|
|
377
|
+
author: best.author,
|
|
378
|
+
subject: best.subject,
|
|
379
|
+
};
|
|
380
|
+
}
|
package/src/hooks.ts
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { gitPath, gitRoot } from "./git.ts";
|
|
7
7
|
import type { HookAction } from "./types.ts";
|
|
8
|
+
import type { CliUi } from "./ui.ts";
|
|
8
9
|
|
|
9
10
|
const execFileAsync = promisify(execFile);
|
|
10
11
|
const START = "# stupify hook start";
|
|
@@ -16,6 +17,22 @@ export async function runHookCommand(action: HookAction): Promise<string> {
|
|
|
16
17
|
return uninstallHook();
|
|
17
18
|
}
|
|
18
19
|
|
|
20
|
+
export function renderHookResultToUi(result: string, ui: CliUi): void {
|
|
21
|
+
const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
|
|
22
|
+
if (firstLine.includes("not installed")) {
|
|
23
|
+
ui.info(firstLine);
|
|
24
|
+
} else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
|
|
25
|
+
ui.success(firstLine);
|
|
26
|
+
} else if (firstLine.includes("existing non-Stupify")) {
|
|
27
|
+
ui.warn(firstLine);
|
|
28
|
+
} else {
|
|
29
|
+
ui.info(firstLine);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const detail = rest.join("\n").trim();
|
|
33
|
+
if (detail) ui.note(detail, "Hook");
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
export function hookSnippet(): string {
|
|
20
37
|
return managedBlock("stupify --staged");
|
|
21
38
|
}
|
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 });
|
|
@@ -117,6 +127,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
117
127
|
elapsedMs: Date.now() - startedAt,
|
|
118
128
|
modelCalls: 0,
|
|
119
129
|
committers: changeSet.committers,
|
|
130
|
+
commitSubjects: changeSet.commitSubjects,
|
|
120
131
|
skipped: true,
|
|
121
132
|
skipReason: "no_candidates",
|
|
122
133
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -160,6 +171,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
160
171
|
elapsedMs: Date.now() - startedAt,
|
|
161
172
|
modelCalls: 0,
|
|
162
173
|
committers: changeSet.committers,
|
|
174
|
+
commitSubjects: changeSet.commitSubjects,
|
|
163
175
|
skipped: true,
|
|
164
176
|
skipReason: "no_candidates",
|
|
165
177
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -214,6 +226,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
214
226
|
inputTokens: batches.estimatedInputTokens,
|
|
215
227
|
inputTokenCap: maxSearchInputTokens,
|
|
216
228
|
committers: changeSet.committers,
|
|
229
|
+
commitSubjects: changeSet.commitSubjects,
|
|
217
230
|
skipped: true,
|
|
218
231
|
skipReason: "input_too_large",
|
|
219
232
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -276,7 +289,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
276
289
|
modelCalls += 1;
|
|
277
290
|
matches.push(...withCheckWhy(value, checks));
|
|
278
291
|
}
|
|
279
|
-
const uniqueMatches = dedupeMatches(matches);
|
|
292
|
+
const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
|
|
280
293
|
|
|
281
294
|
return {
|
|
282
295
|
schemaVersion: "search.v1",
|
|
@@ -290,6 +303,7 @@ export async function runSearchCommand(command: SearchCommand, startedAt: number
|
|
|
290
303
|
inputTokens,
|
|
291
304
|
inputTokenCap: maxSearchInputTokens,
|
|
292
305
|
committers: changeSet.committers,
|
|
306
|
+
commitSubjects: changeSet.commitSubjects,
|
|
293
307
|
filesChanged: changeSet.summary.fileCount,
|
|
294
308
|
entitiesScanned: changeSet.summary.total,
|
|
295
309
|
candidates: contexts.length,
|
|
@@ -324,10 +338,29 @@ function withCheckWhy(matches: readonly SearchMatch[], checks: readonly StupifyC
|
|
|
324
338
|
const checksById = new Map(checks.map((check) => [check.id, check]));
|
|
325
339
|
return matches.map((match) => ({
|
|
326
340
|
...match,
|
|
341
|
+
patternName: checksById.get(match.patternId)?.name,
|
|
327
342
|
checkWhy: checksById.get(match.patternId)?.why,
|
|
328
343
|
}));
|
|
329
344
|
}
|
|
330
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
|
+
|
|
331
364
|
type SearchBatch = Readonly<{
|
|
332
365
|
contexts: readonly SemContext[];
|
|
333
366
|
pack: SemContextPack;
|
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;
|