@mainahq/core 0.3.0 → 0.5.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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Cloud HTTP client.
3
+ *
4
+ * Provides authenticated access to the maina cloud API for prompt sync,
5
+ * team management, and feedback reporting. All methods return Result<T, string>.
6
+ */
7
+
8
+ import type { Result } from "../db/index";
9
+ import type {
10
+ ApiResponse,
11
+ CloudConfig,
12
+ CloudFeedbackPayload,
13
+ PromptRecord,
14
+ TeamInfo,
15
+ TeamMember,
16
+ } from "./types";
17
+
18
+ // ── Helpers ─────────────────────────────────────────────────────────────────
19
+
20
+ function ok<T>(value: T): Result<T, string> {
21
+ return { ok: true, value };
22
+ }
23
+
24
+ function err(error: string): Result<never, string> {
25
+ return { ok: false, error };
26
+ }
27
+
28
+ const DEFAULT_TIMEOUT_MS = 10_000;
29
+ const DEFAULT_MAX_RETRIES = 3;
30
+ const INITIAL_BACKOFF_MS = 500;
31
+
32
+ /** Status codes that trigger a retry. */
33
+ function isRetryable(status: number): boolean {
34
+ return status === 429 || status >= 500;
35
+ }
36
+
37
+ /**
38
+ * Sleep for the given number of milliseconds.
39
+ * Extracted so tests can verify backoff behaviour.
40
+ */
41
+ function sleep(ms: number): Promise<void> {
42
+ return new Promise((resolve) => setTimeout(resolve, ms));
43
+ }
44
+
45
+ // ── Cloud Client ────────────────────────────────────────────────────────────
46
+
47
+ export interface CloudClient {
48
+ /** Check API availability. */
49
+ health(): Promise<Result<{ status: string }, string>>;
50
+
51
+ /** Download team prompts. */
52
+ getPrompts(): Promise<Result<PromptRecord[], string>>;
53
+
54
+ /** Upload local prompts. */
55
+ putPrompts(prompts: PromptRecord[]): Promise<Result<void, string>>;
56
+
57
+ /** Fetch team information. */
58
+ getTeam(): Promise<Result<TeamInfo, string>>;
59
+
60
+ /** List team members. */
61
+ getTeamMembers(): Promise<Result<TeamMember[], string>>;
62
+
63
+ /** Invite a new member by email. */
64
+ inviteTeamMember(
65
+ email: string,
66
+ role?: "admin" | "member",
67
+ ): Promise<Result<{ invited: boolean }, string>>;
68
+
69
+ /** Report prompt feedback to the cloud. */
70
+ postFeedback(
71
+ payload: CloudFeedbackPayload,
72
+ ): Promise<Result<{ recorded: boolean }, string>>;
73
+ }
74
+
75
+ /**
76
+ * Create a cloud API client.
77
+ *
78
+ * Every request attaches `Authorization: Bearer <token>` when a token is
79
+ * present in the config. Transient failures (429, 5xx) are retried up to
80
+ * `maxRetries` times with exponential backoff.
81
+ */
82
+ export function createCloudClient(config: CloudConfig): CloudClient {
83
+ const {
84
+ baseUrl,
85
+ token,
86
+ timeoutMs = DEFAULT_TIMEOUT_MS,
87
+ maxRetries = DEFAULT_MAX_RETRIES,
88
+ } = config;
89
+
90
+ /** Build standard headers. */
91
+ function headers(): Record<string, string> {
92
+ const h: Record<string, string> = {
93
+ "Content-Type": "application/json",
94
+ Accept: "application/json",
95
+ };
96
+ if (token) {
97
+ h.Authorization = `Bearer ${token}`;
98
+ }
99
+ return h;
100
+ }
101
+
102
+ /** Make an HTTP request with retry + timeout. */
103
+ async function request<T>(
104
+ method: string,
105
+ path: string,
106
+ body?: unknown,
107
+ ): Promise<Result<T, string>> {
108
+ let lastError = "Unknown error";
109
+
110
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
111
+ if (attempt > 0) {
112
+ const backoff = INITIAL_BACKOFF_MS * 2 ** (attempt - 1);
113
+ await sleep(backoff);
114
+ }
115
+
116
+ try {
117
+ const controller = new AbortController();
118
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
119
+
120
+ const response = await fetch(`${baseUrl}${path}`, {
121
+ method,
122
+ headers: headers(),
123
+ body: body ? JSON.stringify(body) : undefined,
124
+ signal: controller.signal,
125
+ });
126
+
127
+ clearTimeout(timer);
128
+
129
+ if (isRetryable(response.status) && attempt < maxRetries) {
130
+ lastError = `HTTP ${response.status}`;
131
+ continue;
132
+ }
133
+
134
+ if (!response.ok) {
135
+ const text = await response.text();
136
+ let message: string;
137
+ try {
138
+ const parsed = JSON.parse(text) as ApiResponse<unknown>;
139
+ message = parsed.error ?? `HTTP ${response.status}`;
140
+ } catch {
141
+ message = text || `HTTP ${response.status}`;
142
+ }
143
+ return err(message);
144
+ }
145
+
146
+ // 204 No Content — return void-compatible
147
+ if (response.status === 204) {
148
+ return ok(undefined as unknown as T);
149
+ }
150
+
151
+ const json = (await response.json()) as ApiResponse<T>;
152
+ if (json.error) {
153
+ return err(json.error);
154
+ }
155
+ return ok(json.data as T);
156
+ } catch (e) {
157
+ if (
158
+ e instanceof DOMException &&
159
+ e.name === "AbortError" &&
160
+ attempt < maxRetries
161
+ ) {
162
+ lastError = "Request timed out";
163
+ continue;
164
+ }
165
+ lastError = e instanceof Error ? e.message : String(e);
166
+ // Transient network errors are retried via the outer loop
167
+ }
168
+ }
169
+
170
+ return err(`Request failed after ${maxRetries + 1} attempts: ${lastError}`);
171
+ }
172
+
173
+ return {
174
+ health: () => request<{ status: string }>("GET", "/health"),
175
+
176
+ getPrompts: () => request<PromptRecord[]>("GET", "/prompts"),
177
+
178
+ putPrompts: (prompts) => request<void>("PUT", "/prompts", { prompts }),
179
+
180
+ getTeam: () => request<TeamInfo>("GET", "/team"),
181
+
182
+ getTeamMembers: () => request<TeamMember[]>("GET", "/team/members"),
183
+
184
+ inviteTeamMember: (email, role = "member") =>
185
+ request<{ invited: boolean }>("POST", "/team/invite", { email, role }),
186
+
187
+ postFeedback: (payload) =>
188
+ request<{ recorded: boolean }>("POST", "/feedback", payload),
189
+ };
190
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Cloud client shared types.
3
+ *
4
+ * Used by the cloud HTTP client, auth module, and CLI commands
5
+ * for syncing prompts, team management, and device-flow auth.
6
+ */
7
+
8
+ // ── Configuration ───────────────────────────────────────────────────────────
9
+
10
+ export interface CloudConfig {
11
+ /** Base URL of the maina cloud API (e.g. "https://api.maina.dev"). */
12
+ baseUrl: string;
13
+ /** Bearer token for authenticated requests. */
14
+ token?: string;
15
+ /** Request timeout in milliseconds. Default: 10_000. */
16
+ timeoutMs?: number;
17
+ /** Maximum retry attempts for transient failures. Default: 3. */
18
+ maxRetries?: number;
19
+ }
20
+
21
+ // ── Prompts ─────────────────────────────────────────────────────────────────
22
+
23
+ export interface PromptRecord {
24
+ /** Unique prompt identifier. */
25
+ id: string;
26
+ /** File path relative to .maina/prompts/ (e.g. "commit.md"). */
27
+ path: string;
28
+ /** Full prompt content (markdown). */
29
+ content: string;
30
+ /** SHA-256 hash of the content for change detection. */
31
+ hash: string;
32
+ /** ISO-8601 timestamp of last modification. */
33
+ updatedAt: string;
34
+ }
35
+
36
+ // ── Team ────────────────────────────────────────────────────────────────────
37
+
38
+ export interface TeamInfo {
39
+ /** Team identifier. */
40
+ id: string;
41
+ /** Display name. */
42
+ name: string;
43
+ /** Current billing plan. */
44
+ plan: string;
45
+ /** Number of seats used / total. */
46
+ seats: { used: number; total: number };
47
+ }
48
+
49
+ export interface TeamMember {
50
+ /** User email. */
51
+ email: string;
52
+ /** Role within the team. */
53
+ role: "owner" | "admin" | "member";
54
+ /** ISO-8601 join date. */
55
+ joinedAt: string;
56
+ }
57
+
58
+ // ── Device-Code OAuth ───────────────────────────────────────────────────────
59
+
60
+ export interface DeviceCodeResponse {
61
+ /** The code the user enters on the verification page. */
62
+ userCode: string;
63
+ /** The device code used to poll for token completion. */
64
+ deviceCode: string;
65
+ /** URL the user should visit. */
66
+ verificationUri: string;
67
+ /** Polling interval in seconds. */
68
+ interval: number;
69
+ /** Seconds until the device code expires. */
70
+ expiresIn: number;
71
+ }
72
+
73
+ export interface TokenResponse {
74
+ /** Bearer access token. */
75
+ accessToken: string;
76
+ /** Refresh token (if applicable). */
77
+ refreshToken?: string;
78
+ /** Seconds until the access token expires. */
79
+ expiresIn: number;
80
+ }
81
+
82
+ // ── API Envelope ────────────────────────────────────────────────────────────
83
+
84
+ export interface ApiResponse<T> {
85
+ /** Response payload (present on success). */
86
+ data?: T;
87
+ /** Error message (present on failure). */
88
+ error?: string;
89
+ /** Optional metadata. */
90
+ meta?: Record<string, unknown>;
91
+ }
92
+
93
+ // ── Feedback ────────────────────────────────────────────────────────────────
94
+
95
+ export interface CloudFeedbackPayload {
96
+ /** Prompt hash the feedback refers to. */
97
+ promptHash: string;
98
+ /** Command that generated the output. */
99
+ command: string;
100
+ /** Whether the user accepted the output. */
101
+ accepted: boolean;
102
+ /** ISO-8601 timestamp. */
103
+ timestamp: string;
104
+ /** Optional context about the feedback. */
105
+ context?: string;
106
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Tests for post-workflow RL trace analysis.
3
+ *
4
+ * Verifies that analyzeWorkflowTrace() reads workflow context,
5
+ * correlates with feedback data, and generates prompt improvements.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ describe("analyzeWorkflowTrace", () => {
14
+ let mainaDir: string;
15
+
16
+ beforeEach(() => {
17
+ mainaDir = join(tmpdir(), `maina-trace-test-${Date.now()}`);
18
+ mkdirSync(join(mainaDir, "workflow"), { recursive: true });
19
+ mkdirSync(join(mainaDir, "prompts"), { recursive: true });
20
+ });
21
+
22
+ afterEach(() => {
23
+ rmSync(mainaDir, { recursive: true, force: true });
24
+ });
25
+
26
+ it("should parse workflow context into trace steps", async () => {
27
+ writeFileSync(
28
+ join(mainaDir, "workflow", "current.md"),
29
+ [
30
+ "# Workflow: feature/test",
31
+ "",
32
+ "## plan (2026-04-05T10:00:00.000Z)",
33
+ "Feature scaffolded.",
34
+ "",
35
+ "## commit (2026-04-05T10:05:00.000Z)",
36
+ "Verified: 8 tools, 0 findings. Committed.",
37
+ ].join("\n"),
38
+ );
39
+
40
+ const { analyzeWorkflowTrace } = await import("../trace-analysis");
41
+ const result = await analyzeWorkflowTrace(mainaDir);
42
+
43
+ expect(result.steps).toHaveLength(2);
44
+ expect(result.steps[0]?.command).toBe("plan");
45
+ expect(result.steps[1]?.command).toBe("commit");
46
+ });
47
+
48
+ it("should return empty improvements when no workflow exists", async () => {
49
+ rmSync(join(mainaDir, "workflow"), { recursive: true, force: true });
50
+
51
+ const { analyzeWorkflowTrace } = await import("../trace-analysis");
52
+ const result = await analyzeWorkflowTrace(mainaDir);
53
+
54
+ expect(result.steps).toEqual([]);
55
+ expect(result.improvements).toEqual([]);
56
+ });
57
+
58
+ it("should generate improvements from trace patterns", async () => {
59
+ writeFileSync(
60
+ join(mainaDir, "workflow", "current.md"),
61
+ [
62
+ "# Workflow: feature/test",
63
+ "",
64
+ "## commit (2026-04-05T10:00:00.000Z)",
65
+ "Verified: 8 tools, 2 findings. Committed.",
66
+ "",
67
+ "## commit (2026-04-05T10:05:00.000Z)",
68
+ "Verified: 8 tools, 3 findings. Committed.",
69
+ "",
70
+ "## commit (2026-04-05T10:10:00.000Z)",
71
+ "Verified: 8 tools, 0 findings. Committed.",
72
+ ].join("\n"),
73
+ );
74
+
75
+ const { analyzeWorkflowTrace } = await import("../trace-analysis");
76
+ const result = await analyzeWorkflowTrace(mainaDir);
77
+
78
+ expect(result.steps).toHaveLength(3);
79
+ // Should detect that early commits had findings, suggesting improvement
80
+ expect(typeof result.summary).toBe("string");
81
+ });
82
+
83
+ it("should return TraceResult with correct shape", async () => {
84
+ writeFileSync(
85
+ join(mainaDir, "workflow", "current.md"),
86
+ "# Workflow: test\n",
87
+ );
88
+
89
+ const { analyzeWorkflowTrace } = await import("../trace-analysis");
90
+ const result = await analyzeWorkflowTrace(mainaDir);
91
+
92
+ expect(result).toHaveProperty("steps");
93
+ expect(result).toHaveProperty("improvements");
94
+ expect(result).toHaveProperty("summary");
95
+ expect(Array.isArray(result.steps)).toBe(true);
96
+ expect(Array.isArray(result.improvements)).toBe(true);
97
+ });
98
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Post-workflow RL Trace Analysis — analyzes completed workflow traces
3
+ * to propose prompt improvements.
4
+ *
5
+ * After a full workflow completes (brainstorm → ... → pr):
6
+ * 1. Collects the full trace from workflow context
7
+ * 2. Analyzes: which steps had issues? How did findings trend?
8
+ * 3. Proposes prompt improvements based on patterns
9
+ * 4. Feeds into maina learn automatically
10
+ */
11
+
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ // ─── Types ────────────────────────────────────────────────────────────────
16
+
17
+ export interface TraceStep {
18
+ command: string;
19
+ timestamp: string;
20
+ summary: string;
21
+ findingsCount?: number;
22
+ }
23
+
24
+ export interface PromptImprovement {
25
+ promptFile: string;
26
+ reason: string;
27
+ suggestion: string;
28
+ confidence: number;
29
+ }
30
+
31
+ export interface TraceResult {
32
+ steps: TraceStep[];
33
+ improvements: PromptImprovement[];
34
+ summary: string;
35
+ }
36
+
37
+ // ─── Trace Parsing ───────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Parse the workflow context file into structured trace steps.
41
+ */
42
+ function parseWorkflowContext(content: string): TraceStep[] {
43
+ const steps: TraceStep[] = [];
44
+ const stepPattern =
45
+ /^## (\w+) \((\d{4}-\d{2}-\d{2}T[\d:.]+Z)\)\s*\n([\s\S]*?)(?=\n## |\n*$)/gm;
46
+
47
+ for (const match of content.matchAll(stepPattern)) {
48
+ const command = match[1] ?? "";
49
+ const timestamp = match[2] ?? "";
50
+ const summary = (match[3] ?? "").trim();
51
+
52
+ // Extract findings count if present
53
+ const findingsMatch = summary.match(/(\d+)\s+findings?/);
54
+ const findingsCount = findingsMatch
55
+ ? Number.parseInt(findingsMatch[1] ?? "0", 10)
56
+ : undefined;
57
+
58
+ steps.push({ command, timestamp, summary, findingsCount });
59
+ }
60
+
61
+ return steps;
62
+ }
63
+
64
+ // ─── Analysis ────────────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Analyze trace steps for patterns that suggest prompt improvements.
68
+ */
69
+ function analyzePatterns(steps: TraceStep[]): PromptImprovement[] {
70
+ const improvements: PromptImprovement[] = [];
71
+
72
+ // Pattern 1: Multiple commits with findings before a clean one
73
+ const commitSteps = steps.filter(
74
+ (s) => s.command === "commit" && s.findingsCount !== undefined,
75
+ );
76
+ const dirtyCommits = commitSteps.filter(
77
+ (s) => s.findingsCount !== undefined && s.findingsCount > 0,
78
+ );
79
+
80
+ if (dirtyCommits.length >= 2) {
81
+ improvements.push({
82
+ promptFile: "prompts/review.md",
83
+ reason: `${dirtyCommits.length} commits had verification findings before clean pass — review prompt may need to catch these patterns earlier.`,
84
+ suggestion:
85
+ "Add examples of common finding patterns to the review prompt so AI catches them in the first pass.",
86
+ confidence: 0.6,
87
+ });
88
+ }
89
+
90
+ // Pattern 2: Workflow has no review step
91
+ const hasReview = steps.some((s) => s.command === "review");
92
+ if (steps.length >= 3 && !hasReview) {
93
+ improvements.push({
94
+ promptFile: "prompts/commit.md",
95
+ reason:
96
+ "Workflow completed without a review step — commit prompt could remind about review.",
97
+ suggestion:
98
+ "Add a reminder to run maina review before committing when changes are substantial.",
99
+ confidence: 0.4,
100
+ });
101
+ }
102
+
103
+ return improvements;
104
+ }
105
+
106
+ /**
107
+ * Generate a human-readable summary of the trace analysis.
108
+ */
109
+ function generateSummary(
110
+ steps: TraceStep[],
111
+ improvements: PromptImprovement[],
112
+ ): string {
113
+ if (steps.length === 0) return "No workflow trace found.";
114
+
115
+ const commands = steps.map((s) => s.command).join(" → ");
116
+ const totalFindings = steps
117
+ .filter((s) => s.findingsCount !== undefined)
118
+ .reduce((sum, s) => sum + (s.findingsCount ?? 0), 0);
119
+
120
+ let summary = `Workflow: ${commands} (${steps.length} steps, ${totalFindings} total findings)`;
121
+
122
+ if (improvements.length > 0) {
123
+ summary += `\n${improvements.length} improvement(s) suggested.`;
124
+ }
125
+
126
+ return summary;
127
+ }
128
+
129
+ // ─── Main ────────────────────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Analyze the current workflow trace and generate improvement proposals.
133
+ *
134
+ * This runs after maina pr completes. It reads the workflow context,
135
+ * correlates with feedback data, and proposes prompt improvements
136
+ * that are automatically fed into maina learn.
137
+ */
138
+ export async function analyzeWorkflowTrace(
139
+ mainaDir: string,
140
+ ): Promise<TraceResult> {
141
+ const workflowFile = join(mainaDir, "workflow", "current.md");
142
+
143
+ if (!existsSync(workflowFile)) {
144
+ return { steps: [], improvements: [], summary: "No workflow trace found." };
145
+ }
146
+
147
+ const content = readFileSync(workflowFile, "utf-8");
148
+ const steps = parseWorkflowContext(content);
149
+ const improvements = analyzePatterns(steps);
150
+ const summary = generateSummary(steps, improvements);
151
+
152
+ return { steps, improvements, summary };
153
+ }
package/src/index.ts CHANGED
@@ -50,6 +50,26 @@ export {
50
50
  type CacheStats,
51
51
  createCacheManager,
52
52
  } from "./cache/manager";
53
+ // Cloud
54
+ export {
55
+ type AuthConfig,
56
+ clearAuthConfig,
57
+ loadAuthConfig,
58
+ pollForToken,
59
+ saveAuthConfig,
60
+ startDeviceFlow,
61
+ } from "./cloud/auth";
62
+ export { type CloudClient, createCloudClient } from "./cloud/client";
63
+ export type {
64
+ ApiResponse,
65
+ CloudConfig,
66
+ CloudFeedbackPayload,
67
+ DeviceCodeResponse,
68
+ PromptRecord,
69
+ TeamInfo,
70
+ TeamMember,
71
+ TokenResponse,
72
+ } from "./cloud/types";
53
73
  // Config
54
74
  export { getApiKey, isHostMode, shouldDelegateToHost } from "./config/index";
55
75
  export { calculateTokens } from "./context/budget";
@@ -144,6 +164,12 @@ export {
144
164
  type RulePreference,
145
165
  savePreferences,
146
166
  } from "./feedback/preferences";
167
+ export {
168
+ analyzeWorkflowTrace,
169
+ type PromptImprovement,
170
+ type TraceResult,
171
+ type TraceStep,
172
+ } from "./feedback/trace-analysis";
147
173
  // Git
148
174
  export {
149
175
  type Commit,
@@ -268,6 +294,10 @@ export {
268
294
  resolveReferencedFunctions,
269
295
  runAIReview,
270
296
  } from "./verify/ai-review";
297
+ export {
298
+ type ConsistencyResult,
299
+ checkConsistency,
300
+ } from "./verify/consistency";
271
301
  // Verify — Coverage
272
302
  export {
273
303
  type CoverageOptions,
@@ -298,6 +328,13 @@ export {
298
328
  hashFinding,
299
329
  parseFixResponse,
300
330
  } from "./verify/fix";
331
+ // Verify — Lighthouse
332
+ export {
333
+ type LighthouseOptions,
334
+ type LighthouseResult,
335
+ parseLighthouseJson,
336
+ runLighthouse,
337
+ } from "./verify/lighthouse";
301
338
  // Verify — Mutation
302
339
  export {
303
340
  type MutationOptions,
@@ -343,6 +380,8 @@ export {
343
380
  type SyntaxGuardResult,
344
381
  syntaxGuard,
345
382
  } from "./verify/syntax-guard";
383
+ // Verify — Typecheck + Consistency (built-in checks)
384
+ export { runTypecheck, type TypecheckResult } from "./verify/typecheck";
346
385
  // Verify — Visual
347
386
  export {
348
387
  captureScreenshot,
@@ -357,6 +396,13 @@ export {
357
396
  type VisualDiffResult,
358
397
  type VisualVerifyResult,
359
398
  } from "./verify/visual";
399
+ // Verify — ZAP DAST
400
+ export {
401
+ parseZapJson,
402
+ runZap,
403
+ type ZapOptions,
404
+ type ZapResult,
405
+ } from "./verify/zap";
360
406
  // Workflow
361
407
  export {
362
408
  appendWorkflowStep,
@@ -225,4 +225,55 @@ describe("bootstrap", () => {
225
225
  }
226
226
  }
227
227
  });
228
+
229
+ test("auto-configures biome.json when no linter detected", async () => {
230
+ // Project with no linter in dependencies
231
+ writeFileSync(
232
+ join(tmpDir, "package.json"),
233
+ JSON.stringify({ dependencies: {} }),
234
+ );
235
+
236
+ const result = await bootstrap(tmpDir);
237
+ expect(result.ok).toBe(true);
238
+ if (result.ok) {
239
+ const biomePath = join(tmpDir, "biome.json");
240
+ expect(existsSync(biomePath)).toBe(true);
241
+
242
+ const biomeConfig = JSON.parse(readFileSync(biomePath, "utf-8"));
243
+ expect(biomeConfig.linter.enabled).toBe(true);
244
+ expect(biomeConfig.linter.rules.recommended).toBe(true);
245
+ expect(biomeConfig.formatter.enabled).toBe(true);
246
+
247
+ expect(result.value.created).toContain("biome.json");
248
+ expect(result.value.detectedStack.linter).toBe("biome");
249
+ }
250
+ });
251
+
252
+ test("does not overwrite existing biome.json", async () => {
253
+ writeFileSync(
254
+ join(tmpDir, "package.json"),
255
+ JSON.stringify({ dependencies: {} }),
256
+ );
257
+ writeFileSync(join(tmpDir, "biome.json"), '{"custom": true}');
258
+
259
+ const result = await bootstrap(tmpDir);
260
+ expect(result.ok).toBe(true);
261
+
262
+ const content = readFileSync(join(tmpDir, "biome.json"), "utf-8");
263
+ expect(JSON.parse(content)).toEqual({ custom: true });
264
+ });
265
+
266
+ test("skips biome.json when linter already detected", async () => {
267
+ writeFileSync(
268
+ join(tmpDir, "package.json"),
269
+ JSON.stringify({ devDependencies: { eslint: "^9.0.0" } }),
270
+ );
271
+
272
+ const result = await bootstrap(tmpDir);
273
+ expect(result.ok).toBe(true);
274
+ if (result.ok) {
275
+ expect(existsSync(join(tmpDir, "biome.json"))).toBe(false);
276
+ expect(result.value.detectedStack.linter).toBe("eslint");
277
+ }
278
+ });
228
279
  });