@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.
Files changed (66) hide show
  1. package/README.md +55 -0
  2. package/dist/analysis.d.ts +16 -0
  3. package/dist/analysis.js +133 -0
  4. package/dist/cache.d.ts +2 -0
  5. package/dist/cache.js +59 -0
  6. package/dist/checks.d.ts +4 -0
  7. package/dist/checks.js +218 -0
  8. package/dist/command.d.ts +2 -0
  9. package/dist/command.js +147 -0
  10. package/dist/constants.d.ts +4 -0
  11. package/dist/constants.js +53 -0
  12. package/dist/counter-scout.d.ts +14 -0
  13. package/dist/counter-scout.js +159 -0
  14. package/dist/diff.d.ts +1 -0
  15. package/dist/diff.js +10 -0
  16. package/dist/doctor.d.ts +4 -0
  17. package/dist/doctor.js +131 -0
  18. package/dist/git.d.ts +11 -0
  19. package/dist/git.js +253 -0
  20. package/dist/hooks.d.ts +3 -0
  21. package/dist/hooks.js +117 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/model.d.ts +10 -0
  25. package/dist/model.js +297 -0
  26. package/dist/prompts.d.ts +8 -0
  27. package/dist/prompts.js +87 -0
  28. package/dist/render.d.ts +3 -0
  29. package/dist/render.js +93 -0
  30. package/dist/repomix-provider.d.ts +12 -0
  31. package/dist/repomix-provider.js +196 -0
  32. package/dist/search-bench.d.ts +1 -0
  33. package/dist/search-bench.js +675 -0
  34. package/dist/search-profile.d.ts +6 -0
  35. package/dist/search-profile.js +73 -0
  36. package/dist/sem-provider.d.ts +2 -0
  37. package/dist/sem-provider.js +247 -0
  38. package/dist/stupify.d.ts +4 -0
  39. package/dist/stupify.js +237 -0
  40. package/dist/trace.d.ts +29 -0
  41. package/dist/trace.js +64 -0
  42. package/dist/types.d.ts +320 -0
  43. package/dist/types.js +6 -0
  44. package/package.json +42 -5
  45. package/src/analysis.ts +188 -0
  46. package/src/cache.ts +65 -0
  47. package/src/checks.ts +221 -0
  48. package/src/command.ts +173 -0
  49. package/src/constants.ts +56 -0
  50. package/src/counter-scout.ts +175 -0
  51. package/src/diff.ts +9 -0
  52. package/src/doctor.ts +140 -0
  53. package/src/git.ts +262 -0
  54. package/src/hooks.ts +134 -0
  55. package/src/index.ts +1 -0
  56. package/src/model.ts +373 -0
  57. package/src/prompts.ts +98 -0
  58. package/src/render.ts +96 -0
  59. package/src/repomix-provider.ts +219 -0
  60. package/src/search-bench.ts +783 -0
  61. package/src/search-profile.ts +89 -0
  62. package/src/sem-provider.ts +282 -0
  63. package/src/stupify.ts +285 -0
  64. package/src/trace.ts +103 -0
  65. package/src/types.ts +340 -0
  66. package/bin/stupify.mjs +0 -3
@@ -0,0 +1,320 @@
1
+ declare const brand: unique symbol;
2
+ type Brand<Value, Name extends string> = Value & {
3
+ readonly [brand]: Name;
4
+ };
5
+ export type SourceId = Brand<string, "SourceId">;
6
+ export type CheckId = Brand<string, "CheckId">;
7
+ export declare function sourceId(value: string): SourceId;
8
+ export declare function checkId(value: string): CheckId;
9
+ export type SearchMode = "warn" | "off";
10
+ export type HookAction = "install" | "uninstall" | "status";
11
+ export type SearchSource = "since" | "stdin" | "commit" | "commits" | "staged";
12
+ type SearchOptions = Readonly<{
13
+ checkIds: readonly string[] | null;
14
+ json: boolean;
15
+ model: ModelId;
16
+ debugSem: boolean;
17
+ maxCandidates: number;
18
+ maxSearchInputTokens: number;
19
+ searchProfilePath: string | null;
20
+ includeCounterReasonInPrompt: boolean;
21
+ }>;
22
+ export type Command = Readonly<{
23
+ kind: "help";
24
+ }> | Readonly<{
25
+ kind: "hook";
26
+ action: HookAction;
27
+ }> | Readonly<{
28
+ kind: "doctor";
29
+ }> | Readonly<{
30
+ kind: "bench-search";
31
+ configPath: string;
32
+ }> | (Readonly<{
33
+ kind: "since";
34
+ since: string;
35
+ mode: "search";
36
+ source: "since";
37
+ }> & SearchOptions) | (Readonly<{
38
+ kind: "stdin";
39
+ mode: "search";
40
+ source: "stdin";
41
+ }> & SearchOptions) | (Readonly<{
42
+ kind: "commit";
43
+ commit: string;
44
+ mode: "search";
45
+ source: "commit";
46
+ }> & SearchOptions) | (Readonly<{
47
+ kind: "commits";
48
+ count: number;
49
+ mode: "search";
50
+ source: "commits";
51
+ }> & SearchOptions) | (Readonly<{
52
+ kind: "staged";
53
+ mode: "search";
54
+ source: "staged";
55
+ }> & SearchOptions);
56
+ export type SearchCommand = Exclude<Command, Readonly<{
57
+ kind: "help";
58
+ }> | Readonly<{
59
+ kind: "hook";
60
+ action: HookAction;
61
+ }> | Readonly<{
62
+ kind: "doctor";
63
+ }> | Readonly<{
64
+ kind: "bench-search";
65
+ configPath: string;
66
+ }>>;
67
+ export type StupifyCheck = Readonly<{
68
+ id: CheckId;
69
+ name: string;
70
+ question: string;
71
+ lookFor: readonly string[];
72
+ ignoreWhen: readonly string[];
73
+ enabledByDefault?: boolean;
74
+ hookMode?: SearchMode;
75
+ searchPrompt?: string;
76
+ searchExamples?: Readonly<{
77
+ match: readonly string[];
78
+ nonMatch: readonly string[];
79
+ }>;
80
+ examples?: Readonly<{
81
+ match?: readonly string[];
82
+ noMatch?: readonly string[];
83
+ }>;
84
+ }>;
85
+ export type NetDiffStats = Readonly<{
86
+ filesChanged: number;
87
+ additions: number;
88
+ deletions: number;
89
+ }>;
90
+ export type StagedDiff = Readonly<{
91
+ text: string;
92
+ stats: NetDiffStats;
93
+ }>;
94
+ export type NetDiff = Readonly<{
95
+ id: SourceId;
96
+ label: string;
97
+ base: string;
98
+ target: string;
99
+ text: string;
100
+ stats: NetDiffStats;
101
+ }>;
102
+ export type SourceRange = Readonly<{
103
+ id: SourceId;
104
+ label: string;
105
+ base: string;
106
+ target: string;
107
+ stats: NetDiffStats;
108
+ }>;
109
+ export type SemChange = Readonly<{
110
+ entityId: string;
111
+ entityName: string;
112
+ entityType: string;
113
+ filePath: string;
114
+ changeType: string;
115
+ beforeContent: string | null;
116
+ afterContent: string | null;
117
+ }>;
118
+ export type SemChangeSummary = Readonly<{
119
+ added: number;
120
+ deleted: number;
121
+ modified: number;
122
+ moved: number;
123
+ renamed: number;
124
+ fileCount: number;
125
+ total: number;
126
+ }>;
127
+ export type SemChangeSet = Readonly<{
128
+ id: SourceId;
129
+ label: string;
130
+ base: string;
131
+ target: string;
132
+ contextCwd: string;
133
+ cleanup: () => Promise<void>;
134
+ changes: readonly SemChange[];
135
+ summary: SemChangeSummary;
136
+ }>;
137
+ export type SemCandidate = Readonly<{
138
+ sourceId: SourceId;
139
+ targetId: string;
140
+ entityId: string;
141
+ checkId: CheckId;
142
+ reason: string;
143
+ }>;
144
+ export type SemContext = Readonly<{
145
+ targetId: string;
146
+ entityId: string;
147
+ entityName: string;
148
+ entityKind: string;
149
+ changeKind: string;
150
+ checkId: CheckId;
151
+ reason: string;
152
+ filePath?: string;
153
+ text: string;
154
+ }>;
155
+ export type SemContextPack = Readonly<{
156
+ provider: "repomix";
157
+ filePaths: readonly string[];
158
+ totalCharacters: number;
159
+ totalTokens: number;
160
+ text: string;
161
+ config: RepomixSearchConfig;
162
+ }>;
163
+ export type RepomixSearchConfig = Readonly<{
164
+ compress: boolean;
165
+ showLineNumbers: boolean;
166
+ removeEmptyLines: boolean;
167
+ maxFileSizeBytes: number;
168
+ maxTotalSizeBytes: number;
169
+ ignorePatterns: readonly string[];
170
+ }>;
171
+ export type SearchProfileRepomixConfig = Readonly<{
172
+ compress?: boolean;
173
+ showLineNumbers?: boolean;
174
+ removeEmptyLines?: boolean;
175
+ maxFileBytes?: number;
176
+ maxTotalBytes?: number;
177
+ ignorePatterns?: readonly string[];
178
+ }>;
179
+ export type SearchProfilePattern = Readonly<{
180
+ enabled?: boolean;
181
+ searchPrompt?: string;
182
+ matchExamples?: readonly string[];
183
+ nonMatchExamples?: readonly string[];
184
+ }>;
185
+ export type SearchProfile = Readonly<{
186
+ id: string;
187
+ context?: "repomix" | "sem";
188
+ maxCandidates?: number;
189
+ maxSearchInputTokens?: number;
190
+ includeCounterReasonInPrompt?: boolean;
191
+ repomix?: SearchProfileRepomixConfig;
192
+ patterns?: Readonly<Record<string, SearchProfilePattern>>;
193
+ }>;
194
+ export type SearchMatch = Readonly<{
195
+ targetId: string;
196
+ patternId: CheckId;
197
+ reason: string;
198
+ proof: string;
199
+ }>;
200
+ export type SearchRunJson = Readonly<{
201
+ schemaVersion: "search.v1";
202
+ mode: "search";
203
+ source: SearchSource;
204
+ model: Readonly<{
205
+ id: ModelId;
206
+ }>;
207
+ patterns: readonly CheckId[];
208
+ stats: Readonly<{
209
+ elapsedMs: number;
210
+ modelCalls: number;
211
+ inputTokens?: number;
212
+ inputTokenCap?: number;
213
+ skipped?: boolean;
214
+ skipReason?: "input_too_large" | "no_candidates";
215
+ filesChanged?: number;
216
+ entitiesScanned?: number;
217
+ candidates?: number;
218
+ repomixFiles?: number;
219
+ repomixTokens?: number;
220
+ repomixConfig?: RepomixSearchConfig;
221
+ searchTargets?: number;
222
+ profileId?: string;
223
+ targetsByPattern?: Readonly<Record<string, number>>;
224
+ targetsPreview?: readonly SearchTargetPreview[];
225
+ }>;
226
+ matches: readonly SearchMatch[];
227
+ }>;
228
+ export type SearchTargetPreview = Readonly<{
229
+ targetId: string;
230
+ patternId: CheckId;
231
+ entityKind?: string;
232
+ sourceKind?: string;
233
+ }>;
234
+ export type SearchBenchConfig = Readonly<{
235
+ name: string;
236
+ profiles: readonly string[];
237
+ fixtures: string;
238
+ realSmokeRuns?: readonly SearchBenchSmokeRun[];
239
+ realCommitReplay?: readonly SearchBenchCommitReplay[];
240
+ }>;
241
+ export type SearchBenchSmokeRun = Readonly<{
242
+ id: string;
243
+ cwd?: string;
244
+ args: readonly string[];
245
+ }>;
246
+ export type SearchBenchCommitReplay = Readonly<{
247
+ id: string;
248
+ repoEnv?: string;
249
+ cwd?: string;
250
+ limit: number;
251
+ since?: string;
252
+ nonMerge?: boolean;
253
+ profiles: readonly string[];
254
+ }>;
255
+ export type SearchFixture = Readonly<{
256
+ id: string;
257
+ description: string;
258
+ stagedPatch: string;
259
+ checks: readonly string[];
260
+ expected: readonly SearchFixtureExpectation[];
261
+ }>;
262
+ export type SearchFixtureExpectation = Readonly<{
263
+ patternId: string;
264
+ shouldMatch: boolean;
265
+ }>;
266
+ export type SearchBenchRun = Readonly<{
267
+ profileId: string;
268
+ fixtureId?: string;
269
+ smokeId?: string;
270
+ elapsedMs: number;
271
+ modelCalls: number;
272
+ patterns: readonly CheckId[];
273
+ targets: number;
274
+ targetsByPattern: Readonly<Record<string, number>>;
275
+ inputTokens: number;
276
+ repomixPackedTokens?: number;
277
+ skipped: boolean;
278
+ skipReason?: string;
279
+ matches: readonly SearchMatch[];
280
+ expected?: readonly SearchFixtureExpectation[];
281
+ score?: number;
282
+ targetsPreview: readonly SearchTargetPreview[];
283
+ matchesUsingCounterReasonAsProof: number;
284
+ error?: string;
285
+ }>;
286
+ export type SearchBenchReplayRun = Readonly<{
287
+ profileId: string;
288
+ replayId: string;
289
+ commitId: string;
290
+ outcome: SearchReplayOutcome;
291
+ changedFiles: number;
292
+ addedLines: number;
293
+ deletedLines: number;
294
+ elapsedMs: number;
295
+ skipped: boolean;
296
+ skipReason?: string;
297
+ targets: number;
298
+ inputTokens: number;
299
+ repomixPackedTokens?: number;
300
+ modelCalls: number;
301
+ matches: readonly SearchMatch[];
302
+ matchesByPattern: Readonly<Record<string, number>>;
303
+ error?: string;
304
+ }>;
305
+ export type SearchReplayOutcome = "no_candidates" | "ran_no_matches" | "ran_with_matches" | "skipped_input_too_large" | "error";
306
+ export type ModelId = "gemma-4-e2b" | "gemma-4-e4b" | "gemma-4-26b-a4b" | "qwen3-4b-magicquant" | "qwen2.5-coder-1.5b" | "qwen2.5-coder-7b" | "qwen2.5-coder-32b";
307
+ export type ModelConfig = Readonly<{
308
+ id: ModelId;
309
+ name: string;
310
+ file: string;
311
+ url: string;
312
+ size: string;
313
+ }>;
314
+ export type TraceEvent = Readonly<{
315
+ name: string;
316
+ ms: number;
317
+ count?: number;
318
+ detail?: string;
319
+ }>;
320
+ export {};
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ export function sourceId(value) {
2
+ return value;
3
+ }
4
+ export function checkId(value) {
5
+ return value;
6
+ }
package/package.json CHANGED
@@ -1,12 +1,49 @@
1
1
  {
2
2
  "name": "@stupify/cli",
3
- "version": "0.0.2",
4
- "description": "stupif.ai CLI",
3
+ "version": "0.0.4",
4
+ "description": "Local-only diagnostic CLI for checking whether AI is making you dumber.",
5
+ "private": false,
6
+ "type": "module",
5
7
  "bin": {
6
- "stupify": "./bin/stupify.mjs"
8
+ "stupify": "dist/stupify.js"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+ssh://git@github.com/Octember/stupif.ai.git",
13
+ "directory": "packages/cli"
14
+ },
15
+ "homepage": "https://stupif.ai",
16
+ "bugs": {
17
+ "url": "https://github.com/Octember/stupif.ai/issues"
18
+ },
19
+ "keywords": [
20
+ "ai",
21
+ "cli",
22
+ "developer-tools",
23
+ "local-first",
24
+ "code-review"
25
+ ],
26
+ "license": "UNLICENSED",
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
7
32
  },
8
33
  "files": [
9
- "bin"
34
+ "dist",
35
+ "src",
36
+ "README.md",
37
+ "package.json"
10
38
  ],
11
- "license": "MIT"
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.build.json",
41
+ "prepack": "bun run build",
42
+ "typecheck": "tsc -p tsconfig.json",
43
+ "smoke": "bun run build && node ./dist/stupify.js --help"
44
+ },
45
+ "dependencies": {
46
+ "@ataraxy-labs/sem": "^0.3.24",
47
+ "repomix": "^1.14.0"
48
+ }
12
49
  }
@@ -0,0 +1,188 @@
1
+ import { cachedJson, fingerprint } from "./cache.ts";
2
+ import type { LocalModel } from "./model.ts";
3
+ import { searchPrompt } from "./prompts.ts";
4
+ import type { SearchMatch, SemChangeSet, SemContext, SemContextPack, StupifyCheck } from "./types.ts";
5
+
6
+ export async function runSearch(
7
+ model: LocalModel,
8
+ request: SearchRequest,
9
+ ): Promise<readonly SearchMatch[]> {
10
+ const raw = await runJsonPrompt(model, request.prompt, request.schema, 0);
11
+ return uncheckedSearchMatches(raw, request.contexts);
12
+ }
13
+
14
+ export type SearchRequest = Readonly<{
15
+ prompt: string;
16
+ schema: unknown;
17
+ contexts: readonly SemContext[];
18
+ }>;
19
+
20
+ export function searchRequest(input: Readonly<{
21
+ changeSet: SemChangeSet;
22
+ contexts: readonly SemContext[];
23
+ pack: SemContextPack;
24
+ patterns: readonly StupifyCheck[];
25
+ includeCounterReasonInPrompt?: boolean;
26
+ }>): SearchRequest {
27
+ return {
28
+ prompt: searchPrompt({
29
+ ...input,
30
+ includeCounterReason: input.includeCounterReasonInPrompt ?? false,
31
+ }),
32
+ schema: searchSchema(input.contexts),
33
+ contexts: input.contexts,
34
+ };
35
+ }
36
+
37
+ export async function countPromptTokens(model: LocalModel, prompt: string): Promise<number> {
38
+ const cached = await cachedJson(
39
+ "prompt-tokens",
40
+ fingerprint({
41
+ version: 1,
42
+ modelId: model.id,
43
+ profile: model.profile,
44
+ prompt,
45
+ }),
46
+ async () => {
47
+ const response = await fetch(`${model.baseUrl}/tokenize`, {
48
+ method: "POST",
49
+ headers: { "content-type": "application/json" },
50
+ body: JSON.stringify({ content: prompt }),
51
+ });
52
+ if (!response.ok) {
53
+ throw new Error(`llama-server tokenize failed: HTTP ${response.status} ${await response.text()}`);
54
+ }
55
+ const body = await response.json() as { tokens?: unknown };
56
+ if (!Array.isArray(body.tokens)) throw new Error("llama-server tokenize returned no tokens.");
57
+ return { count: body.tokens.length };
58
+ },
59
+ );
60
+ return cached.count;
61
+ }
62
+
63
+ function searchSchema(contexts: readonly SemContext[]): unknown {
64
+ return {
65
+ type: "object",
66
+ properties: {
67
+ matches: {
68
+ type: "array",
69
+ maxItems: 5,
70
+ items: {
71
+ type: "object",
72
+ properties: {
73
+ targetId: { type: "string", enum: contexts.map((context) => context.targetId) },
74
+ reason: { type: "string" },
75
+ proof: { type: "string" },
76
+ },
77
+ required: ["targetId", "reason", "proof"],
78
+ additionalProperties: false,
79
+ },
80
+ },
81
+ },
82
+ required: ["matches"],
83
+ additionalProperties: false,
84
+ };
85
+ }
86
+
87
+ type RawSearchOutput = Readonly<{
88
+ matches?: readonly RawSearchMatch[];
89
+ }>;
90
+ type RawSearchMatch = Readonly<{
91
+ targetId?: string;
92
+ reason?: string;
93
+ proof?: string;
94
+ }>;
95
+
96
+ function uncheckedSearchMatches(value: unknown, contexts: readonly SemContext[]): readonly SearchMatch[] {
97
+ const output = value as RawSearchOutput;
98
+ const contextsByTargetId = new Map(contexts.map((context) => [context.targetId, context]));
99
+ return (output.matches ?? []).flatMap((match): readonly SearchMatch[] => {
100
+ const targetId = match.targetId ?? "";
101
+ const context = contextsByTargetId.get(targetId);
102
+ if (!context) return [];
103
+ return [{
104
+ targetId,
105
+ patternId: context.checkId,
106
+ reason: match.reason ?? "",
107
+ proof: match.proof ?? "",
108
+ }];
109
+ });
110
+ }
111
+
112
+ async function runJsonPrompt(
113
+ model: LocalModel,
114
+ prompt: string,
115
+ schema: unknown,
116
+ temperature: number,
117
+ ): Promise<unknown> {
118
+ return cachedJson(
119
+ "model-json",
120
+ fingerprint({
121
+ version: 1,
122
+ modelId: model.id,
123
+ profile: model.profile,
124
+ prompt,
125
+ schema,
126
+ temperature,
127
+ }),
128
+ () => runJsonPromptUncached(model, prompt, schema, temperature),
129
+ );
130
+ }
131
+
132
+ async function runJsonPromptUncached(
133
+ model: LocalModel,
134
+ prompt: string,
135
+ schema: unknown,
136
+ temperature: number,
137
+ ): Promise<unknown> {
138
+ const first = await complete(model, prompt, schema, temperature);
139
+ const parsed = parseJson(first);
140
+ if (parsed.ok) return parsed.value;
141
+
142
+ const retry = await complete(model, `${prompt}
143
+
144
+ Your previous response was not valid JSON. Return the requested JSON object only.`, schema, temperature);
145
+ const retryParsed = parseJson(retry);
146
+ if (retryParsed.ok) return retryParsed.value;
147
+
148
+ console.error("Raw model output:");
149
+ console.error(retry);
150
+ throw new Error("Model returned invalid JSON.");
151
+ }
152
+
153
+ async function complete(
154
+ model: LocalModel,
155
+ prompt: string,
156
+ schema: unknown,
157
+ temperature: number,
158
+ ): Promise<string> {
159
+ const response = await fetch(`${model.baseUrl}/v1/chat/completions`, {
160
+ method: "POST",
161
+ headers: { "content-type": "application/json" },
162
+ body: JSON.stringify({
163
+ model: model.id,
164
+ messages: [{ role: "user", content: prompt }],
165
+ temperature,
166
+ response_format: {
167
+ type: "json_object",
168
+ schema,
169
+ },
170
+ }),
171
+ });
172
+
173
+ if (!response.ok) throw new Error(`llama-server request failed: HTTP ${response.status} ${await response.text()}`);
174
+
175
+ const body = await response.json() as { choices?: Array<{ message?: { content?: unknown } }> };
176
+ const content = body.choices?.[0]?.message?.content;
177
+ if (typeof content !== "string") throw new Error("llama-server returned no message content.");
178
+ return content;
179
+ }
180
+
181
+ function parseJson(raw: string): Readonly<{ ok: true; value: unknown }> | Readonly<{ ok: false }> {
182
+ try {
183
+ const value = JSON.parse(raw);
184
+ return { ok: true, value };
185
+ } catch {
186
+ return { ok: false };
187
+ }
188
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, platform } from "node:os";
4
+ import path from "node:path";
5
+
6
+ export function fingerprint(value: unknown): string {
7
+ const text = typeof value === "string" ? value : stableStringify(value);
8
+ return createHash("sha256").update(text).digest("hex");
9
+ }
10
+
11
+ export async function cachedJson<T>(
12
+ namespace: string,
13
+ key: string,
14
+ compute: () => Promise<T>,
15
+ ): Promise<T> {
16
+ const filePath = cachePath(namespace, key);
17
+ try {
18
+ const value = JSON.parse(await readFile(filePath, "utf8")) as T;
19
+ console.error(`cache hit ${namespace} ${key.slice(0, 12)}`);
20
+ return value;
21
+ } catch {
22
+ console.error(`cache miss ${namespace} ${key.slice(0, 12)}`);
23
+ }
24
+
25
+ const value = await compute();
26
+ await writeCache(filePath, value).catch(() => undefined);
27
+ return value;
28
+ }
29
+
30
+ function cachePath(namespace: string, key: string): string {
31
+ return path.join(cacheRoot(), "intermediate-v1", safeNamespace(namespace), `${key}.json`);
32
+ }
33
+
34
+ async function writeCache(filePath: string, value: unknown): Promise<void> {
35
+ await mkdir(path.dirname(filePath), { recursive: true });
36
+ const tempPath = `${filePath}.${process.pid}.tmp`;
37
+ try {
38
+ await writeFile(tempPath, JSON.stringify(value), "utf8");
39
+ await rename(tempPath, filePath);
40
+ } catch (error) {
41
+ await rm(tempPath, { force: true });
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ function cacheRoot(): string {
47
+ if (process.env.STUPIFY_CACHE_DIR) return process.env.STUPIFY_CACHE_DIR;
48
+ if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, "stupify");
49
+ if (platform() === "darwin") return path.join(homedir(), "Library", "Caches", "stupify");
50
+ if (platform() === "win32" && process.env.LOCALAPPDATA) return path.join(process.env.LOCALAPPDATA, "stupify", "Cache");
51
+ return path.join(homedir(), ".cache", "stupify");
52
+ }
53
+
54
+ function safeNamespace(value: string): string {
55
+ return value.replace(/[^a-zA-Z0-9._-]/g, "_");
56
+ }
57
+
58
+ function stableStringify(value: unknown): string {
59
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
60
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
61
+ const record = value as Record<string, unknown>;
62
+ return `{${Object.keys(record).sort().map((key) =>
63
+ `${JSON.stringify(key)}:${stableStringify(record[key])}`
64
+ ).join(",")}}`;
65
+ }