@stupify/cli 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -31
- package/dist/analysis.d.ts +11 -9
- package/dist/analysis.js +30 -173
- package/dist/checks.d.ts +1 -0
- package/dist/checks.js +89 -2
- package/dist/command.js +55 -91
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/counter-scout.js +70 -8
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +131 -0
- package/dist/git.d.ts +4 -1
- package/dist/git.js +34 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.js +117 -0
- package/dist/model.d.ts +1 -15
- package/dist/model.js +37 -21
- package/dist/prompts.d.ts +8 -5
- package/dist/prompts.js +58 -168
- package/dist/render.d.ts +2 -2
- package/dist/render.js +70 -78
- package/dist/repomix-provider.d.ts +10 -2
- package/dist/repomix-provider.js +62 -11
- package/dist/search-bench.d.ts +1 -0
- package/dist/search-bench.js +675 -0
- package/dist/search-profile.d.ts +6 -0
- package/dist/search-profile.js +73 -0
- package/dist/sem-provider.d.ts +2 -2
- package/dist/sem-provider.js +33 -7
- package/dist/stupify.d.ts +2 -0
- package/dist/stupify.js +183 -333
- package/dist/types.d.ts +193 -109
- package/package.json +1 -1
- package/src/analysis.ts +48 -268
- package/src/checks.ts +91 -2
- package/src/command.ts +62 -107
- package/src/constants.ts +1 -1
- package/src/counter-scout.ts +63 -7
- package/src/doctor.ts +140 -0
- package/src/git.ts +35 -1
- package/src/hooks.ts +134 -0
- package/src/model.ts +39 -26
- package/src/prompts.ts +66 -202
- package/src/render.ts +68 -79
- package/src/repomix-provider.ts +66 -10
- package/src/search-bench.ts +783 -0
- package/src/search-profile.ts +89 -0
- package/src/sem-provider.ts +36 -9
- package/src/stupify.ts +213 -526
- package/src/types.ts +195 -119
- package/dist/batcher.d.ts +0 -3
- package/dist/batcher.js +0 -142
- package/dist/candidate-context.d.ts +0 -2
- package/dist/candidate-context.js +0 -40
- package/dist/experiment.d.ts +0 -1
- package/dist/experiment.js +0 -225
- package/src/batcher.ts +0 -198
- package/src/candidate-context.ts +0 -43
- package/src/experiment.ts +0 -317
package/src/command.ts
CHANGED
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
|
|
2
|
-
import type {
|
|
2
|
+
import type { Command, HookAction, ModelId, SearchSource } from "./types.ts";
|
|
3
3
|
|
|
4
4
|
const DEFAULT_SINCE = "2 weeks ago";
|
|
5
5
|
const DEFAULT_MAX_CANDIDATES = 10;
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
const DEFAULT_AUDIT_PROMPT: AuditPromptName = "high_bar";
|
|
9
|
-
const DEFAULT_AUDIT_BATCH_SIZE = 25;
|
|
10
|
-
const DEFAULT_MAX_AUDIT_INPUT_TOKENS = 20_000;
|
|
11
|
-
const DEFAULT_AUDIT_CONCURRENCY = 2;
|
|
6
|
+
const DEFAULT_MAX_SEARCH_INPUT_TOKENS = 12_000;
|
|
7
|
+
|
|
12
8
|
type InputMode =
|
|
13
|
-
| Readonly<{ kind: "since"; since: string }>
|
|
14
|
-
| Readonly<{ kind: "stdin" }>
|
|
15
|
-
| Readonly<{ kind: "commit"; commit: string }>
|
|
16
|
-
| Readonly<{ kind: "commits"; count: number }
|
|
9
|
+
| Readonly<{ kind: "since"; since: string; source: "since" }>
|
|
10
|
+
| Readonly<{ kind: "stdin"; source: "stdin" }>
|
|
11
|
+
| Readonly<{ kind: "commit"; commit: string; source: "commit" }>
|
|
12
|
+
| Readonly<{ kind: "commits"; count: number; source: "commits" }>
|
|
13
|
+
| Readonly<{ kind: "staged"; source: "staged" }>;
|
|
17
14
|
|
|
18
15
|
export function parseCommand(argv: readonly string[]): Command {
|
|
19
16
|
if (argv.length === 1 && isHelp(argv[0])) return { kind: "help" };
|
|
20
|
-
if (argv[0] === "
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
if (argv[0] === "bench") {
|
|
18
|
+
if (argv[1] !== "search" || !argv[2] || argv.length > 3) {
|
|
19
|
+
throw new Error("Usage: stupify bench search <config.json>");
|
|
20
|
+
}
|
|
21
|
+
return { kind: "bench-search", configPath: argv[2] };
|
|
22
|
+
}
|
|
23
|
+
if (argv[0] === "hook") {
|
|
24
|
+
const action = argv[1];
|
|
25
|
+
if (!action || !isHookAction(action) || argv.length > 2) {
|
|
26
|
+
throw new Error("Usage: stupify hook install|uninstall|status");
|
|
27
|
+
}
|
|
28
|
+
return { kind: "hook", action };
|
|
29
|
+
}
|
|
30
|
+
if (argv[0] === "doctor") {
|
|
31
|
+
if (argv.length > 1) throw new Error("Usage: stupify doctor");
|
|
32
|
+
return { kind: "doctor" };
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
type ParseState = Readonly<{
|
|
@@ -29,52 +38,38 @@ export function parseCommand(argv: readonly string[]): Command {
|
|
|
29
38
|
checkIds: readonly string[] | null;
|
|
30
39
|
json: boolean;
|
|
31
40
|
model: ModelId;
|
|
32
|
-
engine: Engine;
|
|
33
|
-
scout: ScoutMode;
|
|
34
|
-
auditContext: AuditContextMode;
|
|
35
|
-
auditPrompt: AuditPromptName;
|
|
36
41
|
debugSem: boolean;
|
|
37
|
-
debugTargets: boolean;
|
|
38
42
|
maxCandidates: number;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
maxSearchInputTokens: number;
|
|
44
|
+
searchProfilePath: string | null;
|
|
45
|
+
includeCounterReasonInPrompt: boolean;
|
|
42
46
|
}>;
|
|
43
47
|
|
|
44
48
|
const initialState: ParseState = {
|
|
45
|
-
inputMode: { kind: "since", since: DEFAULT_SINCE },
|
|
49
|
+
inputMode: { kind: "since", since: DEFAULT_SINCE, source: "since" },
|
|
46
50
|
explicitInputMode: false,
|
|
47
51
|
checkIds: null,
|
|
48
52
|
json: false,
|
|
49
53
|
model: DEFAULT_MODEL_ID,
|
|
50
|
-
engine: "raw-diff",
|
|
51
|
-
scout: DEFAULT_SCOUT_MODE,
|
|
52
|
-
auditContext: DEFAULT_AUDIT_CONTEXT,
|
|
53
|
-
auditPrompt: DEFAULT_AUDIT_PROMPT,
|
|
54
54
|
debugSem: false,
|
|
55
|
-
debugTargets: false,
|
|
56
55
|
maxCandidates: DEFAULT_MAX_CANDIDATES,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
56
|
+
maxSearchInputTokens: DEFAULT_MAX_SEARCH_INPUT_TOKENS,
|
|
57
|
+
searchProfilePath: null,
|
|
58
|
+
includeCounterReasonInPrompt: false,
|
|
60
59
|
};
|
|
61
60
|
|
|
62
61
|
const finalState = parseFrom(0, initialState);
|
|
63
62
|
return {
|
|
64
63
|
...finalState.inputMode,
|
|
64
|
+
mode: "search",
|
|
65
65
|
checkIds: finalState.checkIds,
|
|
66
66
|
json: finalState.json,
|
|
67
67
|
model: finalState.model,
|
|
68
|
-
engine: finalState.engine,
|
|
69
|
-
scout: finalState.scout,
|
|
70
|
-
auditContext: finalState.auditContext,
|
|
71
|
-
auditPrompt: finalState.auditPrompt,
|
|
72
68
|
debugSem: finalState.debugSem,
|
|
73
|
-
debugTargets: finalState.debugTargets,
|
|
74
69
|
maxCandidates: finalState.maxCandidates,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
maxSearchInputTokens: finalState.maxSearchInputTokens,
|
|
71
|
+
searchProfilePath: finalState.searchProfilePath,
|
|
72
|
+
includeCounterReasonInPrompt: finalState.includeCounterReasonInPrompt,
|
|
78
73
|
};
|
|
79
74
|
|
|
80
75
|
function parseFrom(index: number, state: ParseState): ParseState {
|
|
@@ -82,28 +77,42 @@ export function parseCommand(argv: readonly string[]): Command {
|
|
|
82
77
|
|
|
83
78
|
const arg = argv[index];
|
|
84
79
|
|
|
85
|
-
if (arg === "--
|
|
80
|
+
if (arg === "--mode") {
|
|
81
|
+
const value = argv[index + 1];
|
|
82
|
+
if (value !== "search") throw new Error("--mode only supports search.");
|
|
83
|
+
return parseFrom(index + 2, state);
|
|
84
|
+
}
|
|
85
|
+
if (arg === "--staged") return parseFrom(index + 1, setInputMode(state, { kind: "staged", source: "staged" }));
|
|
86
|
+
if (arg === "--stdin") return parseFrom(index + 1, setInputMode(state, { kind: "stdin", source: "stdin" }));
|
|
86
87
|
if (arg === "--json") return parseFrom(index + 1, { ...state, json: true });
|
|
87
88
|
if (arg === "--debug-sem") return parseFrom(index + 1, { ...state, debugSem: true });
|
|
88
|
-
if (arg === "--
|
|
89
|
+
if (arg === "--include-counter-reason-in-prompt") {
|
|
90
|
+
return parseFrom(index + 1, { ...state, includeCounterReasonInPrompt: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (arg === "--search-profile") {
|
|
94
|
+
const value = argv[index + 1];
|
|
95
|
+
if (!value || value.startsWith("-")) throw new Error("--search-profile requires a JSON profile path.");
|
|
96
|
+
return parseFrom(index + 2, { ...state, searchProfilePath: value });
|
|
97
|
+
}
|
|
89
98
|
|
|
90
99
|
if (arg === "--since") {
|
|
91
100
|
const value = argv[index + 1];
|
|
92
101
|
if (!value || value.startsWith("-")) throw new Error("--since requires a git date, such as \"2 weeks ago\".");
|
|
93
|
-
return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value }));
|
|
102
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value, source: "since" }));
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
if (arg === "--commit") {
|
|
97
106
|
const value = argv[index + 1];
|
|
98
107
|
if (!value || !isSafeCommitArg(value)) throw new Error("Invalid commit.");
|
|
99
|
-
return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value }));
|
|
108
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value, source: "commit" }));
|
|
100
109
|
}
|
|
101
110
|
|
|
102
111
|
if (arg === "--commits") {
|
|
103
112
|
const value = argv[index + 1];
|
|
104
113
|
const count = Number(value);
|
|
105
114
|
if (!Number.isInteger(count) || count < 1) throw new Error("--commits requires a positive integer.");
|
|
106
|
-
return parseFrom(index + 2, setInputMode(state, { kind: "commits", count }));
|
|
115
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commits", count, source: "commits" }));
|
|
107
116
|
}
|
|
108
117
|
|
|
109
118
|
if (arg === "--checks") {
|
|
@@ -120,30 +129,6 @@ export function parseCommand(argv: readonly string[]): Command {
|
|
|
120
129
|
return parseFrom(index + 2, { ...state, model: value });
|
|
121
130
|
}
|
|
122
131
|
|
|
123
|
-
if (arg === "--engine") {
|
|
124
|
-
const value = argv[index + 1];
|
|
125
|
-
if (!value || !isEngine(value)) throw new Error("--engine must be raw-diff or sem.");
|
|
126
|
-
return parseFrom(index + 2, { ...state, engine: value });
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
if (arg === "--scout") {
|
|
130
|
-
const value = argv[index + 1];
|
|
131
|
-
if (!value || !isScoutMode(value)) throw new Error("--scout must be counter or llm.");
|
|
132
|
-
return parseFrom(index + 2, { ...state, scout: value });
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
if (arg === "--audit-context") {
|
|
136
|
-
const value = argv[index + 1];
|
|
137
|
-
if (!value || !isAuditContextMode(value)) throw new Error("--audit-context must be none or repomix.");
|
|
138
|
-
return parseFrom(index + 2, { ...state, auditContext: value });
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (arg === "--audit-prompt") {
|
|
142
|
-
const value = argv[index + 1];
|
|
143
|
-
if (!value || !isAuditPromptName(value)) throw new Error("--audit-prompt must be strict or high_bar.");
|
|
144
|
-
return parseFrom(index + 2, { ...state, auditPrompt: value });
|
|
145
|
-
}
|
|
146
|
-
|
|
147
132
|
if (arg === "--max-candidates") {
|
|
148
133
|
const value = argv[index + 1];
|
|
149
134
|
const maxCandidates = Number(value);
|
|
@@ -153,38 +138,20 @@ export function parseCommand(argv: readonly string[]): Command {
|
|
|
153
138
|
return parseFrom(index + 2, { ...state, maxCandidates });
|
|
154
139
|
}
|
|
155
140
|
|
|
156
|
-
if (arg === "--
|
|
157
|
-
const value = argv[index + 1];
|
|
158
|
-
const auditBatchSize = Number(value);
|
|
159
|
-
if (!Number.isInteger(auditBatchSize) || auditBatchSize < 1) {
|
|
160
|
-
throw new Error("--audit-batch-size requires a positive integer.");
|
|
161
|
-
}
|
|
162
|
-
return parseFrom(index + 2, { ...state, auditBatchSize });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (arg === "--max-audit-input-tokens") {
|
|
166
|
-
const value = argv[index + 1];
|
|
167
|
-
const maxAuditInputTokens = Number(value);
|
|
168
|
-
if (!Number.isInteger(maxAuditInputTokens) || maxAuditInputTokens < 1) {
|
|
169
|
-
throw new Error("--max-audit-input-tokens requires a positive integer.");
|
|
170
|
-
}
|
|
171
|
-
return parseFrom(index + 2, { ...state, maxAuditInputTokens });
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (arg === "--audit-concurrency") {
|
|
141
|
+
if (arg === "--max-search-input-tokens") {
|
|
175
142
|
const value = argv[index + 1];
|
|
176
|
-
const
|
|
177
|
-
if (!Number.isInteger(
|
|
178
|
-
throw new Error("--
|
|
143
|
+
const maxSearchInputTokens = Number(value);
|
|
144
|
+
if (!Number.isInteger(maxSearchInputTokens) || maxSearchInputTokens < 1) {
|
|
145
|
+
throw new Error("--max-search-input-tokens requires a positive integer.");
|
|
179
146
|
}
|
|
180
|
-
return parseFrom(index + 2, { ...state,
|
|
147
|
+
return parseFrom(index + 2, { ...state, maxSearchInputTokens });
|
|
181
148
|
}
|
|
182
149
|
|
|
183
150
|
throw new Error(`Unknown option: ${arg}`);
|
|
184
151
|
}
|
|
185
152
|
|
|
186
153
|
function setInputMode(state: ParseState, next: InputMode): ParseState {
|
|
187
|
-
if (state.explicitInputMode) throw new Error("Choose only one input mode: --since, --stdin, --commit, or --
|
|
154
|
+
if (state.explicitInputMode) throw new Error("Choose only one input mode: --since, --stdin, --commit, --commits, or --staged.");
|
|
188
155
|
return { ...state, inputMode: next, explicitInputMode: true };
|
|
189
156
|
}
|
|
190
157
|
}
|
|
@@ -201,18 +168,6 @@ function isModelId(value: string): value is ModelId {
|
|
|
201
168
|
return value in MODEL_REGISTRY;
|
|
202
169
|
}
|
|
203
170
|
|
|
204
|
-
function
|
|
205
|
-
return value === "
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function isScoutMode(value: string): value is ScoutMode {
|
|
209
|
-
return value === "counter" || value === "llm";
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function isAuditContextMode(value: string): value is AuditContextMode {
|
|
213
|
-
return value === "none" || value === "repomix";
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function isAuditPromptName(value: string): value is AuditPromptName {
|
|
217
|
-
return value === "strict" || value === "high_bar";
|
|
171
|
+
function isHookAction(value: string): value is HookAction {
|
|
172
|
+
return value === "install" || value === "uninstall" || value === "status";
|
|
218
173
|
}
|
package/src/constants.ts
CHANGED
package/src/counter-scout.ts
CHANGED
|
@@ -60,13 +60,15 @@ export function runSignalCounters(
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function reasonForCheck(checkId: CheckId, change: SemChange): string | null {
|
|
63
|
+
if (!isSearchableSourceChange(change)) return null;
|
|
64
|
+
|
|
63
65
|
const haystack = `${change.entityName}\n${change.entityType}\n${change.filePath}\n${change.afterContent ?? ""}`.toLowerCase();
|
|
64
66
|
const changed = change.changeType === "added" || change.changeType === "modified";
|
|
65
67
|
if (!changed) return null;
|
|
66
68
|
|
|
67
69
|
switch (checkId as string) {
|
|
68
70
|
case "duplicated_schema":
|
|
69
|
-
return
|
|
71
|
+
return isDuplicatedSchemaCandidate(change) ? "local_schemaish_copy" : null;
|
|
70
72
|
case "unnecessary_complexity":
|
|
71
73
|
return /\b(helper|wrapper|service|provider|manager|factory|adapter|resolver|coordinator)\b/i.test(change.entityName)
|
|
72
74
|
? "new_abstraction_name"
|
|
@@ -84,11 +86,11 @@ function reasonForCheck(checkId: CheckId, change: SemChange): string | null {
|
|
|
84
86
|
? "large_changed_chunk"
|
|
85
87
|
: null;
|
|
86
88
|
case "over_commenting":
|
|
87
|
-
return
|
|
89
|
+
return overCommentingSignal(change)
|
|
88
90
|
? "comment_lines_increased"
|
|
89
91
|
: null;
|
|
90
92
|
case "lint_bypass":
|
|
91
|
-
return
|
|
93
|
+
return lintBypassSignal(change.afterContent ?? "")
|
|
92
94
|
? "lint_or_type_bypass_text"
|
|
93
95
|
: null;
|
|
94
96
|
case "inconsistent_patterns":
|
|
@@ -96,7 +98,7 @@ function reasonForCheck(checkId: CheckId, change: SemChange): string | null {
|
|
|
96
98
|
? "pattern_abstraction_name"
|
|
97
99
|
: null;
|
|
98
100
|
case "reinvented_utils":
|
|
99
|
-
return
|
|
101
|
+
return reinventedUtilitySignal(change)
|
|
100
102
|
? "generic_utility_name"
|
|
101
103
|
: null;
|
|
102
104
|
case "operator_style_mismatch":
|
|
@@ -108,12 +110,66 @@ function reasonForCheck(checkId: CheckId, change: SemChange): string | null {
|
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
function
|
|
112
|
-
if (
|
|
113
|
-
|
|
113
|
+
function isDuplicatedSchemaCandidate(change: SemChange): boolean {
|
|
114
|
+
if (!/^(interface|type)$/i.test(change.entityType)) return false;
|
|
115
|
+
if (/^(public|external|internal|payment|.+client$)/i.test(change.entityName)) return false;
|
|
116
|
+
return /\b(local|payload|schema)\b/i.test(words(change.entityName));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function overCommentingSignal(change: SemChange): boolean {
|
|
120
|
+
const before = commentLines(change.beforeContent);
|
|
121
|
+
const after = commentLines(change.afterContent);
|
|
122
|
+
if (after <= before + 3) return false;
|
|
123
|
+
const comments = commentText(change.afterContent);
|
|
124
|
+
if (/\b(because|why|constraint|provider|external|api|quirk|edge case|timezone|utc|ledger|finance|reconciliation|rejects|mirrors|keep this)\b/i.test(comments)) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function lintBypassSignal(value: string): boolean {
|
|
131
|
+
return value.split(/\r?\n/).some((line) => {
|
|
132
|
+
const trimmed = line.trim();
|
|
133
|
+
const comment = /^(\/\/|\/\*|\*)/.test(trimmed);
|
|
134
|
+
if (comment && /@ts-ignore\s*$/i.test(trimmed)) return true;
|
|
135
|
+
if (comment && /@ts-expect-error\s*$/i.test(trimmed)) return true;
|
|
136
|
+
if (comment && /(eslint-disable|biome-ignore)/i.test(trimmed) && !/\s--\s*\S/.test(trimmed)) return true;
|
|
137
|
+
return /\bas unknown as\b|\bas any\b|:\s*any\b/i.test(trimmed);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function reinventedUtilitySignal(change: SemChange): boolean {
|
|
142
|
+
const name = change.entityName;
|
|
143
|
+
if (!/^(clamp|debounce|throttle|slug|slugify|group|sort|shuffle|memoize|pick|omit|uniq)/i.test(name)) return false;
|
|
144
|
+
const content = change.afterContent ?? "";
|
|
145
|
+
if (/currency|invoice|refund|subscription|tier|domain/i.test(`${name}\n${content}`)) return false;
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isSearchableSourceChange(change: SemChange): boolean {
|
|
150
|
+
const filePath = change.filePath.toLowerCase();
|
|
151
|
+
if (/(^|\/)(bun|package-lock|pnpm-lock|yarn)\.lock$/.test(filePath)) return false;
|
|
152
|
+
if (/(^|\/)(dist|build|coverage|generated|vendor|fixtures?|snapshots?)(\/|$)/.test(filePath)) return false;
|
|
153
|
+
if (/\.(md|mdx|txt|json|jsonc|ya?ml|toml|lock|csv|svg|png|jpe?g|gif|webp)$/i.test(filePath)) return false;
|
|
154
|
+
if (/\.(test|spec|fixture)\.[cm]?[jt]sx?$/i.test(filePath)) return false;
|
|
155
|
+
return /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i.test(filePath);
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
function commentLines(value: string | null): number {
|
|
117
159
|
if (!value) return 0;
|
|
118
160
|
return value.split(/\r?\n/).filter((line) => /^\s*(\/\/|\/\*|\*|#)/.test(line)).length;
|
|
119
161
|
}
|
|
162
|
+
|
|
163
|
+
function commentText(value: string | null): string {
|
|
164
|
+
if (!value) return "";
|
|
165
|
+
return value
|
|
166
|
+
.split(/\r?\n/)
|
|
167
|
+
.filter((line) => /^\s*(\/\/|\/\*|\*|#)/.test(line))
|
|
168
|
+
.join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function words(value: string): string {
|
|
172
|
+
return value
|
|
173
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
174
|
+
.replace(/[_-]+/g, " ");
|
|
175
|
+
}
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { homedir, platform } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
|
|
7
|
+
import { runHookCommand } from "./hooks.ts";
|
|
8
|
+
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
|
|
11
|
+
type CheckStatus = "ok" | "missing" | "info";
|
|
12
|
+
|
|
13
|
+
type DoctorCheck = Readonly<{
|
|
14
|
+
label: string;
|
|
15
|
+
status: CheckStatus;
|
|
16
|
+
detail: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
export async function runDoctor(): Promise<Readonly<{ exitCode: number; text: string }>> {
|
|
21
|
+
const checks = await Promise.all([
|
|
22
|
+
gitCheck(),
|
|
23
|
+
hookCheck(),
|
|
24
|
+
semCheck(),
|
|
25
|
+
repomixCheck(),
|
|
26
|
+
llamaServerCheck(),
|
|
27
|
+
modelCacheCheck(),
|
|
28
|
+
]);
|
|
29
|
+
const requiredMissing = checks.some((check) => check.required && check.status === "missing");
|
|
30
|
+
return {
|
|
31
|
+
exitCode: requiredMissing ? 1 : 0,
|
|
32
|
+
text: renderDoctor(checks),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function gitCheck(): Promise<DoctorCheck> {
|
|
37
|
+
try {
|
|
38
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"], { maxBuffer: 1024 * 1024 });
|
|
39
|
+
return { label: "git repo", status: "ok", detail: stdout.trim(), required: true };
|
|
40
|
+
} catch {
|
|
41
|
+
return { label: "git repo", status: "missing", detail: "not inside a git repository", required: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function hookCheck(): Promise<DoctorCheck> {
|
|
46
|
+
try {
|
|
47
|
+
const status = await runHookCommand("status");
|
|
48
|
+
return { label: "pre-commit hook", status: "info", detail: status.replace(/^Stupify hook:\s*/, "") };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return { label: "pre-commit hook", status: "info", detail: errorMessage(error) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function semCheck(): Promise<DoctorCheck> {
|
|
55
|
+
const packageBin = resolvePackage("@ataraxy-labs/sem/bin/sem.js");
|
|
56
|
+
if (packageBin) return { label: "sem", status: "ok", detail: "@ataraxy-labs/sem package binary found", required: true };
|
|
57
|
+
if (await commandExists("sem")) return { label: "sem", status: "ok", detail: "sem found on PATH", required: true };
|
|
58
|
+
return { label: "sem", status: "missing", detail: "install @ataraxy-labs/sem or put sem on PATH", required: true };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function repomixCheck(): Promise<DoctorCheck> {
|
|
62
|
+
if (resolvePackage("repomix")) return { label: "Repomix", status: "ok", detail: "repomix package found", required: true };
|
|
63
|
+
return { label: "Repomix", status: "missing", detail: "repomix package is not installed", required: true };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function llamaServerCheck(): Promise<DoctorCheck> {
|
|
67
|
+
if (await commandExists("llama-server")) return { label: "llama-server", status: "ok", detail: "llama-server found on PATH", required: true };
|
|
68
|
+
return { label: "llama-server", status: "missing", detail: "install llama.cpp, for example `brew install llama.cpp`", required: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function modelCacheCheck(): Promise<DoctorCheck> {
|
|
72
|
+
const model = MODEL_REGISTRY[DEFAULT_MODEL_ID];
|
|
73
|
+
const modelPath = path.join(cacheDir(), "models", model.file);
|
|
74
|
+
if (await fileExists(modelPath)) return { label: "default model", status: "ok", detail: `${model.name} cached` };
|
|
75
|
+
return {
|
|
76
|
+
label: "default model",
|
|
77
|
+
status: "info",
|
|
78
|
+
detail: `${model.name} not cached yet; first interactive search can download it locally`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderDoctor(checks: readonly DoctorCheck[]): string {
|
|
83
|
+
const lines = [
|
|
84
|
+
"Stupify doctor",
|
|
85
|
+
"",
|
|
86
|
+
...checks.map((check) => `${icon(check.status)} ${check.label}: ${check.detail}`),
|
|
87
|
+
"",
|
|
88
|
+
"Privacy: local-only. Stupify does not upload source, diffs, filenames, repo URLs, commit messages, author names, or private package names.",
|
|
89
|
+
];
|
|
90
|
+
return lines.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function icon(status: CheckStatus): string {
|
|
94
|
+
if (status === "ok") return "OK";
|
|
95
|
+
if (status === "missing") return "MISSING";
|
|
96
|
+
return "INFO";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolvePackage(specifier: string): string | null {
|
|
100
|
+
try {
|
|
101
|
+
const require = createRequire(import.meta.url);
|
|
102
|
+
return require.resolve(specifier);
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function commandExists(command: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
await execFileAsync("sh", ["-c", `command -v ${shellQuote(command)}`], { maxBuffer: 1024 * 1024 });
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
118
|
+
try {
|
|
119
|
+
const { stat } = await import("node:fs/promises");
|
|
120
|
+
return (await stat(filePath)).isFile();
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cacheDir(): string {
|
|
127
|
+
if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
|
|
128
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, "stupify");
|
|
129
|
+
if (platform() === "darwin") return path.join(homedir(), "Library", "Caches", "stupify");
|
|
130
|
+
if (platform() === "win32" && process.env.LOCALAPPDATA) return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
|
|
131
|
+
return path.join(homedir(), ".cache", "stupify");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function shellQuote(value: string): string {
|
|
135
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function errorMessage(error: unknown): string {
|
|
139
|
+
return error instanceof Error ? error.message : String(error);
|
|
140
|
+
}
|
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 } from "./types.ts";
|
|
3
|
+
import { sourceId, type NetDiff, type NetDiffStats, type SourceRange, type StagedDiff } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
const execFileAsync = promisify(execFile);
|
|
6
6
|
|
|
@@ -62,6 +62,40 @@ export async function netDiffFromStdin(text: string): Promise<NetDiff> {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
export async function stagedDiff(): Promise<StagedDiff> {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await execFileAsync("git", [
|
|
68
|
+
"diff",
|
|
69
|
+
"--cached",
|
|
70
|
+
"--no-ext-diff",
|
|
71
|
+
"--no-color",
|
|
72
|
+
"--unified=3",
|
|
73
|
+
"--",
|
|
74
|
+
], { maxBuffer: 64 * 1024 * 1024 });
|
|
75
|
+
return { text: stdout, stats: statsFromDiff(stdout) };
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error("Could not read staged changes. Run stupify inside a git repository.");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function gitRoot(): Promise<string> {
|
|
82
|
+
try {
|
|
83
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--show-toplevel"]);
|
|
84
|
+
return stdout.trim();
|
|
85
|
+
} catch {
|
|
86
|
+
throw new Error("Could not find a git repository.");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function gitPath(pathspec: string): Promise<string> {
|
|
91
|
+
try {
|
|
92
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", pathspec]);
|
|
93
|
+
return stdout.trim();
|
|
94
|
+
} catch {
|
|
95
|
+
throw new Error(`Could not resolve git path: ${pathspec}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
65
99
|
async function netDiff(base: string, target: string, label: string, id?: NetDiff["id"]): Promise<NetDiff> {
|
|
66
100
|
const [text, stats, shortBase, shortTarget] = await Promise.all([
|
|
67
101
|
diff(base, target),
|