@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/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) {
|
|
@@ -48,12 +60,32 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
export async function runSearchCommand(command, startedAt, ui = createCliUi({ quiet: command.json })) {
|
|
63
|
+
const activeSpans = new Map();
|
|
51
64
|
const t = createTracer({
|
|
52
65
|
writeLine: () => undefined,
|
|
53
66
|
onEvent: (event) => {
|
|
54
67
|
if (command.json)
|
|
55
68
|
return;
|
|
56
|
-
|
|
69
|
+
if (event.phase === "start") {
|
|
70
|
+
activeSpans.set(event.name, ui.spinner(formatStartStep(event.name, event.detail)));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const active = activeSpans.get(event.name);
|
|
74
|
+
activeSpans.delete(event.name);
|
|
75
|
+
const message = event.phase === "error"
|
|
76
|
+
? formatErrorStep(event.name, event.ms)
|
|
77
|
+
: formatStep(event.name, event.ms, event.count, event.detail);
|
|
78
|
+
if (!active) {
|
|
79
|
+
if (event.phase === "error")
|
|
80
|
+
ui.error(message);
|
|
81
|
+
else
|
|
82
|
+
ui.step(message);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (event.phase === "error")
|
|
86
|
+
active.error(message);
|
|
87
|
+
else
|
|
88
|
+
active.stop(message);
|
|
57
89
|
},
|
|
58
90
|
});
|
|
59
91
|
const profile = await loadSearchProfile(command.searchProfilePath);
|
|
@@ -85,6 +117,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
85
117
|
elapsedMs: Date.now() - startedAt,
|
|
86
118
|
modelCalls: 0,
|
|
87
119
|
committers: changeSet.committers,
|
|
120
|
+
commitSubjects: changeSet.commitSubjects,
|
|
88
121
|
skipped: true,
|
|
89
122
|
skipReason: "no_candidates",
|
|
90
123
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -124,6 +157,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
124
157
|
elapsedMs: Date.now() - startedAt,
|
|
125
158
|
modelCalls: 0,
|
|
126
159
|
committers: changeSet.committers,
|
|
160
|
+
commitSubjects: changeSet.commitSubjects,
|
|
127
161
|
skipped: true,
|
|
128
162
|
skipReason: "no_candidates",
|
|
129
163
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -143,7 +177,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
143
177
|
const pack = profile?.context === "sem" || searchContexts.length === contexts.length
|
|
144
178
|
? initialPack
|
|
145
179
|
: await repomixContextPack(changeSet.contextCwd, searchContexts, changeSet.changes, baseRepomixConfig);
|
|
146
|
-
const batches = await buildSearchBatches({
|
|
180
|
+
const { value: batches } = await t.trace("search.batches", () => buildSearchBatches({
|
|
147
181
|
command,
|
|
148
182
|
changeSet,
|
|
149
183
|
contexts: searchContexts,
|
|
@@ -153,6 +187,12 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
153
187
|
includeCounterReasonInPrompt: command.includeCounterReasonInPrompt,
|
|
154
188
|
maxSearchInputTokens,
|
|
155
189
|
baseRepomixConfig,
|
|
190
|
+
}), {
|
|
191
|
+
startDetail: `${searchContexts.length} targets`,
|
|
192
|
+
count: (result) => result.batches.length,
|
|
193
|
+
detail: (result) => result.wasSplit
|
|
194
|
+
? `${result.skippedTargets} oversized targets skipped`
|
|
195
|
+
: `${result.estimatedInputTokens} estimated tokens`,
|
|
156
196
|
});
|
|
157
197
|
if (batches.batches.length === 0) {
|
|
158
198
|
return {
|
|
@@ -167,6 +207,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
167
207
|
inputTokens: batches.estimatedInputTokens,
|
|
168
208
|
inputTokenCap: maxSearchInputTokens,
|
|
169
209
|
committers: changeSet.committers,
|
|
210
|
+
commitSubjects: changeSet.commitSubjects,
|
|
170
211
|
skipped: true,
|
|
171
212
|
skipReason: "input_too_large",
|
|
172
213
|
filesChanged: changeSet.summary.fileCount,
|
|
@@ -201,7 +242,10 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
201
242
|
let inputTokens = 0;
|
|
202
243
|
let exactSkippedTargets = batches.skippedTargets;
|
|
203
244
|
for (const batch of batches.batches) {
|
|
204
|
-
const batchInputTokens = await countPromptTokens(model, batch.request.prompt)
|
|
245
|
+
const { value: batchInputTokens } = await t.trace("prompt.tokens", () => countPromptTokens(model, batch.request.prompt), {
|
|
246
|
+
startDetail: `${batch.contexts.length} targets`,
|
|
247
|
+
count: (tokens) => tokens,
|
|
248
|
+
});
|
|
205
249
|
inputTokens += batchInputTokens;
|
|
206
250
|
if (batchInputTokens > maxSearchInputTokens) {
|
|
207
251
|
exactSkippedTargets += batch.contexts.length;
|
|
@@ -210,11 +254,14 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
210
254
|
}
|
|
211
255
|
continue;
|
|
212
256
|
}
|
|
213
|
-
const { value } = await t.trace("search.model", () => runSearch(model, batch.request), {
|
|
257
|
+
const { value } = await t.trace("search.model", () => runSearch(model, batch.request), {
|
|
258
|
+
startDetail: `${batch.contexts.length} targets`,
|
|
259
|
+
count: (v) => v.length,
|
|
260
|
+
});
|
|
214
261
|
modelCalls += 1;
|
|
215
262
|
matches.push(...withCheckWhy(value, checks));
|
|
216
263
|
}
|
|
217
|
-
const uniqueMatches = dedupeMatches(matches);
|
|
264
|
+
const uniqueMatches = await withEntityBlame(dedupeMatches(matches), changeSet.target, command);
|
|
218
265
|
return {
|
|
219
266
|
schemaVersion: "search.v1",
|
|
220
267
|
mode: "search",
|
|
@@ -227,6 +274,7 @@ export async function runSearchCommand(command, startedAt, ui = createCliUi({ qu
|
|
|
227
274
|
inputTokens,
|
|
228
275
|
inputTokenCap: maxSearchInputTokens,
|
|
229
276
|
committers: changeSet.committers,
|
|
277
|
+
commitSubjects: changeSet.commitSubjects,
|
|
230
278
|
filesChanged: changeSet.summary.fileCount,
|
|
231
279
|
entitiesScanned: changeSet.summary.total,
|
|
232
280
|
candidates: contexts.length,
|
|
@@ -261,9 +309,24 @@ function withCheckWhy(matches, checks) {
|
|
|
261
309
|
const checksById = new Map(checks.map((check) => [check.id, check]));
|
|
262
310
|
return matches.map((match) => ({
|
|
263
311
|
...match,
|
|
312
|
+
patternName: checksById.get(match.patternId)?.name,
|
|
264
313
|
checkWhy: checksById.get(match.patternId)?.why,
|
|
265
314
|
}));
|
|
266
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
|
+
}
|
|
267
330
|
async function buildSearchBatches(input) {
|
|
268
331
|
const first = makeSearchBatch(input, input.contexts, input.initialPack);
|
|
269
332
|
if (first.estimatedInputTokens <= input.maxSearchInputTokens) {
|
|
@@ -344,15 +407,45 @@ function printRunPlan(command, patternIds, ui) {
|
|
|
344
407
|
`Patterns: ${patternIds.join(", ")}`,
|
|
345
408
|
].join("\n"), "Run");
|
|
346
409
|
}
|
|
410
|
+
function formatStartStep(name, detail) {
|
|
411
|
+
if (name === "entity.diff")
|
|
412
|
+
return "Diff: running sem over the selected git range";
|
|
413
|
+
if (name === "context.pack")
|
|
414
|
+
return "Context: packing selected target files with Repomix";
|
|
415
|
+
if (name === "search.batches")
|
|
416
|
+
return `Search: preparing token-bounded model batches${detail ? ` for ${detail}` : ""}`;
|
|
417
|
+
if (name === "prompt.tokens")
|
|
418
|
+
return `Tokens: counting search prompt${detail ? ` for ${detail}` : ""}`;
|
|
419
|
+
if (name === "search.model")
|
|
420
|
+
return `Model: searching selected target/check pairs${detail ? ` (${detail})` : ""}`;
|
|
421
|
+
return `${name}: working`;
|
|
422
|
+
}
|
|
347
423
|
function formatStep(name, ms, count, detail) {
|
|
348
424
|
if (name === "entity.diff")
|
|
349
425
|
return `Diff: ${detail ?? "changed files"}, ${count ?? 0} changed entities (${ms}ms)`;
|
|
350
426
|
if (name === "context.pack")
|
|
351
427
|
return `Context: ${count ?? 0} files, ${detail ?? "0 tokens"} (${ms}ms)`;
|
|
428
|
+
if (name === "search.batches")
|
|
429
|
+
return `Search: ${count ?? 0} model batches, ${detail ?? "0 estimated tokens"} (${ms}ms)`;
|
|
430
|
+
if (name === "prompt.tokens")
|
|
431
|
+
return `Tokens: ${count ?? 0} prompt tokens (${ms}ms)`;
|
|
352
432
|
if (name === "search.model")
|
|
353
433
|
return `Model: ${count ?? 0} matches (${ms}ms)`;
|
|
354
434
|
return `${name}: ${ms}ms`;
|
|
355
435
|
}
|
|
436
|
+
function formatErrorStep(name, ms) {
|
|
437
|
+
if (name === "entity.diff")
|
|
438
|
+
return `Diff failed after ${ms}ms`;
|
|
439
|
+
if (name === "context.pack")
|
|
440
|
+
return `Context packing failed after ${ms}ms`;
|
|
441
|
+
if (name === "search.batches")
|
|
442
|
+
return `Search batch preparation failed after ${ms}ms`;
|
|
443
|
+
if (name === "prompt.tokens")
|
|
444
|
+
return `Token counting failed after ${ms}ms`;
|
|
445
|
+
if (name === "search.model")
|
|
446
|
+
return `Model search failed after ${ms}ms`;
|
|
447
|
+
return `${name} failed after ${ms}ms`;
|
|
448
|
+
}
|
|
356
449
|
function scoutPlanLine(plan, entitiesScanned) {
|
|
357
450
|
if (plan.targets.length === 0) {
|
|
358
451
|
return `Scout: deterministic counters scanned ${entitiesScanned} entities; no target/check pairs selected`;
|
package/dist/trace.d.ts
CHANGED
|
@@ -11,12 +11,14 @@ export type Tracer = {
|
|
|
11
11
|
};
|
|
12
12
|
export type SpanTraceEvent = Readonly<{
|
|
13
13
|
name: string;
|
|
14
|
+
phase: "start" | "end" | "error";
|
|
14
15
|
ms: number;
|
|
15
16
|
count?: number;
|
|
16
17
|
detail?: string;
|
|
17
18
|
}>;
|
|
18
19
|
export type SpanTraceOptions<T> = Readonly<{
|
|
19
20
|
fields?: TraceFields;
|
|
21
|
+
startDetail?: string | (() => string);
|
|
20
22
|
count?: (value: T) => number;
|
|
21
23
|
detail?: (value: T) => string;
|
|
22
24
|
}>;
|
package/dist/trace.js
CHANGED
|
@@ -16,6 +16,12 @@ export function createTracer(options) {
|
|
|
16
16
|
}
|
|
17
17
|
function trace(span, fn, options) {
|
|
18
18
|
const startedAtMs = nowMs();
|
|
19
|
+
onEvent?.({
|
|
20
|
+
name: span,
|
|
21
|
+
phase: "start",
|
|
22
|
+
ms: 0,
|
|
23
|
+
detail: typeof options?.startDetail === "function" ? options.startDetail() : options?.startDetail,
|
|
24
|
+
});
|
|
19
25
|
try {
|
|
20
26
|
const out = fn();
|
|
21
27
|
if (isPromiseLike(out)) {
|
|
@@ -26,6 +32,7 @@ export function createTracer(options) {
|
|
|
26
32
|
durationMs = nowMs() - startedAtMs;
|
|
27
33
|
const event = {
|
|
28
34
|
name: span,
|
|
35
|
+
phase: "end",
|
|
29
36
|
ms: Math.round(durationMs),
|
|
30
37
|
count: options?.count?.(value),
|
|
31
38
|
detail: options?.detail?.(value),
|
|
@@ -33,6 +40,15 @@ export function createTracer(options) {
|
|
|
33
40
|
onEvent?.(event);
|
|
34
41
|
return { value, ms: event.ms };
|
|
35
42
|
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
durationMs = nowMs() - startedAtMs;
|
|
45
|
+
onEvent?.({
|
|
46
|
+
name: span,
|
|
47
|
+
phase: "error",
|
|
48
|
+
ms: Math.round(durationMs),
|
|
49
|
+
});
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
36
52
|
finally {
|
|
37
53
|
durationMs ??= nowMs() - startedAtMs;
|
|
38
54
|
emit(span, durationMs, options?.fields);
|
|
@@ -43,6 +59,7 @@ export function createTracer(options) {
|
|
|
43
59
|
emit(span, durationMs, options?.fields);
|
|
44
60
|
const event = {
|
|
45
61
|
name: span,
|
|
62
|
+
phase: "end",
|
|
46
63
|
ms: Math.round(durationMs),
|
|
47
64
|
count: options?.count?.(out),
|
|
48
65
|
detail: options?.detail?.(out),
|
|
@@ -52,6 +69,11 @@ export function createTracer(options) {
|
|
|
52
69
|
}
|
|
53
70
|
catch (error) {
|
|
54
71
|
const durationMs = nowMs() - startedAtMs;
|
|
72
|
+
onEvent?.({
|
|
73
|
+
name: span,
|
|
74
|
+
phase: "error",
|
|
75
|
+
ms: Math.round(durationMs),
|
|
76
|
+
});
|
|
55
77
|
emit(span, durationMs, options?.fields);
|
|
56
78
|
throw error;
|
|
57
79
|
}
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stupify/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+
|
|
12
|
+
"url": "git+https://github.com/Octember/stupif.ai.git",
|
|
13
13
|
"directory": "packages/cli"
|
|
14
14
|
},
|
|
15
15
|
"homepage": "https://stupif.ai",
|
|
@@ -23,16 +23,18 @@
|
|
|
23
23
|
"local-first",
|
|
24
24
|
"code-review"
|
|
25
25
|
],
|
|
26
|
-
"license": "
|
|
26
|
+
"license": "MIT",
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=20"
|
|
29
29
|
},
|
|
30
30
|
"publishConfig": {
|
|
31
|
-
"access": "public"
|
|
31
|
+
"access": "public",
|
|
32
|
+
"provenance": true
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
35
|
"dist",
|
|
35
36
|
"src",
|
|
37
|
+
"LICENSE",
|
|
36
38
|
"README.md",
|
|
37
39
|
"package.json"
|
|
38
40
|
],
|
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
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
|
}
|