@stupify/cli 0.0.2 → 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 +55 -0
- package/dist/analysis.d.ts +16 -0
- package/dist/analysis.js +133 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.js +59 -0
- package/dist/checks.d.ts +4 -0
- package/dist/checks.js +218 -0
- package/dist/command.d.ts +2 -0
- package/dist/command.js +147 -0
- package/dist/constants.d.ts +4 -0
- package/dist/constants.js +53 -0
- package/dist/counter-scout.d.ts +14 -0
- package/dist/counter-scout.js +159 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +10 -0
- package/dist/doctor.d.ts +4 -0
- package/dist/doctor.js +131 -0
- package/dist/git.d.ts +11 -0
- package/dist/git.js +253 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/hooks.js +117 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/model.d.ts +10 -0
- package/dist/model.js +297 -0
- package/dist/prompts.d.ts +8 -0
- package/dist/prompts.js +87 -0
- package/dist/render.d.ts +3 -0
- package/dist/render.js +93 -0
- package/dist/repomix-provider.d.ts +12 -0
- package/dist/repomix-provider.js +196 -0
- 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 -0
- package/dist/sem-provider.js +247 -0
- package/dist/stupify.d.ts +4 -0
- package/dist/stupify.js +237 -0
- package/dist/trace.d.ts +29 -0
- package/dist/trace.js +64 -0
- package/dist/types.d.ts +320 -0
- package/dist/types.js +6 -0
- package/package.json +42 -5
- package/src/analysis.ts +188 -0
- package/src/cache.ts +65 -0
- package/src/checks.ts +221 -0
- package/src/command.ts +173 -0
- package/src/constants.ts +56 -0
- package/src/counter-scout.ts +175 -0
- package/src/diff.ts +9 -0
- package/src/doctor.ts +140 -0
- package/src/git.ts +262 -0
- package/src/hooks.ts +134 -0
- package/src/index.ts +1 -0
- package/src/model.ts +373 -0
- package/src/prompts.ts +98 -0
- package/src/render.ts +96 -0
- package/src/repomix-provider.ts +219 -0
- package/src/search-bench.ts +783 -0
- package/src/search-profile.ts +89 -0
- package/src/sem-provider.ts +282 -0
- package/src/stupify.ts +285 -0
- package/src/trace.ts +103 -0
- package/src/types.ts +340 -0
- package/bin/stupify.mjs +0 -3
package/src/checks.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { checkId, type StupifyCheck } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export const defaultChecks: readonly StupifyCheck[] = [
|
|
4
|
+
{
|
|
5
|
+
id: checkId("duplicated_schema"),
|
|
6
|
+
name: "Duplicated schema",
|
|
7
|
+
question: "Did the change duplicate an existing type, schema, payload, or DTO shape?",
|
|
8
|
+
lookFor: [
|
|
9
|
+
"local shape mirrors existing fields and maps them one-for-one",
|
|
10
|
+
"new response, payload, schema, or DTO adds no filtering, renaming, validation, or versioning",
|
|
11
|
+
],
|
|
12
|
+
ignoreWhen: [
|
|
13
|
+
"test fixture, mock, or intentional external contract",
|
|
14
|
+
"public API DTO filters, omits, protects, renames, or versions fields",
|
|
15
|
+
],
|
|
16
|
+
hookMode: "warn",
|
|
17
|
+
searchPrompt: "Find only local/private payload or schema shapes that clearly copy another local shape one-for-one without creating a boundary. Do not match ordinary Input/Output/Request/Response types by name alone, public DTOs, external contracts, client types, or types that omit/protect private fields.",
|
|
18
|
+
searchExamples: {
|
|
19
|
+
match: [
|
|
20
|
+
"LocalUserPayload repeats User fields and maps id/email/displayName one-for-one.",
|
|
21
|
+
],
|
|
22
|
+
nonMatch: [
|
|
23
|
+
"PublicWebhookDto omits privateNotes from InternalJob.",
|
|
24
|
+
"A client type describes an external dependency boundary.",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: checkId("unnecessary_complexity"),
|
|
30
|
+
name: "Unnecessary complexity",
|
|
31
|
+
question: "Did the change add structure without buying clarity?",
|
|
32
|
+
lookFor: [
|
|
33
|
+
"helper, wrapper, service, layer, or extra file around simple logic without reuse",
|
|
34
|
+
],
|
|
35
|
+
ignoreWhen: [
|
|
36
|
+
"isolates dependency, removes duplication, or improves testability",
|
|
37
|
+
],
|
|
38
|
+
hookMode: "warn",
|
|
39
|
+
searchPrompt: `Find staged changes where a locally simple decision is made harder to understand by new indirection.
|
|
40
|
+
Only match when the staged diff clearly shows:
|
|
41
|
+
- a new named helper, wrapper, service, adapter, boundary, or abstraction
|
|
42
|
+
- and the surrounding change still appears locally simple
|
|
43
|
+
- and the new structure makes the decision harder to see
|
|
44
|
+
Do not match:
|
|
45
|
+
- plain conditionals, guard clauses, skip paths, or error handling
|
|
46
|
+
- normal feature structure
|
|
47
|
+
- exported utilities that are part of a real feature
|
|
48
|
+
- command plumbing
|
|
49
|
+
- prompt/instruction files
|
|
50
|
+
- domain configuration
|
|
51
|
+
- refactors that make ownership clearer
|
|
52
|
+
- changes where the payoff is unclear from the diff
|
|
53
|
+
Prefer no match over a weak match.`,
|
|
54
|
+
searchExamples: {
|
|
55
|
+
match: [
|
|
56
|
+
"A small inline operation becomes a helper/service/wrapper with one obvious caller.",
|
|
57
|
+
"A straightforward flow is split across files in a way that hides the decision.",
|
|
58
|
+
"A new abstraction appears before there is evidence it buys clarity, correctness, reuse, or isolation.",
|
|
59
|
+
],
|
|
60
|
+
nonMatch: [
|
|
61
|
+
"A real external dependency boundary is isolated.",
|
|
62
|
+
"A security/auth boundary becomes clearer.",
|
|
63
|
+
"A refactor removes larger complexity elsewhere.",
|
|
64
|
+
"Framework-required structure is added.",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: checkId("fake_precision_windowing"),
|
|
70
|
+
name: "Fake precision windowing",
|
|
71
|
+
question: "Did the change add fake precision around model context?",
|
|
72
|
+
lookFor: [
|
|
73
|
+
"precise-looking counts, budgets, ratios, reports, or batching fields without useful behavior",
|
|
74
|
+
],
|
|
75
|
+
ignoreWhen: [
|
|
76
|
+
"simple fixed cap or chunking",
|
|
77
|
+
"external API requirement",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: checkId("coauthored_slop"),
|
|
82
|
+
name: "Coauthored slop",
|
|
83
|
+
question: "Does author metadata contain co-author text?",
|
|
84
|
+
lookFor: [
|
|
85
|
+
"author signal contains coauhtoried, coauthored, or co-authored text",
|
|
86
|
+
],
|
|
87
|
+
ignoreWhen: [
|
|
88
|
+
"normal Co-authored-by trailer in the commit body",
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: checkId("mega_file"),
|
|
93
|
+
name: "Mega file",
|
|
94
|
+
question: "Is a touched non-config file over 1000 LOC?",
|
|
95
|
+
lookFor: [
|
|
96
|
+
"touched non-config source file over 1000 LOC",
|
|
97
|
+
],
|
|
98
|
+
ignoreWhen: [
|
|
99
|
+
"config, lock, generated, fixture, or vendored file",
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: checkId("over_commenting"),
|
|
104
|
+
name: "Over commenting",
|
|
105
|
+
question: "Did the change add noisy comments?",
|
|
106
|
+
lookFor: [
|
|
107
|
+
"comments restate obvious code or narrate simple logic",
|
|
108
|
+
],
|
|
109
|
+
ignoreWhen: [
|
|
110
|
+
"comment explains intent, constraint, workaround, or public API behavior",
|
|
111
|
+
],
|
|
112
|
+
hookMode: "warn",
|
|
113
|
+
searchPrompt: "Find staged changes where comments appear to substitute for judgment rather than clarify it.",
|
|
114
|
+
searchExamples: {
|
|
115
|
+
match: [
|
|
116
|
+
"New comments narrate obvious code instead of explaining tradeoffs.",
|
|
117
|
+
"A simple change gains multiple generic comments that restate control flow.",
|
|
118
|
+
"Comments make the code look more deliberate without adding useful reasoning.",
|
|
119
|
+
],
|
|
120
|
+
nonMatch: [
|
|
121
|
+
"Comments explain a real domain constraint.",
|
|
122
|
+
"Comments document an external API quirk.",
|
|
123
|
+
"Comments clarify a surprising edge case.",
|
|
124
|
+
"Comments are sparse and specific.",
|
|
125
|
+
"Comments explain provider, finance, reconciliation, timezone, or ledger behavior.",
|
|
126
|
+
],
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
id: checkId("lint_bypass"),
|
|
131
|
+
name: "Lint bypass",
|
|
132
|
+
question: "Did the change bypass lint or type rules?",
|
|
133
|
+
lookFor: [
|
|
134
|
+
"adds suppressions, any, broad casts, or weakens lint/typecheck config",
|
|
135
|
+
],
|
|
136
|
+
ignoreWhen: [
|
|
137
|
+
"narrow suppression with a reason",
|
|
138
|
+
"type-level test",
|
|
139
|
+
"generated file convention",
|
|
140
|
+
],
|
|
141
|
+
hookMode: "warn",
|
|
142
|
+
searchPrompt: "Find only broad lint/type bypasses that hide useful feedback. Match bare @ts-ignore, bare @ts-expect-error, broad casts, any, or eslint/biome suppressions without a concrete inline reason. Do not match targeted suppressions that include a reason for a known framework, test, mock, or external-library limitation.",
|
|
143
|
+
searchExamples: {
|
|
144
|
+
match: [
|
|
145
|
+
"A bare // @ts-ignore hides property access on unknown input.",
|
|
146
|
+
],
|
|
147
|
+
nonMatch: [
|
|
148
|
+
"// @ts-expect-error explains a known external library typing gap.",
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: checkId("inconsistent_patterns"),
|
|
154
|
+
name: "Inconsistent patterns",
|
|
155
|
+
question: "Does the change clash with nearby patterns?",
|
|
156
|
+
lookFor: [
|
|
157
|
+
"same job uses different naming, errors, state, imports, or layout than nearby files",
|
|
158
|
+
],
|
|
159
|
+
ignoreWhen: [
|
|
160
|
+
"external API requires it",
|
|
161
|
+
"change follows a newer local convention",
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
id: checkId("reinvented_utils"),
|
|
166
|
+
name: "Reinvented utils",
|
|
167
|
+
question: "Did the change recreate an existing utility?",
|
|
168
|
+
lookFor: [
|
|
169
|
+
"new helper duplicates local utility or standard library behavior",
|
|
170
|
+
],
|
|
171
|
+
ignoreWhen: [
|
|
172
|
+
"existing utility has wrong contract",
|
|
173
|
+
"new helper is clearer as a tiny private expression",
|
|
174
|
+
"helper is domain-specific or used by multiple local call sites",
|
|
175
|
+
],
|
|
176
|
+
hookMode: "warn",
|
|
177
|
+
searchPrompt: "Find only tiny generic utility functions that recreate common helpers such as clamp, debounce, throttle, slugify, group, sort, pick, omit, uniq, or shuffle without domain-specific behavior. Do not match resolve/parse/format helpers, domain formatting, feature constants, or helpers with multiple obvious call sites.",
|
|
178
|
+
searchExamples: {
|
|
179
|
+
match: [
|
|
180
|
+
"clampValue returns min, max, or value.",
|
|
181
|
+
],
|
|
182
|
+
nonMatch: [
|
|
183
|
+
"formatCurrencyHelper is used by invoice and refund labels.",
|
|
184
|
+
"Subscription tier constants encode domain configuration.",
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: checkId("operator_style_mismatch"),
|
|
190
|
+
name: "Operator style mismatch",
|
|
191
|
+
question: "Does the change read unlike the surrounding code?",
|
|
192
|
+
lookFor: [
|
|
193
|
+
"generic or template-like names, abstractions, comments, or control flow clash with local style",
|
|
194
|
+
],
|
|
195
|
+
ignoreWhen: [
|
|
196
|
+
"generated, vendored, framework-required, or newer established local style",
|
|
197
|
+
],
|
|
198
|
+
enabledByDefault: false,
|
|
199
|
+
},
|
|
200
|
+
] as const;
|
|
201
|
+
|
|
202
|
+
export function enabledChecks(checkIds: readonly string[] | null): readonly StupifyCheck[] {
|
|
203
|
+
if (!checkIds) return defaultChecks.filter((check) => check.enabledByDefault !== false);
|
|
204
|
+
|
|
205
|
+
return checksById(checkIds);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function searchChecks(checkIds: readonly string[] | null): readonly StupifyCheck[] {
|
|
209
|
+
if (!checkIds) return defaultChecks.filter((check) => check.hookMode === "warn");
|
|
210
|
+
|
|
211
|
+
return checksById(checkIds);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function checksById(checkIds: readonly string[]): readonly StupifyCheck[] {
|
|
215
|
+
const checksById = new Map<string, StupifyCheck>(defaultChecks.map((check) => [check.id, check]));
|
|
216
|
+
return checkIds.map((id) => {
|
|
217
|
+
const check = checksById.get(id);
|
|
218
|
+
if (!check) throw new Error(`Unknown check: ${id}`);
|
|
219
|
+
return check;
|
|
220
|
+
});
|
|
221
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { DEFAULT_MODEL_ID, MODEL_REGISTRY } from "./constants.ts";
|
|
2
|
+
import type { Command, HookAction, ModelId, SearchSource } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_SINCE = "2 weeks ago";
|
|
5
|
+
const DEFAULT_MAX_CANDIDATES = 10;
|
|
6
|
+
const DEFAULT_MAX_SEARCH_INPUT_TOKENS = 12_000;
|
|
7
|
+
|
|
8
|
+
type InputMode =
|
|
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" }>;
|
|
14
|
+
|
|
15
|
+
export function parseCommand(argv: readonly string[]): Command {
|
|
16
|
+
if (argv.length === 1 && isHelp(argv[0])) return { kind: "help" };
|
|
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" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type ParseState = Readonly<{
|
|
36
|
+
inputMode: InputMode;
|
|
37
|
+
explicitInputMode: boolean;
|
|
38
|
+
checkIds: readonly string[] | null;
|
|
39
|
+
json: boolean;
|
|
40
|
+
model: ModelId;
|
|
41
|
+
debugSem: boolean;
|
|
42
|
+
maxCandidates: number;
|
|
43
|
+
maxSearchInputTokens: number;
|
|
44
|
+
searchProfilePath: string | null;
|
|
45
|
+
includeCounterReasonInPrompt: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
const initialState: ParseState = {
|
|
49
|
+
inputMode: { kind: "since", since: DEFAULT_SINCE, source: "since" },
|
|
50
|
+
explicitInputMode: false,
|
|
51
|
+
checkIds: null,
|
|
52
|
+
json: false,
|
|
53
|
+
model: DEFAULT_MODEL_ID,
|
|
54
|
+
debugSem: false,
|
|
55
|
+
maxCandidates: DEFAULT_MAX_CANDIDATES,
|
|
56
|
+
maxSearchInputTokens: DEFAULT_MAX_SEARCH_INPUT_TOKENS,
|
|
57
|
+
searchProfilePath: null,
|
|
58
|
+
includeCounterReasonInPrompt: false,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const finalState = parseFrom(0, initialState);
|
|
62
|
+
return {
|
|
63
|
+
...finalState.inputMode,
|
|
64
|
+
mode: "search",
|
|
65
|
+
checkIds: finalState.checkIds,
|
|
66
|
+
json: finalState.json,
|
|
67
|
+
model: finalState.model,
|
|
68
|
+
debugSem: finalState.debugSem,
|
|
69
|
+
maxCandidates: finalState.maxCandidates,
|
|
70
|
+
maxSearchInputTokens: finalState.maxSearchInputTokens,
|
|
71
|
+
searchProfilePath: finalState.searchProfilePath,
|
|
72
|
+
includeCounterReasonInPrompt: finalState.includeCounterReasonInPrompt,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function parseFrom(index: number, state: ParseState): ParseState {
|
|
76
|
+
if (index >= argv.length) return state;
|
|
77
|
+
|
|
78
|
+
const arg = argv[index];
|
|
79
|
+
|
|
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" }));
|
|
87
|
+
if (arg === "--json") return parseFrom(index + 1, { ...state, json: true });
|
|
88
|
+
if (arg === "--debug-sem") return parseFrom(index + 1, { ...state, debugSem: 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
|
+
}
|
|
98
|
+
|
|
99
|
+
if (arg === "--since") {
|
|
100
|
+
const value = argv[index + 1];
|
|
101
|
+
if (!value || value.startsWith("-")) throw new Error("--since requires a git date, such as \"2 weeks ago\".");
|
|
102
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "since", since: value, source: "since" }));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (arg === "--commit") {
|
|
106
|
+
const value = argv[index + 1];
|
|
107
|
+
if (!value || !isSafeCommitArg(value)) throw new Error("Invalid commit.");
|
|
108
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commit", commit: value, source: "commit" }));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (arg === "--commits") {
|
|
112
|
+
const value = argv[index + 1];
|
|
113
|
+
const count = Number(value);
|
|
114
|
+
if (!Number.isInteger(count) || count < 1) throw new Error("--commits requires a positive integer.");
|
|
115
|
+
return parseFrom(index + 2, setInputMode(state, { kind: "commits", count, source: "commits" }));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (arg === "--checks") {
|
|
119
|
+
const value = argv[index + 1];
|
|
120
|
+
if (!value || value.startsWith("-")) throw new Error("--checks requires a comma-separated list.");
|
|
121
|
+
const checkIds = value.split(",").map((id) => id.trim()).filter(Boolean);
|
|
122
|
+
if (checkIds.length === 0) throw new Error("--checks requires at least one check id.");
|
|
123
|
+
return parseFrom(index + 2, { ...state, checkIds });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (arg === "--model") {
|
|
127
|
+
const value = argv[index + 1];
|
|
128
|
+
if (!value || !isModelId(value)) throw new Error(`--model must be one of: ${Object.keys(MODEL_REGISTRY).join(", ")}`);
|
|
129
|
+
return parseFrom(index + 2, { ...state, model: value });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (arg === "--max-candidates") {
|
|
133
|
+
const value = argv[index + 1];
|
|
134
|
+
const maxCandidates = Number(value);
|
|
135
|
+
if (!Number.isInteger(maxCandidates) || maxCandidates < 1) {
|
|
136
|
+
throw new Error("--max-candidates requires a positive integer.");
|
|
137
|
+
}
|
|
138
|
+
return parseFrom(index + 2, { ...state, maxCandidates });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (arg === "--max-search-input-tokens") {
|
|
142
|
+
const value = argv[index + 1];
|
|
143
|
+
const maxSearchInputTokens = Number(value);
|
|
144
|
+
if (!Number.isInteger(maxSearchInputTokens) || maxSearchInputTokens < 1) {
|
|
145
|
+
throw new Error("--max-search-input-tokens requires a positive integer.");
|
|
146
|
+
}
|
|
147
|
+
return parseFrom(index + 2, { ...state, maxSearchInputTokens });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function setInputMode(state: ParseState, next: InputMode): ParseState {
|
|
154
|
+
if (state.explicitInputMode) throw new Error("Choose only one input mode: --since, --stdin, --commit, --commits, or --staged.");
|
|
155
|
+
return { ...state, inputMode: next, explicitInputMode: true };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isSafeCommitArg(value: string): boolean {
|
|
160
|
+
return value.length > 0 && !value.startsWith("-") && /^[A-Za-z0-9._/@~^:+-]+$/.test(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isHelp(value: string): boolean {
|
|
164
|
+
return value === "--help" || value === "-h";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function isModelId(value: string): value is ModelId {
|
|
168
|
+
return value in MODEL_REGISTRY;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isHookAction(value: string): value is HookAction {
|
|
172
|
+
return value === "install" || value === "uninstall" || value === "status";
|
|
173
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const VERSION = "0.0.4";
|
|
2
|
+
import type { ModelConfig, ModelId } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_MODEL_ID: ModelId = "gemma-4-e2b";
|
|
5
|
+
|
|
6
|
+
export const MODEL_REGISTRY: Record<ModelId, ModelConfig> = {
|
|
7
|
+
"gemma-4-e2b": {
|
|
8
|
+
id: "gemma-4-e2b",
|
|
9
|
+
name: "Gemma 4 E2B Instruct Q4_K_M",
|
|
10
|
+
size: "about 3.1 GB",
|
|
11
|
+
file: "gemma-4-e2b-it-q4_k_m.gguf",
|
|
12
|
+
url: "https://huggingface.co/unsloth/gemma-4-E2B-it-GGUF/resolve/main/gemma-4-E2B-it-Q4_K_M.gguf?download=true",
|
|
13
|
+
},
|
|
14
|
+
"gemma-4-e4b": {
|
|
15
|
+
id: "gemma-4-e4b",
|
|
16
|
+
name: "Gemma 4 E4B Instruct Q4_K_M",
|
|
17
|
+
size: "about 5.0 GB",
|
|
18
|
+
file: "gemma-4-e4b-it-q4_k_m.gguf",
|
|
19
|
+
url: "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-Q4_K_M.gguf?download=true",
|
|
20
|
+
},
|
|
21
|
+
"gemma-4-26b-a4b": {
|
|
22
|
+
id: "gemma-4-26b-a4b",
|
|
23
|
+
name: "Gemma 4 26B A4B Instruct UD-IQ2_XXS",
|
|
24
|
+
size: "about 9.9 GB",
|
|
25
|
+
file: "gemma-4-26b-a4b-it-ud-iq2_xxs.gguf",
|
|
26
|
+
url: "https://huggingface.co/unsloth/gemma-4-26B-A4B-it-GGUF/resolve/main/gemma-4-26B-A4B-it-UD-IQ2_XXS.gguf?download=true",
|
|
27
|
+
},
|
|
28
|
+
"qwen3-4b-magicquant": {
|
|
29
|
+
id: "qwen3-4b-magicquant",
|
|
30
|
+
name: "Qwen3-4B-Instruct-2507 MagicQuant Q4_K_M",
|
|
31
|
+
size: "about 2.4 GB",
|
|
32
|
+
file: "qwen3-4b-instruct-2507-magicquant-q4_k_m.gguf",
|
|
33
|
+
url: "https://huggingface.co/magiccodingman/Qwen3-4B-Instruct-2507-Unsloth-MagicQuant-v2-GGUF/resolve/main/Model-MQ-Q4_K_M_1.gguf?download=true",
|
|
34
|
+
},
|
|
35
|
+
"qwen2.5-coder-1.5b": {
|
|
36
|
+
id: "qwen2.5-coder-1.5b",
|
|
37
|
+
name: "Qwen2.5-Coder-1.5B-Instruct Q4_K_M",
|
|
38
|
+
size: "about 1.1 GB",
|
|
39
|
+
file: "qwen2.5-coder-1.5b-instruct-q4_k_m.gguf",
|
|
40
|
+
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct-GGUF/resolve/main/qwen2.5-coder-1.5b-instruct-q4_k_m.gguf?download=true",
|
|
41
|
+
},
|
|
42
|
+
"qwen2.5-coder-7b": {
|
|
43
|
+
id: "qwen2.5-coder-7b",
|
|
44
|
+
name: "Qwen2.5-Coder-7B-Instruct Q4_K_M",
|
|
45
|
+
size: "about 4.7 GB",
|
|
46
|
+
file: "qwen2.5-coder-7b-instruct-q4_k_m.gguf",
|
|
47
|
+
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-7B-Instruct-GGUF/resolve/main/qwen2.5-coder-7b-instruct-q4_k_m.gguf?download=true",
|
|
48
|
+
},
|
|
49
|
+
"qwen2.5-coder-32b": {
|
|
50
|
+
id: "qwen2.5-coder-32b",
|
|
51
|
+
name: "Qwen2.5-Coder-32B-Instruct Q4_K_M",
|
|
52
|
+
size: "about 19 GB",
|
|
53
|
+
file: "qwen2.5-coder-32b-instruct-q4_k_m.gguf",
|
|
54
|
+
url: "https://huggingface.co/Qwen/Qwen2.5-Coder-32B-Instruct-GGUF/resolve/main/qwen2.5-coder-32b-instruct-q4_k_m.gguf?download=true",
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { CheckId, SemCandidate, SemChange, SemChangeSet, StupifyCheck } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
type Signal = Readonly<{
|
|
4
|
+
checkId: CheckId;
|
|
5
|
+
entityId: string;
|
|
6
|
+
reasonCode: string;
|
|
7
|
+
}>;
|
|
8
|
+
|
|
9
|
+
type SignalBucket = Readonly<{
|
|
10
|
+
checkId: CheckId;
|
|
11
|
+
total: number;
|
|
12
|
+
examples: readonly Signal[];
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
const MAX_COUNTER_EXAMPLES_PER_CHECK = 4;
|
|
16
|
+
|
|
17
|
+
export function counterScoutTargets(
|
|
18
|
+
changeSet: SemChangeSet,
|
|
19
|
+
checks: readonly StupifyCheck[],
|
|
20
|
+
maxTargets: number,
|
|
21
|
+
): readonly SemCandidate[] {
|
|
22
|
+
const buckets = runSignalCounters(changeSet, checks);
|
|
23
|
+
const targets: SemCandidate[] = [];
|
|
24
|
+
let cursor = 0;
|
|
25
|
+
while (targets.length < maxTargets && buckets.some((bucket) => cursor < bucket.examples.length)) {
|
|
26
|
+
for (const bucket of buckets) {
|
|
27
|
+
const signal = bucket.examples[cursor];
|
|
28
|
+
if (!signal) continue;
|
|
29
|
+
targets.push({
|
|
30
|
+
sourceId: changeSet.id,
|
|
31
|
+
targetId: `t${String(targets.length + 1).padStart(3, "0")}`,
|
|
32
|
+
entityId: signal.entityId,
|
|
33
|
+
checkId: signal.checkId,
|
|
34
|
+
reason: signal.reasonCode,
|
|
35
|
+
});
|
|
36
|
+
if (targets.length >= maxTargets) break;
|
|
37
|
+
}
|
|
38
|
+
cursor += 1;
|
|
39
|
+
}
|
|
40
|
+
return targets;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runSignalCounters(
|
|
44
|
+
changeSet: SemChangeSet,
|
|
45
|
+
checks: readonly StupifyCheck[],
|
|
46
|
+
): readonly SignalBucket[] {
|
|
47
|
+
return checks
|
|
48
|
+
.map((check) => {
|
|
49
|
+
const signals = changeSet.changes.flatMap((change): readonly Signal[] => {
|
|
50
|
+
const reasonCode = reasonForCheck(check.id, change);
|
|
51
|
+
return reasonCode ? [{ checkId: check.id, entityId: change.entityId, reasonCode }] : [];
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
checkId: check.id,
|
|
55
|
+
total: signals.length,
|
|
56
|
+
examples: signals.slice(0, MAX_COUNTER_EXAMPLES_PER_CHECK),
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
.filter((bucket) => bucket.total > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function reasonForCheck(checkId: CheckId, change: SemChange): string | null {
|
|
63
|
+
if (!isSearchableSourceChange(change)) return null;
|
|
64
|
+
|
|
65
|
+
const haystack = `${change.entityName}\n${change.entityType}\n${change.filePath}\n${change.afterContent ?? ""}`.toLowerCase();
|
|
66
|
+
const changed = change.changeType === "added" || change.changeType === "modified";
|
|
67
|
+
if (!changed) return null;
|
|
68
|
+
|
|
69
|
+
switch (checkId as string) {
|
|
70
|
+
case "duplicated_schema":
|
|
71
|
+
return isDuplicatedSchemaCandidate(change) ? "local_schemaish_copy" : null;
|
|
72
|
+
case "unnecessary_complexity":
|
|
73
|
+
return /\b(helper|wrapper|service|provider|manager|factory|adapter|resolver|coordinator)\b/i.test(change.entityName)
|
|
74
|
+
? "new_abstraction_name"
|
|
75
|
+
: null;
|
|
76
|
+
case "fake_precision_windowing":
|
|
77
|
+
return /\b(token|budget|window|batch|ratio|estimate|counter|count|limit)\b/i.test(haystack)
|
|
78
|
+
? "precision_accounting_terms"
|
|
79
|
+
: null;
|
|
80
|
+
case "coauthored_slop":
|
|
81
|
+
return /\b(coauhtoried|coauthored|co-authored|co-authored-by)\b/i.test(haystack)
|
|
82
|
+
? "coauthor_text"
|
|
83
|
+
: null;
|
|
84
|
+
case "mega_file":
|
|
85
|
+
return change.entityType === "chunk" && /lines\s+\d+-\d+/i.test(change.entityName)
|
|
86
|
+
? "large_changed_chunk"
|
|
87
|
+
: null;
|
|
88
|
+
case "over_commenting":
|
|
89
|
+
return overCommentingSignal(change)
|
|
90
|
+
? "comment_lines_increased"
|
|
91
|
+
: null;
|
|
92
|
+
case "lint_bypass":
|
|
93
|
+
return lintBypassSignal(change.afterContent ?? "")
|
|
94
|
+
? "lint_or_type_bypass_text"
|
|
95
|
+
: null;
|
|
96
|
+
case "inconsistent_patterns":
|
|
97
|
+
return /\b(manager|factory|provider|adapter|orchestrator|coordinator)\b/i.test(change.entityName)
|
|
98
|
+
? "pattern_abstraction_name"
|
|
99
|
+
: null;
|
|
100
|
+
case "reinvented_utils":
|
|
101
|
+
return reinventedUtilitySignal(change)
|
|
102
|
+
? "generic_utility_name"
|
|
103
|
+
: null;
|
|
104
|
+
case "operator_style_mismatch":
|
|
105
|
+
return /\b(manager|factory|provider|enterprise|orchestrator)\b/i.test(haystack)
|
|
106
|
+
? "style_smell_terms"
|
|
107
|
+
: null;
|
|
108
|
+
default:
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
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);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function commentLines(value: string | null): number {
|
|
159
|
+
if (!value) return 0;
|
|
160
|
+
return value.split(/\r?\n/).filter((line) => /^\s*(\/\/|\/\*|\*|#)/.test(line)).length;
|
|
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/diff.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { stdin as input } from "node:process";
|
|
2
|
+
|
|
3
|
+
export async function readDiffFromStdin(): Promise<string> {
|
|
4
|
+
const chunks: Buffer[] = [];
|
|
5
|
+
for await (const chunk of input) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
6
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
7
|
+
if (!text.trim()) throw new Error("No diff received on stdin.");
|
|
8
|
+
return text;
|
|
9
|
+
}
|