@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/dist/analysis.js
CHANGED
|
@@ -74,6 +74,9 @@ function uncheckedSearchMatches(value, contexts) {
|
|
|
74
74
|
reason: match.reason ?? "",
|
|
75
75
|
proof: sourcePointer(context),
|
|
76
76
|
snapshot: sourceSnapshot(context),
|
|
77
|
+
filePath: context.filePath,
|
|
78
|
+
entityName: context.entityName,
|
|
79
|
+
entityKind: context.entityKind,
|
|
77
80
|
}];
|
|
78
81
|
});
|
|
79
82
|
}
|
package/dist/constants.d.ts
CHANGED
package/dist/constants.js
CHANGED
package/dist/doctor.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import type { CliUi } from "./ui.ts";
|
|
2
|
+
type CheckStatus = "ok" | "missing" | "info";
|
|
3
|
+
type DoctorCheck = Readonly<{
|
|
4
|
+
label: string;
|
|
5
|
+
status: CheckStatus;
|
|
6
|
+
detail: string;
|
|
7
|
+
required?: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
export type DoctorResult = Readonly<{
|
|
2
10
|
exitCode: number;
|
|
3
11
|
text: string;
|
|
4
|
-
|
|
12
|
+
checks: readonly DoctorCheck[];
|
|
13
|
+
}>;
|
|
14
|
+
export declare function runDoctor(): Promise<DoctorResult>;
|
|
15
|
+
export declare function renderDoctorToUi(result: DoctorResult, ui: CliUi): void;
|
|
16
|
+
export {};
|
package/dist/doctor.js
CHANGED
|
@@ -19,8 +19,20 @@ export async function runDoctor() {
|
|
|
19
19
|
return {
|
|
20
20
|
exitCode: requiredMissing ? 1 : 0,
|
|
21
21
|
text: renderDoctor(checks),
|
|
22
|
+
checks,
|
|
22
23
|
};
|
|
23
24
|
}
|
|
25
|
+
export function renderDoctorToUi(result, ui) {
|
|
26
|
+
const missingRequired = result.checks.filter((check) => check.required && check.status === "missing");
|
|
27
|
+
if (missingRequired.length > 0) {
|
|
28
|
+
ui.error(`Doctor found ${missingRequired.length} missing required dependency.`);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
ui.success("Doctor checks complete.");
|
|
32
|
+
}
|
|
33
|
+
ui.note(result.checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`).join("\n"), "Doctor");
|
|
34
|
+
ui.note("Local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.", "Privacy");
|
|
35
|
+
}
|
|
24
36
|
async function gitCheck() {
|
|
25
37
|
try {
|
|
26
38
|
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
|
package/dist/git.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
|
|
1
|
+
import { type BlameSummary, type NetDiff, type SourceRange, type StagedDiff } from "./types.ts";
|
|
2
2
|
export declare function netDiffSince(since: string): Promise<NetDiff>;
|
|
3
3
|
export declare function netDiffForCommit(commit: string): Promise<NetDiff>;
|
|
4
4
|
export declare function netDiffForRecentCommits(count: number): Promise<NetDiff>;
|
|
@@ -10,3 +10,8 @@ export declare function stagedDiff(): Promise<StagedDiff>;
|
|
|
10
10
|
export declare function gitRoot(): Promise<string>;
|
|
11
11
|
export declare function gitPath(pathspec: string): Promise<string>;
|
|
12
12
|
export declare function gitUserLabel(): Promise<string>;
|
|
13
|
+
export declare function blameEntity(input: Readonly<{
|
|
14
|
+
filePath: string;
|
|
15
|
+
entityName: string;
|
|
16
|
+
rev: string;
|
|
17
|
+
}>): Promise<BlameSummary | null>;
|
package/dist/git.js
CHANGED
|
@@ -98,6 +98,23 @@ export async function gitUserLabel() {
|
|
|
98
98
|
return `${name} <${email}>`;
|
|
99
99
|
return name || email || "working tree";
|
|
100
100
|
}
|
|
101
|
+
export async function blameEntity(input) {
|
|
102
|
+
try {
|
|
103
|
+
const { stdout } = await execFileAsync("git", [
|
|
104
|
+
"blame",
|
|
105
|
+
"--line-porcelain",
|
|
106
|
+
"-L",
|
|
107
|
+
`:${input.entityName}`,
|
|
108
|
+
input.rev,
|
|
109
|
+
"--",
|
|
110
|
+
input.filePath,
|
|
111
|
+
], { maxBuffer: 16 * 1024 * 1024 });
|
|
112
|
+
return summarizeBlame(stdout);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
101
118
|
async function netDiff(base, target, label, id) {
|
|
102
119
|
const [text, stats, shortBase, shortTarget] = await Promise.all([
|
|
103
120
|
diff(base, target),
|
|
@@ -115,11 +132,12 @@ async function netDiff(base, target, label, id) {
|
|
|
115
132
|
};
|
|
116
133
|
}
|
|
117
134
|
async function sourceRange(base, target, label, id) {
|
|
118
|
-
const [stats, shortBase, shortTarget, committers] = await Promise.all([
|
|
135
|
+
const [stats, shortBase, shortTarget, committers, commitSubjects] = await Promise.all([
|
|
119
136
|
diffStats(base, target),
|
|
120
137
|
shortCommit(base),
|
|
121
138
|
shortCommit(target),
|
|
122
139
|
committersForRange(base, target),
|
|
140
|
+
commitSubjectsForRange(base, target),
|
|
123
141
|
]);
|
|
124
142
|
return {
|
|
125
143
|
id: id ?? sourceId(`net:${shortBase}..${shortTarget}`),
|
|
@@ -127,6 +145,7 @@ async function sourceRange(base, target, label, id) {
|
|
|
127
145
|
base,
|
|
128
146
|
target,
|
|
129
147
|
committers,
|
|
148
|
+
commitSubjects,
|
|
130
149
|
stats,
|
|
131
150
|
};
|
|
132
151
|
}
|
|
@@ -150,6 +169,17 @@ async function committersForRange(base, target) {
|
|
|
150
169
|
return [];
|
|
151
170
|
}
|
|
152
171
|
}
|
|
172
|
+
async function commitSubjectsForRange(base, target) {
|
|
173
|
+
try {
|
|
174
|
+
const { stdout } = await execFileAsync("git", ["log", "--format=%s", `${base}..${target}`], {
|
|
175
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
176
|
+
});
|
|
177
|
+
return uniqueLines(stdout);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
153
183
|
function uniqueLines(value) {
|
|
154
184
|
const seen = new Set();
|
|
155
185
|
const lines = [];
|
|
@@ -296,3 +326,43 @@ async function commitMessage(commit) {
|
|
|
296
326
|
function firstLine(value) {
|
|
297
327
|
return value.trim().split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
298
328
|
}
|
|
329
|
+
function summarizeBlame(output) {
|
|
330
|
+
const entries = new Map();
|
|
331
|
+
let currentCommit = "";
|
|
332
|
+
let currentAuthor = "";
|
|
333
|
+
let currentSubject = "";
|
|
334
|
+
for (const line of output.split(/\r?\n/)) {
|
|
335
|
+
const header = /^([0-9a-f]{40})\s+/.exec(line);
|
|
336
|
+
if (header?.[1]) {
|
|
337
|
+
currentCommit = header[1];
|
|
338
|
+
currentAuthor = "";
|
|
339
|
+
currentSubject = "";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (line.startsWith("author ")) {
|
|
343
|
+
currentAuthor = line.slice("author ".length).trim();
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (line.startsWith("summary ")) {
|
|
347
|
+
currentSubject = line.slice("summary ".length).trim();
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (!line.startsWith("\t") || !currentCommit)
|
|
351
|
+
continue;
|
|
352
|
+
const previous = entries.get(currentCommit);
|
|
353
|
+
entries.set(currentCommit, {
|
|
354
|
+
commit: currentCommit,
|
|
355
|
+
author: currentAuthor || previous?.author || "unknown author",
|
|
356
|
+
subject: currentSubject || previous?.subject || currentCommit.slice(0, 7),
|
|
357
|
+
count: (previous?.count ?? 0) + 1,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const [best] = [...entries.values()].sort((a, b) => b.count - a.count);
|
|
361
|
+
if (!best)
|
|
362
|
+
return null;
|
|
363
|
+
return {
|
|
364
|
+
commit: best.commit.slice(0, 7),
|
|
365
|
+
author: best.author,
|
|
366
|
+
subject: best.subject,
|
|
367
|
+
};
|
|
368
|
+
}
|
package/dist/hooks.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import type { HookAction } from "./types.ts";
|
|
2
|
+
import type { CliUi } from "./ui.ts";
|
|
2
3
|
export declare function runHookCommand(action: HookAction): Promise<string>;
|
|
4
|
+
export declare function renderHookResultToUi(result: string, ui: CliUi): void;
|
|
3
5
|
export declare function hookSnippet(): string;
|
package/dist/hooks.js
CHANGED
|
@@ -14,6 +14,24 @@ export async function runHookCommand(action) {
|
|
|
14
14
|
return installHook();
|
|
15
15
|
return uninstallHook();
|
|
16
16
|
}
|
|
17
|
+
export function renderHookResultToUi(result, ui) {
|
|
18
|
+
const [firstLine = "Stupify hook: no status returned", ...rest] = result.split(/\r?\n/);
|
|
19
|
+
if (firstLine.includes("not installed")) {
|
|
20
|
+
ui.info(firstLine);
|
|
21
|
+
}
|
|
22
|
+
else if (firstLine.includes("installed") || firstLine.includes("updated") || firstLine.includes("uninstalled")) {
|
|
23
|
+
ui.success(firstLine);
|
|
24
|
+
}
|
|
25
|
+
else if (firstLine.includes("existing non-Stupify")) {
|
|
26
|
+
ui.warn(firstLine);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
ui.info(firstLine);
|
|
30
|
+
}
|
|
31
|
+
const detail = rest.join("\n").trim();
|
|
32
|
+
if (detail)
|
|
33
|
+
ui.note(detail, "Hook");
|
|
34
|
+
}
|
|
17
35
|
export function hookSnippet() {
|
|
18
36
|
return managedBlock("stupify --staged");
|
|
19
37
|
}
|
package/dist/render.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
import type { SearchCommand, SearchRunJson } from "./types.ts";
|
|
2
|
+
import { type CliUi } from "./ui.ts";
|
|
2
3
|
export declare function renderSearchRun(run: SearchRunJson, command: SearchCommand): string;
|
|
4
|
+
export declare function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void;
|
|
5
|
+
export declare function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string;
|
|
3
6
|
export declare function helpText(): string;
|
package/dist/render.js
CHANGED
|
@@ -3,16 +3,43 @@ import { format } from "./ui.js";
|
|
|
3
3
|
export function renderSearchRun(run, command) {
|
|
4
4
|
if (command.json)
|
|
5
5
|
return JSON.stringify(run, null, 2);
|
|
6
|
+
return renderSearchHumanText(run, command);
|
|
7
|
+
}
|
|
8
|
+
export function renderSearchRunToUi(run, command, ui) {
|
|
9
|
+
if (command.json) {
|
|
10
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
14
|
+
ui.warn("Search skipped: input is too large for precise local search.");
|
|
15
|
+
ui.note(oversizedText(run, command), "Skipped");
|
|
16
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
20
|
+
ui.success("Search complete: no search targets found.");
|
|
21
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
22
|
+
ui.outro("No judgment-offload signals found.");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (run.matches.length === 0) {
|
|
26
|
+
ui.success("Search complete: no judgment-offload signals found.");
|
|
27
|
+
ui.note(cleanSummaryText(run), "Summary");
|
|
28
|
+
ui.outro("Warn-only. Nothing blocked.");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
|
|
32
|
+
ui.note(matchSummaryText(run, command), "Summary");
|
|
33
|
+
for (const group of groupMatchesByFile(run.matches)) {
|
|
34
|
+
ui.note(renderMatchGroup(group, run), group.filePath);
|
|
35
|
+
}
|
|
36
|
+
ui.outro(summaryLine(run));
|
|
37
|
+
}
|
|
38
|
+
export function renderSearchHumanText(run, command) {
|
|
6
39
|
if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
|
|
7
|
-
return `${format.heading("Search
|
|
8
|
-
${
|
|
9
|
-
|
|
10
|
-
${format.heading("Limit:")}
|
|
11
|
-
${run.stats.inputTokenCap ?? "unknown"} tokens
|
|
12
|
-
Stupify skipped the search rather than review truncated context.
|
|
13
|
-
Nothing was blocked.
|
|
14
|
-
${format.heading("Try:")}
|
|
15
|
-
rerun with ${sourceHint(command)} --max-search-input-tokens ${Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2)}`;
|
|
40
|
+
return `${format.heading("Search skipped")}
|
|
41
|
+
${oversizedText(run, command)}
|
|
42
|
+
Warn-only. Nothing blocked.`;
|
|
16
43
|
}
|
|
17
44
|
if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
|
|
18
45
|
return `${format.heading("Search complete.")}
|
|
@@ -24,18 +51,12 @@ ${format.success("No search targets found.")}`;
|
|
|
24
51
|
${format.label("Patterns:")} ${run.patterns.join(", ")}
|
|
25
52
|
${format.success("No judgment-offload signals found.")}`;
|
|
26
53
|
}
|
|
54
|
+
const groups = groupMatchesByFile(run.matches);
|
|
27
55
|
return `${slopHeading()}
|
|
28
|
-
${
|
|
29
|
-
|
|
30
|
-
${run.matches.map((match, index) => `${index + 1}. ${format.label(match.patternId)}
|
|
31
|
-
${match.reason}
|
|
32
|
-
|
|
33
|
-
\`\`\`
|
|
34
|
-
${match.snapshot ?? match.proof}
|
|
35
|
-
\`\`\`
|
|
36
|
-
${format.muted(match.proof)}
|
|
56
|
+
${matchSummaryText(run, command)}
|
|
37
57
|
|
|
38
|
-
${
|
|
58
|
+
${groups.map((group) => `${format.heading(group.filePath)}
|
|
59
|
+
${renderMatchGroup(group, run)}`).join("\n\n")}
|
|
39
60
|
${format.muted(summaryLine(run))}`;
|
|
40
61
|
}
|
|
41
62
|
export function helpText() {
|
|
@@ -85,6 +106,127 @@ Not included:
|
|
|
85
106
|
Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
|
|
86
107
|
`;
|
|
87
108
|
}
|
|
109
|
+
function oversizedText(run, command) {
|
|
110
|
+
const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
|
|
111
|
+
return [
|
|
112
|
+
`Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
|
|
113
|
+
`Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
|
|
114
|
+
"Stupify skipped the search rather than review truncated context.",
|
|
115
|
+
`Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
|
|
116
|
+
].join("\n");
|
|
117
|
+
}
|
|
118
|
+
function cleanSummaryText(run) {
|
|
119
|
+
return [
|
|
120
|
+
`Patterns: ${run.patterns.join(", ")}`,
|
|
121
|
+
run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
|
|
122
|
+
].filter(Boolean).join("\n");
|
|
123
|
+
}
|
|
124
|
+
function matchSummaryText(run, command) {
|
|
125
|
+
const fileCount = groupMatchesByFile(run.matches).length;
|
|
126
|
+
const fileNoun = fileCount === 1 ? "file" : "files";
|
|
127
|
+
return [
|
|
128
|
+
`${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
|
|
129
|
+
`${committerLabel(run)} · ${sourceLabel(command)}`,
|
|
130
|
+
"Warn-only. Nothing blocked.",
|
|
131
|
+
"",
|
|
132
|
+
patternSummaryLine(run),
|
|
133
|
+
].filter((line) => line !== null).join("\n");
|
|
134
|
+
}
|
|
135
|
+
function patternSummaryLine(run) {
|
|
136
|
+
const counts = new Map();
|
|
137
|
+
for (const match of run.matches)
|
|
138
|
+
counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
|
|
139
|
+
return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
|
|
140
|
+
}
|
|
141
|
+
function groupMatchesByFile(matches) {
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
for (const match of matches) {
|
|
144
|
+
const filePath = proofFilePath(match.proof);
|
|
145
|
+
const group = groups.get(filePath) ?? [];
|
|
146
|
+
group.push(match);
|
|
147
|
+
groups.set(filePath, group);
|
|
148
|
+
}
|
|
149
|
+
return [...groups.entries()].map(([filePath, groupedMatches]) => ({
|
|
150
|
+
filePath,
|
|
151
|
+
matches: groupedMatches,
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
function renderMatchGroup(group, run) {
|
|
155
|
+
return group.matches.map((match, index) => {
|
|
156
|
+
const lines = [
|
|
157
|
+
matchHeadline(match, run, index),
|
|
158
|
+
match.reason,
|
|
159
|
+
match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
|
|
160
|
+
format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
|
|
161
|
+
match.checkWhy ?? "This pattern may indicate judgment-offload.",
|
|
162
|
+
];
|
|
163
|
+
return lines.filter(Boolean).join("\n");
|
|
164
|
+
}).join("\n\n");
|
|
165
|
+
}
|
|
166
|
+
function matchHeadline(match, run, index) {
|
|
167
|
+
return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
|
|
168
|
+
}
|
|
169
|
+
function patternLabel(match) {
|
|
170
|
+
return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
|
|
171
|
+
}
|
|
172
|
+
function headlineArgs(match) {
|
|
173
|
+
const destination = entityNameFromProof(match.proof);
|
|
174
|
+
const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
|
|
175
|
+
if (source && destination && source !== destination)
|
|
176
|
+
return `${codeLabel(source)} -> ${codeLabel(destination)}`;
|
|
177
|
+
if (destination)
|
|
178
|
+
return codeLabel(destination);
|
|
179
|
+
return codeLabel(match.targetId);
|
|
180
|
+
}
|
|
181
|
+
function matchBlameLabel(match, run) {
|
|
182
|
+
return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
|
|
183
|
+
}
|
|
184
|
+
function blameSummaryLabel(blame) {
|
|
185
|
+
return `${blame.author} (${blame.subject})`;
|
|
186
|
+
}
|
|
187
|
+
function runLevelBlameLabel(run) {
|
|
188
|
+
const author = committerLabel(run);
|
|
189
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
190
|
+
return subject ? `${author} (${subject})` : author;
|
|
191
|
+
}
|
|
192
|
+
function commitSubjectSuffix(run) {
|
|
193
|
+
const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
|
|
194
|
+
return subject ? ` · commit: ${subject}` : "";
|
|
195
|
+
}
|
|
196
|
+
function firstHumanSubject(subjects) {
|
|
197
|
+
return subjects.map((subject) => subject.trim()).find(Boolean);
|
|
198
|
+
}
|
|
199
|
+
function codeLabel(value) {
|
|
200
|
+
return `\`${value}\``;
|
|
201
|
+
}
|
|
202
|
+
function titleCase(value) {
|
|
203
|
+
return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
|
|
204
|
+
}
|
|
205
|
+
function entityNameFromProof(proof) {
|
|
206
|
+
const parts = proof.split("::");
|
|
207
|
+
return parts[2] || parts[1] || undefined;
|
|
208
|
+
}
|
|
209
|
+
function firstBacktickedToken(value) {
|
|
210
|
+
const match = /`([^`]+)`/.exec(value);
|
|
211
|
+
return cleanToken(match?.[1]);
|
|
212
|
+
}
|
|
213
|
+
function firstLikelySource(value, destination) {
|
|
214
|
+
const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
|
|
215
|
+
.map((match) => cleanToken(match[0]))
|
|
216
|
+
.filter((token) => Boolean(token));
|
|
217
|
+
return tokens.find((token) => token !== destination && token !== "The");
|
|
218
|
+
}
|
|
219
|
+
function cleanToken(value) {
|
|
220
|
+
const token = value?.trim().replace(/[.,;:]+$/, "");
|
|
221
|
+
return token || undefined;
|
|
222
|
+
}
|
|
223
|
+
function proofFilePath(proof) {
|
|
224
|
+
return proof.split("::")[0] || proof;
|
|
225
|
+
}
|
|
226
|
+
function proofDetail(proof) {
|
|
227
|
+
const [, ...rest] = proof.split("::");
|
|
228
|
+
return rest.length > 0 ? `::${rest.join("::")}` : proof;
|
|
229
|
+
}
|
|
88
230
|
function sourceHint(command) {
|
|
89
231
|
if (command.kind === "staged")
|
|
90
232
|
return "--staged";
|
|
@@ -146,6 +288,8 @@ function sinceLabel(since) {
|
|
|
146
288
|
return `last ${count} ${unit}s`;
|
|
147
289
|
}
|
|
148
290
|
function summaryLine(run) {
|
|
149
|
-
|
|
150
|
-
|
|
291
|
+
return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
|
|
292
|
+
}
|
|
293
|
+
function signalNoun(count) {
|
|
294
|
+
return count === 1 ? "signal" : "signals";
|
|
151
295
|
}
|
package/dist/sem-provider.js
CHANGED
|
@@ -36,6 +36,7 @@ function emptyChangeSet(label, stats, committers) {
|
|
|
36
36
|
base: label,
|
|
37
37
|
target: label,
|
|
38
38
|
committers,
|
|
39
|
+
commitSubjects: undefined,
|
|
39
40
|
contextCwd: process.cwd(),
|
|
40
41
|
cleanup: async () => undefined,
|
|
41
42
|
changes: [],
|
|
@@ -75,6 +76,7 @@ async function semChangeSetFromPatch(patch, debugSem, label = "stdin", committer
|
|
|
75
76
|
base: label,
|
|
76
77
|
target: label,
|
|
77
78
|
committers,
|
|
79
|
+
commitSubjects: undefined,
|
|
78
80
|
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
79
81
|
}),
|
|
80
82
|
contextCwd: process.cwd(),
|
|
@@ -199,6 +201,7 @@ function normalizeSemDiff(value, range) {
|
|
|
199
201
|
base: range.base,
|
|
200
202
|
target: range.target,
|
|
201
203
|
committers: range.committers,
|
|
204
|
+
commitSubjects: range.commitSubjects,
|
|
202
205
|
contextCwd: process.cwd(),
|
|
203
206
|
cleanup: async () => undefined,
|
|
204
207
|
changes,
|
package/dist/stupify.js
CHANGED
|
@@ -5,11 +5,12 @@ import { countPromptTokens, runSearch, searchRequest } from "./analysis.js";
|
|
|
5
5
|
import { searchChecks } from "./checks.js";
|
|
6
6
|
import { parseCommand } from "./command.js";
|
|
7
7
|
import { counterScoutPlan } from "./counter-scout.js";
|
|
8
|
-
import { runDoctor } from "./doctor.js";
|
|
9
|
-
import {
|
|
8
|
+
import { renderDoctorToUi, runDoctor } from "./doctor.js";
|
|
9
|
+
import { blameEntity } from "./git.js";
|
|
10
|
+
import { renderHookResultToUi, runHookCommand } from "./hooks.js";
|
|
10
11
|
import { firstRunModelBootstrap, loadLocalModel } from "./model.js";
|
|
11
12
|
import { entityContextsFromChanges, emptyContextPack, repomixContextPack, repomixSearchConfig } from "./repomix-provider.js";
|
|
12
|
-
import { helpText, renderSearchRun } from "./render.js";
|
|
13
|
+
import { helpText, renderSearchRun, renderSearchRunToUi } from "./render.js";
|
|
13
14
|
import { effectiveMaxCandidates, effectiveMaxSearchInputTokens, effectiveRepomixConfig, effectiveSearchChecks, loadSearchProfile, } from "./search-profile.js";
|
|
14
15
|
import { semChangeSetForCommand } from "./sem-provider.js";
|
|
15
16
|
import { createTracer } from "./trace.js";
|
|
@@ -20,26 +21,37 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
20
21
|
try {
|
|
21
22
|
const command = parseCommand(argv);
|
|
22
23
|
if (command.kind === "help") {
|
|
23
|
-
ui.
|
|
24
|
+
ui.intro("stupify");
|
|
25
|
+
ui.note(helpText().trim(), "Help");
|
|
26
|
+
ui.outro("Local-only. Warn-only.");
|
|
24
27
|
return 0;
|
|
25
28
|
}
|
|
26
29
|
if (command.kind === "hook") {
|
|
27
|
-
ui.
|
|
30
|
+
ui.intro("stupify");
|
|
31
|
+
renderHookResultToUi(await runHookCommand(command.action), ui);
|
|
32
|
+
ui.outro("Hook mode is warn-only. Commits are not blocked.");
|
|
28
33
|
return 0;
|
|
29
34
|
}
|
|
30
35
|
if (command.kind === "doctor") {
|
|
31
36
|
const result = await runDoctor();
|
|
32
|
-
ui.
|
|
37
|
+
ui.intro("stupify");
|
|
38
|
+
renderDoctorToUi(result, ui);
|
|
39
|
+
ui.outro(result.exitCode === 0 ? "Ready." : "Fix missing required dependencies, then rerun doctor.");
|
|
33
40
|
return result.exitCode;
|
|
34
41
|
}
|
|
35
42
|
if (command.kind === "bench-search") {
|
|
36
43
|
const { runSearchBench } = await import("./search-bench.js");
|
|
37
|
-
ui.
|
|
44
|
+
ui.intro("stupify");
|
|
45
|
+
ui.note(await runSearchBench(command.configPath), "Search bench");
|
|
46
|
+
ui.outro("Bench complete.");
|
|
38
47
|
return 0;
|
|
39
48
|
}
|
|
40
49
|
ui = createCliUi({ quiet: command.json });
|
|
41
50
|
const run = await runSearchCommand(command, startedAt, ui);
|
|
42
|
-
|
|
51
|
+
if (command.json)
|
|
52
|
+
ui.writeStdout(renderSearchRun(run, command));
|
|
53
|
+
else
|
|
54
|
+
renderSearchRunToUi(run, command, ui);
|
|
43
55
|
return 0;
|
|
44
56
|
}
|
|
45
57
|
catch (error) {
|
|
@@ -105,6 +117,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
105
117
|
elapsedMs: Date.now() - startedAt,
|
|
106
118
|
modelCalls: 0,
|
|
107
119
|
committers: changeSet.committers,
|
|
120
|
+
commitSubjects: changeSet.commitSubjects,
|
|
108
121
|
skipped: true,
|
|
109
122
|
skipReason: "no_candidates",
|
|
110
123
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -144,6 +157,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
144
157
|
elapsedMs: Date.now() - startedAt,
|
|
145
158
|
modelCalls: 0,
|
|
146
159
|
committers: changeSet.committers,
|
|
160
|
+
commitSubjects: changeSet.commitSubjects,
|
|
147
161
|
skipped: true,
|
|
148
162
|
skipReason: "no_candidates",
|
|
149
163
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -193,6 +207,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
193
207
|
inputTokens: batches.estimatedInputTokens,
|
|
194
208
|
inputTokenCap: maxSearchInputTokens,
|
|
195
209
|
committers: changeSet.committers,
|
|
210
|
+
commitSubjects: changeSet.commitSubjects,
|
|
196
211
|
skipped: true,
|
|
197
212
|
skipReason: "input_too_large",
|
|
198
213
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -246,7 +261,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
246
261
|
modelCalls += 1;
|
|
247
262
|
matches.push(...withCheckWhy(value, checks));
|
|
248
263
|
}
|
|
249
|
-
const uniqueMatches = dedupeMatches(matches);
|
|
264
|
+
const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
|
|
250
265
|
return {
|
|
251
266
|
schemaVersion: "search.v1",
|
|
252
267
|
mode: "search",
|
|
@@ -259,6 +274,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
259
274
|
inputTokens,
|
|
260
275
|
inputTokenCap: maxSearchInputTokens,
|
|
261
276
|
committers: changeSet.committers,
|
|
277
|
+
commitSubjects: changeSet.commitSubjects,
|
|
262
278
|
filesChanged: changeSet.summary.fileCount,
|
|
263
279
|
entitiesScanned: changeSet.summary.total,
|
|
264
280
|
candidates: contexts.length,
|
|
@@ -293,9 +309,24 @@ function withCheckWhy(matches, checks) {
|
|
|
293
309
|
const checksById = new Map(checks.map((check) => [check.id, check]));
|
|
294
310
|
return matches.map((match) => ({
|
|
295
311
|
...match,
|
|
312
|
+
patternName: checksById.get(match.patternId)?.name,
|
|
296
313
|
checkWhy: checksById.get(match.patternId)?.why,
|
|
297
314
|
}));
|
|
298
315
|
}
|
|
316
|
+
async function withEntityBlame(matches, targetRev, command) {
|
|
317
|
+
if (command.kind === "staged" || command.kind === "stdin")
|
|
318
|
+
return matches;
|
|
319
|
+
return Promise.all(matches.map(async (match) => {
|
|
320
|
+
if (!match.filePath || !match.entityName)
|
|
321
|
+
return match;
|
|
322
|
+
const blame = await blameEntity({
|
|
323
|
+
filePath: match.filePath,
|
|
324
|
+
entityName: match.entityName,
|
|
325
|
+
rev: targetRev,
|
|
326
|
+
});
|
|
327
|
+
return blame ? { ...match, blame } : match;
|
|
328
|
+
}));
|
|
329
|
+
}
|
|
299
330
|
async function buildSearchBatches(input) {
|
|
300
331
|
const first = makeSearchBatch(input, input.contexts, input.initialPack);
|
|
301
332
|
if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
|
package/dist/types.d.ts
CHANGED
|
@@ -92,6 +92,11 @@ export type StagedDiff = Readonly<{
|
|
|
92
92
|
text: string;
|
|
93
93
|
stats: NetDiffStats;
|
|
94
94
|
}>;
|
|
95
|
+
export type BlameSummary = Readonly<{
|
|
96
|
+
commit: string;
|
|
97
|
+
author: string;
|
|
98
|
+
subject: string;
|
|
99
|
+
}>;
|
|
95
100
|
export type NetDiff = Readonly<{
|
|
96
101
|
id: SourceId;
|
|
97
102
|
label: string;
|
|
@@ -106,6 +111,7 @@ export type SourceRange = Readonly<{
|
|
|
106
111
|
base: string;
|
|
107
112
|
target: string;
|
|
108
113
|
committers?: readonly string[];
|
|
114
|
+
commitSubjects?: readonly string[];
|
|
109
115
|
stats: NetDiffStats;
|
|
110
116
|
}>;
|
|
111
117
|
export type SemChange = Readonly<{
|
|
@@ -132,6 +138,7 @@ export type SemChangeSet = Readonly<{
|
|
|
132
138
|
base: string;
|
|
133
139
|
target: string;
|
|
134
140
|
committers?: readonly string[];
|
|
141
|
+
commitSubjects?: readonly string[];
|
|
135
142
|
contextCwd: string;
|
|
136
143
|
cleanup: () => Promise<void>;
|
|
137
144
|
changes: readonly SemChange[];
|
|
@@ -197,10 +204,15 @@ export type SearchProfile = Readonly<{
|
|
|
197
204
|
export type SearchMatch = Readonly<{
|
|
198
205
|
targetId: string;
|
|
199
206
|
patternId: CheckId;
|
|
207
|
+
patternName?: string;
|
|
200
208
|
checkWhy?: string;
|
|
201
209
|
reason: string;
|
|
202
210
|
proof: string;
|
|
203
211
|
snapshot?: string;
|
|
212
|
+
filePath?: string;
|
|
213
|
+
entityName?: string;
|
|
214
|
+
entityKind?: string;
|
|
215
|
+
blame?: BlameSummary;
|
|
204
216
|
}>;
|
|
205
217
|
export type SearchRunJson = Readonly<{
|
|
206
218
|
schemaVersion: "search.v1";
|
|
@@ -218,6 +230,7 @@ export type SearchRunJson = Readonly<{
|
|
|
218
230
|
skipped?: boolean;
|
|
219
231
|
skipReason?: "input_too_large" | "no_candidates";
|
|
220
232
|
committers?: readonly string[];
|
|
233
|
+
commitSubjects?: readonly string[];
|
|
221
234
|
filesChanged?: number;
|
|
222
235
|
entitiesScanned?: number;
|
|
223
236
|
candidates?: number;
|
package/package.json
CHANGED
package/src/analysis.ts
CHANGED
|
@@ -107,6 +107,9 @@ function uncheckedSearchMatches(value: unknown, contexts: readonly SemContext[])
|
|
|
107
107
|
reason: match.reason ?? "",
|
|
108
108
|
proof: sourcePointer(context),
|
|
109
109
|
snapshot: sourceSnapshot(context),
|
|
110
|
+
filePath: context.filePath,
|
|
111
|
+
entityName: context.entityName,
|
|
112
|
+
entityKind: context.entityKind,
|
|
110
113
|
}];
|
|
111
114
|
});
|
|
112
115
|
}
|
package/src/constants.ts
CHANGED