@stupify/cli 0.0.16 → 0.2.0

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 (89) hide show
  1. package/.review/CORPUS.md +44 -0
  2. package/.review/CORPUS.template.md +73 -0
  3. package/.review/REVIEW-PROMPT.md +52 -0
  4. package/.review/RUBRIC.md +46 -0
  5. package/LICENSE +1 -1
  6. package/README.md +95 -37
  7. package/package.json +27 -26
  8. package/packs/antirez.md +10 -0
  9. package/packs/anton-kropp.md +10 -0
  10. package/packs/dhh.md +10 -0
  11. package/packs/dtolnay.md +10 -0
  12. package/packs/jarred-sumner.md +9 -0
  13. package/packs/mitchell-hashimoto.md +10 -0
  14. package/packs/rich-harris.md +10 -0
  15. package/packs/simon-willison.md +10 -0
  16. package/packs/sindre-sorhus.md +10 -0
  17. package/packs/tanner-linsley.md +10 -0
  18. package/packs/zod.md +10 -0
  19. package/src/cli.ts +626 -0
  20. package/src/prime-install.test.ts +109 -0
  21. package/src/prime.ts +50 -0
  22. package/src/review-sweep.test.ts +101 -0
  23. package/src/review-sweep.ts +526 -0
  24. package/dist/analysis.d.ts +0 -16
  25. package/dist/analysis.js +0 -168
  26. package/dist/cache.d.ts +0 -2
  27. package/dist/cache.js +0 -57
  28. package/dist/checks.d.ts +0 -4
  29. package/dist/checks.js +0 -228
  30. package/dist/command.d.ts +0 -2
  31. package/dist/command.js +0 -147
  32. package/dist/constants.d.ts +0 -4
  33. package/dist/constants.js +0 -53
  34. package/dist/counter-scout.d.ts +0 -21
  35. package/dist/counter-scout.js +0 -167
  36. package/dist/diff.d.ts +0 -1
  37. package/dist/diff.js +0 -10
  38. package/dist/doctor.d.ts +0 -16
  39. package/dist/doctor.js +0 -143
  40. package/dist/git.d.ts +0 -17
  41. package/dist/git.js +0 -368
  42. package/dist/hooks.d.ts +0 -5
  43. package/dist/hooks.js +0 -135
  44. package/dist/index.d.ts +0 -1
  45. package/dist/index.js +0 -1
  46. package/dist/model.d.ts +0 -11
  47. package/dist/model.js +0 -296
  48. package/dist/prompts.d.ts +0 -8
  49. package/dist/prompts.js +0 -89
  50. package/dist/render.d.ts +0 -6
  51. package/dist/render.js +0 -295
  52. package/dist/repomix-provider.d.ts +0 -12
  53. package/dist/repomix-provider.js +0 -196
  54. package/dist/search-bench.d.ts +0 -1
  55. package/dist/search-bench.js +0 -677
  56. package/dist/search-profile.d.ts +0 -6
  57. package/dist/search-profile.js +0 -73
  58. package/dist/sem-provider.d.ts +0 -2
  59. package/dist/sem-provider.js +0 -255
  60. package/dist/stupify.d.ts +0 -38
  61. package/dist/stupify.js +0 -505
  62. package/dist/trace.d.ts +0 -31
  63. package/dist/trace.js +0 -86
  64. package/dist/types.d.ts +0 -341
  65. package/dist/types.js +0 -6
  66. package/dist/ui.d.ts +0 -34
  67. package/dist/ui.js +0 -143
  68. package/src/analysis.ts +0 -223
  69. package/src/cache.ts +0 -63
  70. package/src/checks.ts +0 -231
  71. package/src/command.ts +0 -173
  72. package/src/constants.ts +0 -56
  73. package/src/counter-scout.ts +0 -195
  74. package/src/diff.ts +0 -9
  75. package/src/doctor.ts +0 -166
  76. package/src/git.ts +0 -380
  77. package/src/hooks.ts +0 -151
  78. package/src/index.ts +0 -1
  79. package/src/model.ts +0 -367
  80. package/src/prompts.ts +0 -100
  81. package/src/render.ts +0 -328
  82. package/src/repomix-provider.ts +0 -219
  83. package/src/search-bench.ts +0 -783
  84. package/src/search-profile.ts +0 -89
  85. package/src/sem-provider.ts +0 -300
  86. package/src/stupify.ts +0 -604
  87. package/src/trace.ts +0 -126
  88. package/src/types.ts +0 -362
  89. package/src/ui.ts +0 -187
package/src/render.ts DELETED
@@ -1,328 +0,0 @@
1
- import { VERSION } from "./constants.ts";
2
- import type { SearchCommand, SearchRunJson } from "./types.ts";
3
- import { format, type CliUi } from "./ui.ts";
4
-
5
- export function renderSearchRun(run: SearchRunJson, command: SearchCommand): string {
6
- if (command.json) return JSON.stringify(run, null, 2);
7
- return renderSearchHumanText(run, command);
8
- }
9
-
10
- export function renderSearchRunToUi(run: SearchRunJson, command: SearchCommand, ui: CliUi): void {
11
- if (command.json) {
12
- ui.writeStdout(renderSearchRun(run, command));
13
- return;
14
- }
15
-
16
- if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
17
- ui.warn("Search skipped: input is too large for precise local search.");
18
- ui.note(oversizedText(run, command), "Skipped");
19
- ui.outro("Warn-only. Nothing blocked.");
20
- return;
21
- }
22
-
23
- if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
24
- ui.success("Search complete: no search targets found.");
25
- ui.note(cleanSummaryText(run), "Summary");
26
- ui.outro("No judgment-offload signals found.");
27
- return;
28
- }
29
-
30
- if (run.matches.length === 0) {
31
- ui.success("Search complete: no judgment-offload signals found.");
32
- ui.note(cleanSummaryText(run), "Summary");
33
- ui.outro("Warn-only. Nothing blocked.");
34
- return;
35
- }
36
-
37
- ui.warn(format.warn(format.heading("AI SLOP DETECTED")));
38
- ui.note(matchSummaryText(run, command), "Summary");
39
- for (const group of groupMatchesByFile(run.matches)) {
40
- ui.note(renderMatchGroup(group, run), group.filePath);
41
- }
42
- ui.outro(summaryLine(run));
43
- }
44
-
45
- export function renderSearchHumanText(run: SearchRunJson, command: SearchCommand): string {
46
- if (run.stats.skipped && run.stats.skipReason === "input_too_large") {
47
- return `${format.heading("Search skipped")}
48
- ${oversizedText(run, command)}
49
- Warn-only. Nothing blocked.`;
50
- }
51
-
52
- if (run.stats.skipped && run.stats.skipReason === "no_candidates") {
53
- return `${format.heading("Search complete.")}
54
- ${format.label("Patterns:")} ${run.patterns.join(", ")}
55
- ${format.success("No search targets found.")}`;
56
- }
57
-
58
- if (run.matches.length === 0) {
59
- return `${format.heading("Search complete.")}
60
- ${format.label("Patterns:")} ${run.patterns.join(", ")}
61
- ${format.success("No judgment-offload signals found.")}`;
62
- }
63
-
64
- const groups = groupMatchesByFile(run.matches);
65
- return `${slopHeading()}
66
- ${matchSummaryText(run, command)}
67
-
68
- ${groups.map((group) => `${format.heading(group.filePath)}
69
- ${renderMatchGroup(group, run)}`).join("\n\n")}
70
- ${format.muted(summaryLine(run))}`;
71
- }
72
-
73
- export function helpText(): string {
74
- return `Stupify ${VERSION}
75
-
76
- Usage:
77
- stupify
78
- stupify --since "2 weeks ago"
79
- stupify --commit <commit>
80
- stupify --commits <count>
81
- stupify --staged
82
- stupify --mode search --staged
83
- stupify hook install|uninstall|status
84
- stupify doctor
85
- stupify bench search experiments/search-bench.json
86
- git diff HEAD~1..HEAD | stupify --stdin
87
-
88
- Options:
89
- --staged Search staged changes.
90
- --mode <mode> search. Search is the only analysis mode.
91
- --since <date> Search the net diff from the first commit before this git date to HEAD.
92
- --commit <commit> Search one commit as a net diff.
93
- --commits <count> Search the net diff across the last N non-merge commits.
94
- --stdin Read a git diff from stdin.
95
- --debug-sem Print sem commands and stderr.
96
- --max-candidates <n> Max semantic search targets. Default: 50.
97
- --max-search-input-tokens <n>
98
- Max search input tokens before skipping. Default: 12000.
99
- --checks <ids> Comma-separated pattern ids.
100
- --model <id> gemma-4-e2b, gemma-4-e4b, gemma-4-26b-a4b, qwen3-4b-magicquant, qwen2.5-coder-1.5b, qwen2.5-coder-7b, or qwen2.5-coder-32b.
101
- --search-profile <path>
102
- Dev/bench-only search profile override.
103
- --include-counter-reason-in-prompt
104
- Debug/bench-only: include counter reason in the model prompt.
105
- --json Print JSON only.
106
-
107
- Diagnostics:
108
- stupify doctor Check local setup, hook status, and privacy boundary.
109
-
110
- Default:
111
- stupify is equivalent to stupify --since "2 weeks ago".
112
-
113
- Pipeline:
114
- sem diff -> counter scout -> Repomix context -> local search model.
115
-
116
- Not included:
117
- Findings audit, validators, judges, baselines, sharing, hosted server calls, GitHub, dashboards, or repo-wide crawling.
118
- `;
119
- }
120
-
121
- type MatchGroup = Readonly<{
122
- filePath: string;
123
- matches: SearchRunJson["matches"];
124
- }>;
125
-
126
- function oversizedText(run: SearchRunJson, command: SearchCommand): string {
127
- const targetLimit = Math.max((run.stats.inputTokens ?? 12_000) + 1, (run.stats.inputTokenCap ?? 12_000) * 2);
128
- return [
129
- `Size: ~${run.stats.inputTokens ?? "unknown"} tokens`,
130
- `Limit: ${run.stats.inputTokenCap ?? "unknown"} tokens`,
131
- "Stupify skipped the search rather than review truncated context.",
132
- `Try: ${sourceHint(command)} --max-search-input-tokens ${targetLimit}`,
133
- ].join("\n");
134
- }
135
-
136
- function cleanSummaryText(run: SearchRunJson): string {
137
- return [
138
- `Patterns: ${run.patterns.join(", ")}`,
139
- run.stats.filesChanged === undefined ? null : `Diff: ${run.stats.filesChanged} files, ${run.stats.entitiesScanned ?? 0} changed entities`,
140
- ].filter(Boolean).join("\n");
141
- }
142
-
143
- function matchSummaryText(run: SearchRunJson, command: SearchCommand): string {
144
- const fileCount = groupMatchesByFile(run.matches).length;
145
- const fileNoun = fileCount === 1 ? "file" : "files";
146
- return [
147
- `${run.matches.length} ${signalNoun(run.matches.length)} across ${fileCount} ${fileNoun}`,
148
- `${committerLabel(run)} · ${sourceLabel(command)}`,
149
- "Warn-only. Nothing blocked.",
150
- "",
151
- patternSummaryLine(run),
152
- ].filter((line) => line !== null).join("\n");
153
- }
154
-
155
- function patternSummaryLine(run: SearchRunJson): string {
156
- const counts = new Map<string, number>();
157
- for (const match of run.matches) counts.set(patternLabel(match), (counts.get(patternLabel(match)) ?? 0) + 1);
158
- return [...counts.entries()].map(([patternName, count]) => `${patternName} ${count}`).join(" · ");
159
- }
160
-
161
- function groupMatchesByFile(matches: SearchRunJson["matches"]): readonly MatchGroup[] {
162
- const groups = new Map<string, SearchRunJson["matches"][number][]>();
163
- for (const match of matches) {
164
- const filePath = proofFilePath(match.proof);
165
- const group = groups.get(filePath) ?? [];
166
- group.push(match);
167
- groups.set(filePath, group);
168
- }
169
- return [...groups.entries()].map(([filePath, groupedMatches]) => ({
170
- filePath,
171
- matches: groupedMatches,
172
- }));
173
- }
174
-
175
- function renderMatchGroup(group: MatchGroup, run: SearchRunJson): string {
176
- return group.matches.map((match, index) => {
177
- const lines = [
178
- matchHeadline(match, run, index),
179
- match.reason,
180
- match.snapshot ? `\n\`\`\`\n${match.snapshot}\n\`\`\`` : null,
181
- format.muted(`${proofDetail(match.proof)}${commitSubjectSuffix(run)}`),
182
- match.checkWhy ?? "This pattern may indicate judgment-offload.",
183
- ];
184
- return lines.filter(Boolean).join("\n");
185
- }).join("\n\n");
186
- }
187
-
188
- function matchHeadline(match: SearchRunJson["matches"][number], run: SearchRunJson, index: number): string {
189
- return `${index + 1}. ${format.label(patternLabel(match))}: ${headlineArgs(match)} -- ${matchBlameLabel(match, run)}`;
190
- }
191
-
192
- function patternLabel(match: SearchRunJson["matches"][number]): string {
193
- return titleCase(match.patternName ?? match.patternId.replace(/_/g, " "));
194
- }
195
-
196
- function headlineArgs(match: SearchRunJson["matches"][number]): string {
197
- const destination = entityNameFromProof(match.proof);
198
- const source = firstBacktickedToken(match.reason) ?? firstLikelySource(match.reason, destination);
199
- if (source && destination && source !== destination) return `${codeLabel(source)} -> ${codeLabel(destination)}`;
200
- if (destination) return codeLabel(destination);
201
- return codeLabel(match.targetId);
202
- }
203
-
204
- function matchBlameLabel(match: SearchRunJson["matches"][number], run: SearchRunJson): string {
205
- return match.blame ? blameSummaryLabel(match.blame) : runLevelBlameLabel(run);
206
- }
207
-
208
- function blameSummaryLabel(blame: NonNullable<SearchRunJson["matches"][number]["blame"]>): string {
209
- return `${blame.author} (${blame.subject})`;
210
- }
211
-
212
- function runLevelBlameLabel(run: SearchRunJson): string {
213
- const author = committerLabel(run);
214
- const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
215
- return subject ? `${author} (${subject})` : author;
216
- }
217
-
218
- function commitSubjectSuffix(run: SearchRunJson): string {
219
- const subject = firstHumanSubject(run.stats.commitSubjects ?? []);
220
- return subject ? ` · commit: ${subject}` : "";
221
- }
222
-
223
- function firstHumanSubject(subjects: readonly string[]): string | undefined {
224
- return subjects.map((subject) => subject.trim()).find(Boolean);
225
- }
226
-
227
- function codeLabel(value: string): string {
228
- return `\`${value}\``;
229
- }
230
-
231
- function titleCase(value: string): string {
232
- return value.replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
233
- }
234
-
235
- function entityNameFromProof(proof: string): string | undefined {
236
- const parts = proof.split("::");
237
- return parts[2] || parts[1] || undefined;
238
- }
239
-
240
- function firstBacktickedToken(value: string): string | undefined {
241
- const match = /`([^`]+)`/.exec(value);
242
- return cleanToken(match?.[1]);
243
- }
244
-
245
- function firstLikelySource(value: string, destination?: string): string | undefined {
246
- const tokens = [...value.matchAll(/\b[A-Z][A-Za-z0-9_]*(?:\[[^\]]+\])?\b/g)]
247
- .map((match) => cleanToken(match[0]))
248
- .filter((token): token is string => Boolean(token));
249
- return tokens.find((token) => token !== destination && token !== "The");
250
- }
251
-
252
- function cleanToken(value: string | undefined): string | undefined {
253
- const token = value?.trim().replace(/[.,;:]+$/, "");
254
- return token || undefined;
255
- }
256
-
257
- function proofFilePath(proof: string): string {
258
- return proof.split("::")[0] || proof;
259
- }
260
-
261
- function proofDetail(proof: string): string {
262
- const [, ...rest] = proof.split("::");
263
- return rest.length > 0 ? `::${rest.join("::")}` : proof;
264
- }
265
-
266
- function sourceHint(command: SearchCommand): string {
267
- if (command.kind === "staged") return "--staged";
268
- if (command.kind === "since") return `--since "${command.since}"`;
269
- if (command.kind === "commit") return `--commit ${command.commit}`;
270
- if (command.kind === "commits") return `--commits ${command.count}`;
271
- return "--stdin";
272
- }
273
-
274
- function sourceLabel(command: SearchCommand): string {
275
- if (command.kind === "staged") return "staged";
276
- if (command.kind === "since") return sinceLabel(command.since);
277
- if (command.kind === "commit") return `commit ${command.commit}`;
278
- if (command.kind === "commits") return `last ${command.count} commits`;
279
- return "stdin";
280
- }
281
-
282
- function committerLabel(run: SearchRunJson): string {
283
- const committers = humanCommitters(run.stats.committers ?? []).map(committerDisplayName);
284
- if (committers.length === 0) return "unknown committer";
285
- if (committers.length <= 3) return committers.join(", ");
286
- return `${committers.slice(0, 3).join(", ")} +${committers.length - 3} more`;
287
- }
288
-
289
- function humanCommitters(committers: readonly string[]): readonly string[] {
290
- const nonEmpty = committers.filter(Boolean);
291
- const humans = nonEmpty.filter((committer) => !isBotCommitter(committer));
292
- return humans.length > 0 ? humans : nonEmpty;
293
- }
294
-
295
- function isBotCommitter(value: string): boolean {
296
- return /(?:^|<)(?:github|dependabot|renovate)(?:\s|@|>)/i.test(value) ||
297
- /(?:noreply@github\.com|bot@)/i.test(value);
298
- }
299
-
300
- function committerDisplayName(value: string): string {
301
- return value.replace(/\s*<[^>]+>\s*$/, "").trim() || value;
302
- }
303
-
304
- function slopHeading(): string {
305
- const heading = "AI SLOP DETECTED";
306
- return `${format.warn(format.heading(heading))}
307
- ${format.warn("=".repeat(heading.length))}`;
308
- }
309
-
310
- function sinceLabel(since: string): string {
311
- const value = since.trim().toLowerCase();
312
- if (value === "yesterday" || value === "1 day ago") return "yesterday";
313
- const match = /^(\d+)\s+(day|week|month|year)s?\s+ago$/.exec(value);
314
- if (!match) return `since ${since}`;
315
-
316
- const count = Number(match[1]);
317
- const unit = match[2];
318
- if (count === 1) return `last ${unit}`;
319
- return `last ${count} ${unit}s`;
320
- }
321
-
322
- function summaryLine(run: SearchRunJson): string {
323
- return `${run.matches.length} ${signalNoun(run.matches.length)}. Warn-only. Nothing blocked.`;
324
- }
325
-
326
- function signalNoun(count: number): string {
327
- return count === 1 ? "signal" : "signals";
328
- }
@@ -1,219 +0,0 @@
1
- import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import path from "node:path";
4
- import { pack, setLogLevel } from "repomix";
5
- import type { RepomixSearchConfig, SemCandidate, SemChange, SemContext, SemContextPack } from "./types.ts";
6
-
7
- const MAX_PACK_FILE_SIZE_BYTES = 48 * 1024;
8
- const MAX_PACK_TOTAL_SIZE_BYTES = 128 * 1024;
9
-
10
- export function emptyContextPack(): SemContextPack {
11
- const config = repomixSearchConfig();
12
- return {
13
- provider: "repomix",
14
- filePaths: [],
15
- totalCharacters: 0,
16
- totalTokens: 0,
17
- text: "",
18
- config,
19
- };
20
- }
21
-
22
- export async function repomixContextPack(
23
- cwd: string,
24
- contexts: readonly SemContext[],
25
- changes: readonly SemChange[],
26
- config = repomixSearchConfig(),
27
- ): Promise<SemContextPack> {
28
- const filePaths = await candidateFilePaths(cwd, contexts, changes, config);
29
- if (filePaths.length === 0) {
30
- return {
31
- ...emptyContextPack(),
32
- config,
33
- };
34
- }
35
-
36
- setLogLevel(-1);
37
- const tempDir = await mkdtemp(path.join(tmpdir(), "stupify-repomix-"));
38
- const outputPath = path.join(tempDir, "context.xml");
39
- try {
40
- const result = await pack(
41
- [cwd],
42
- {
43
- cwd,
44
- input: { maxFileSize: config.maxFileSizeBytes },
45
- output: {
46
- filePath: outputPath,
47
- style: "xml",
48
- parsableStyle: false,
49
- fileSummary: false,
50
- directoryStructure: false,
51
- files: true,
52
- removeComments: false,
53
- removeEmptyLines: config.removeEmptyLines,
54
- compress: config.compress,
55
- topFilesLength: 0,
56
- showLineNumbers: config.showLineNumbers,
57
- truncateBase64: true,
58
- copyToClipboard: false,
59
- includeFullDirectoryStructure: false,
60
- tokenCountTree: false,
61
- git: {
62
- sortByChanges: false,
63
- sortByChangesMaxCommits: 1,
64
- includeDiffs: false,
65
- includeLogs: false,
66
- includeLogsCount: 1,
67
- },
68
- },
69
- include: [],
70
- ignore: {
71
- useGitignore: true,
72
- useDotIgnore: true,
73
- useDefaultPatterns: true,
74
- customPatterns: [...config.ignorePatterns],
75
- },
76
- security: { enableSecurityCheck: false },
77
- tokenCount: { encoding: "o200k_base" },
78
- } satisfies Parameters<typeof pack>[1],
79
- () => undefined,
80
- {},
81
- [...filePaths],
82
- );
83
- return {
84
- provider: "repomix",
85
- filePaths,
86
- totalCharacters: result.totalCharacters,
87
- totalTokens: result.totalTokens,
88
- text: await readFile(outputPath, "utf8"),
89
- config,
90
- };
91
- } finally {
92
- await rm(tempDir, { recursive: true, force: true });
93
- }
94
- }
95
-
96
- export function entityContextsFromChanges(
97
- candidates: readonly SemCandidate[],
98
- changes: readonly SemChange[],
99
- ): readonly SemContext[] {
100
- const byEntityId = new Map(changes.map((change) => [change.entityId, change]));
101
- return candidates.flatMap((candidate): readonly SemContext[] => {
102
- const change = byEntityId.get(candidate.entityId);
103
- if (!change) return [];
104
- return [{
105
- targetId: candidate.targetId,
106
- entityId: change.entityId,
107
- entityName: change.entityName,
108
- entityKind: change.entityType,
109
- changeKind: change.changeType,
110
- checkId: candidate.checkId,
111
- reason: candidate.reason,
112
- filePath: change.filePath,
113
- text: JSON.stringify({
114
- source: "sem diff",
115
- file: change.filePath,
116
- type: change.entityType,
117
- name: change.entityName,
118
- change: change.changeType,
119
- before: shortenCode(change.beforeContent),
120
- after: shortenCode(change.afterContent),
121
- }, null, 2),
122
- }];
123
- });
124
- }
125
-
126
- async function candidateFilePaths(
127
- cwd: string,
128
- contexts: readonly SemContext[],
129
- changes: readonly SemChange[],
130
- config: RepomixSearchConfig,
131
- ): Promise<readonly string[]> {
132
- const byEntityId = new Map(changes.map((change) => [change.entityId, change.filePath]));
133
- const paths = contexts.flatMap((context) => context.filePath ?? byEntityId.get(context.entityId) ?? []);
134
- const safePaths = [...new Set(paths)].filter(isSafeRelativeFilePath);
135
- const selected = [];
136
- let totalBytes = 0;
137
- for (const filePath of safePaths) {
138
- if (matchesAnyPattern(filePath, config.ignorePatterns)) continue;
139
- const bytes = await fileSize(cwd, filePath);
140
- if (bytes === null || bytes > config.maxFileSizeBytes) continue;
141
- if (totalBytes + bytes > config.maxTotalSizeBytes) continue;
142
- totalBytes += bytes;
143
- selected.push(filePath);
144
- }
145
- return selected;
146
- }
147
-
148
- export function repomixSearchConfig(): RepomixSearchConfig {
149
- return {
150
- compress: envBoolean("STUPIFY_REPOMIX_COMPRESS", true),
151
- showLineNumbers: envBoolean("STUPIFY_REPOMIX_SHOW_LINE_NUMBERS", true),
152
- removeEmptyLines: envBoolean("STUPIFY_REPOMIX_REMOVE_EMPTY_LINES", true),
153
- maxFileSizeBytes: envInteger("STUPIFY_REPOMIX_MAX_FILE_BYTES", MAX_PACK_FILE_SIZE_BYTES),
154
- maxTotalSizeBytes: envInteger("STUPIFY_REPOMIX_MAX_TOTAL_BYTES", MAX_PACK_TOTAL_SIZE_BYTES),
155
- ignorePatterns: envList("STUPIFY_REPOMIX_IGNORE_PATTERNS"),
156
- };
157
- }
158
-
159
- function envBoolean(name: string, fallback: boolean): boolean {
160
- const value = process.env[name];
161
- if (value === undefined || value === "") return fallback;
162
- return /^(1|true|yes|on)$/i.test(value);
163
- }
164
-
165
- function envInteger(name: string, fallback: number): number {
166
- const value = Number(process.env[name]);
167
- return Number.isInteger(value) && value > 0 ? value : fallback;
168
- }
169
-
170
- function envList(name: string): readonly string[] {
171
- return (process.env[name] ?? "")
172
- .split(",")
173
- .map((item) => item.trim())
174
- .filter(Boolean);
175
- }
176
-
177
- function matchesAnyPattern(filePath: string, patterns: readonly string[]): boolean {
178
- return patterns.some((pattern) => matchesPattern(filePath, pattern));
179
- }
180
-
181
- function matchesPattern(filePath: string, pattern: string): boolean {
182
- if (pattern === filePath) return true;
183
- if (!pattern.includes("*")) return false;
184
- const escaped = pattern
185
- .split("*")
186
- .map(escapeRegExp)
187
- .join(".*");
188
- return new RegExp(`^${escaped}$`).test(filePath);
189
- }
190
-
191
- function escapeRegExp(value: string): string {
192
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
193
- }
194
-
195
- function isSafeRelativeFilePath(value: string): boolean {
196
- if (!value || path.isAbsolute(value)) return false;
197
- const normalized = path.normalize(value);
198
- return normalized !== "." && !normalized.startsWith("..") && !path.isAbsolute(normalized);
199
- }
200
-
201
- async function fileSize(cwd: string, filePath: string): Promise<number | null> {
202
- try {
203
- const fullPath = path.join(cwd, filePath);
204
- if (!fullPath.startsWith(`${cwd}${path.sep}`)) return null;
205
- const result = await stat(fullPath);
206
- return result.isFile() ? result.size : null;
207
- } catch {
208
- return null;
209
- }
210
- }
211
-
212
- function shortenCode(value: string | null): string {
213
- if (!value) return "(none)";
214
- const lines = value.split(/\r?\n/);
215
- const limit = 120;
216
- if (lines.length <= limit) return value;
217
- return `${lines.slice(0, limit).join("\n")}
218
- [stupify: sem entity content shortened after ${limit} lines]`;
219
- }