@stupify/cli 0.0.3 → 0.0.5

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.
Files changed (59) hide show
  1. package/README.md +26 -31
  2. package/dist/analysis.d.ts +11 -9
  3. package/dist/analysis.js +30 -173
  4. package/dist/checks.d.ts +1 -0
  5. package/dist/checks.js +89 -2
  6. package/dist/command.js +55 -91
  7. package/dist/constants.d.ts +1 -1
  8. package/dist/constants.js +1 -1
  9. package/dist/counter-scout.js +70 -8
  10. package/dist/doctor.d.ts +4 -0
  11. package/dist/doctor.js +131 -0
  12. package/dist/git.d.ts +4 -1
  13. package/dist/git.js +34 -0
  14. package/dist/hooks.d.ts +3 -0
  15. package/dist/hooks.js +117 -0
  16. package/dist/model.d.ts +1 -15
  17. package/dist/model.js +37 -21
  18. package/dist/prompts.d.ts +8 -5
  19. package/dist/prompts.js +58 -168
  20. package/dist/render.d.ts +2 -2
  21. package/dist/render.js +70 -78
  22. package/dist/repomix-provider.d.ts +10 -2
  23. package/dist/repomix-provider.js +62 -11
  24. package/dist/search-bench.d.ts +1 -0
  25. package/dist/search-bench.js +675 -0
  26. package/dist/search-profile.d.ts +6 -0
  27. package/dist/search-profile.js +73 -0
  28. package/dist/sem-provider.d.ts +2 -2
  29. package/dist/sem-provider.js +33 -7
  30. package/dist/stupify.d.ts +2 -0
  31. package/dist/stupify.js +185 -334
  32. package/dist/types.d.ts +193 -109
  33. package/package.json +1 -1
  34. package/src/analysis.ts +48 -268
  35. package/src/checks.ts +91 -2
  36. package/src/command.ts +62 -107
  37. package/src/constants.ts +1 -1
  38. package/src/counter-scout.ts +63 -7
  39. package/src/doctor.ts +140 -0
  40. package/src/git.ts +35 -1
  41. package/src/hooks.ts +134 -0
  42. package/src/model.ts +39 -26
  43. package/src/prompts.ts +66 -202
  44. package/src/render.ts +68 -79
  45. package/src/repomix-provider.ts +66 -10
  46. package/src/search-bench.ts +783 -0
  47. package/src/search-profile.ts +89 -0
  48. package/src/sem-provider.ts +36 -9
  49. package/src/stupify.ts +215 -527
  50. package/src/types.ts +195 -119
  51. package/dist/batcher.d.ts +0 -3
  52. package/dist/batcher.js +0 -142
  53. package/dist/candidate-context.d.ts +0 -2
  54. package/dist/candidate-context.js +0 -40
  55. package/dist/experiment.d.ts +0 -1
  56. package/dist/experiment.js +0 -225
  57. package/src/batcher.ts +0 -198
  58. package/src/candidate-context.ts +0 -43
  59. 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 { AuditContextMode, AuditPromptName, Command, Engine, ModelId, ScoutMode } from "./types.ts";
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 DEFAULT_SCOUT_MODE: ScoutMode = "counter";
7
- const DEFAULT_AUDIT_CONTEXT: AuditContextMode = "repomix";
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] === "experiment") {
21
- const configPath = argv[1];
22
- if (!configPath || argv.length > 2) throw new Error("Usage: stupify experiment <config.json>");
23
- return { kind: "experiment", configPath };
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
- auditBatchSize: number;
40
- maxAuditInputTokens: number;
41
- auditConcurrency: number;
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
- auditBatchSize: DEFAULT_AUDIT_BATCH_SIZE,
58
- maxAuditInputTokens: DEFAULT_MAX_AUDIT_INPUT_TOKENS,
59
- auditConcurrency: DEFAULT_AUDIT_CONCURRENCY,
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
- auditBatchSize: finalState.auditBatchSize,
76
- maxAuditInputTokens: finalState.maxAuditInputTokens,
77
- auditConcurrency: finalState.auditConcurrency,
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 === "--stdin") return parseFrom(index + 1, setInputMode(state, { kind: "stdin" }));
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 === "--debug-targets") return parseFrom(index + 1, { ...state, debugTargets: true });
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 === "--audit-batch-size") {
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 auditConcurrency = Number(value);
177
- if (!Number.isInteger(auditConcurrency) || auditConcurrency < 1) {
178
- throw new Error("--audit-concurrency requires a positive integer.");
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, auditConcurrency });
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 --commits.");
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 isEngine(value: string): value is Engine {
205
- return value === "raw-diff" || value === "sem";
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
@@ -1,4 +1,4 @@
1
- export const VERSION = "0.0.3";
1
+ export const VERSION = "0.0.5";
2
2
  import type { ModelConfig, ModelId } from "./types.ts";
3
3
 
4
4
  export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
@@ -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 isSchemaish(change, haystack) ? "schemaish_type_or_payload" : null;
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 commentLines(change.afterContent) > commentLines(change.beforeContent) + 3
89
+ return overCommentingSignal(change)
88
90
  ? "comment_lines_increased"
89
91
  : null;
90
92
  case "lint_bypass":
91
- return /(eslint-disable|biome-ignore|@ts-ignore|@ts-expect-error|\bas unknown as\b|\bany\b)/i.test(change.afterContent ?? "")
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 /^(format|parse|normalize|group|sort|filter|find|has|get|set|is|resolve|clamp|slug)/i.test(change.entityName)
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 isSchemaish(change: SemChange, haystack: string): boolean {
112
- if (/^(interface|type|class)$/i.test(change.entityType)) return true;
113
- return /\b(payload|dto|schema|response|request|input|output|result|context|asset|job|node|edge|generation)\b/i.test(haystack);
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),