@mainahq/core 1.0.1 → 1.0.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mainahq/core",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Maina core engines — Context, Prompt, and Verify for verification-first development",
@@ -0,0 +1,131 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { checkAIAvailability } from "../availability";
3
+
4
+ describe("checkAIAvailability", () => {
5
+ const originalEnv = { ...process.env };
6
+
7
+ beforeEach(() => {
8
+ // Clear all relevant env vars before each test
9
+ delete process.env.MAINA_API_KEY;
10
+ delete process.env.OPENROUTER_API_KEY;
11
+ delete process.env.ANTHROPIC_API_KEY;
12
+ delete process.env.MAINA_HOST_MODE;
13
+ delete process.env.CLAUDECODE;
14
+ delete process.env.CLAUDE_CODE_ENTRYPOINT;
15
+ delete process.env.CURSOR;
16
+ });
17
+
18
+ afterEach(() => {
19
+ // Restore original env
20
+ for (const key of [
21
+ "MAINA_API_KEY",
22
+ "OPENROUTER_API_KEY",
23
+ "ANTHROPIC_API_KEY",
24
+ "MAINA_HOST_MODE",
25
+ "CLAUDECODE",
26
+ "CLAUDE_CODE_ENTRYPOINT",
27
+ "CURSOR",
28
+ ]) {
29
+ if (originalEnv[key] !== undefined) {
30
+ process.env[key] = originalEnv[key];
31
+ } else {
32
+ delete process.env[key];
33
+ }
34
+ }
35
+ });
36
+
37
+ it("returns api-key method when MAINA_API_KEY is set", () => {
38
+ process.env.MAINA_API_KEY = "test-key-123";
39
+
40
+ const result = checkAIAvailability();
41
+
42
+ expect(result.available).toBe(true);
43
+ expect(result.method).toBe("api-key");
44
+ expect(result.reason).toBeUndefined();
45
+ });
46
+
47
+ it("returns api-key method when OPENROUTER_API_KEY is set", () => {
48
+ process.env.OPENROUTER_API_KEY = "or-key-456";
49
+
50
+ const result = checkAIAvailability();
51
+
52
+ expect(result.available).toBe(true);
53
+ expect(result.method).toBe("api-key");
54
+ expect(result.reason).toBeUndefined();
55
+ });
56
+
57
+ it("returns api-key method when ANTHROPIC_API_KEY is set", () => {
58
+ process.env.ANTHROPIC_API_KEY = "sk-ant-test";
59
+
60
+ const result = checkAIAvailability();
61
+
62
+ expect(result.available).toBe(true);
63
+ expect(result.method).toBe("api-key");
64
+ expect(result.reason).toBeUndefined();
65
+ });
66
+
67
+ it("returns host-delegation when CLAUDECODE env is set", () => {
68
+ process.env.CLAUDECODE = "1";
69
+
70
+ const result = checkAIAvailability();
71
+
72
+ expect(result.available).toBe(true);
73
+ expect(result.method).toBe("host-delegation");
74
+ expect(result.reason).toBeUndefined();
75
+ });
76
+
77
+ it("returns host-delegation when CLAUDE_CODE_ENTRYPOINT is set", () => {
78
+ process.env.CLAUDE_CODE_ENTRYPOINT = "cli";
79
+
80
+ const result = checkAIAvailability();
81
+
82
+ expect(result.available).toBe(true);
83
+ expect(result.method).toBe("host-delegation");
84
+ expect(result.reason).toBeUndefined();
85
+ });
86
+
87
+ it("returns host-delegation when CURSOR is set", () => {
88
+ process.env.CURSOR = "1";
89
+
90
+ const result = checkAIAvailability();
91
+
92
+ expect(result.available).toBe(true);
93
+ expect(result.method).toBe("host-delegation");
94
+ expect(result.reason).toBeUndefined();
95
+ });
96
+
97
+ it("returns host-delegation when MAINA_HOST_MODE is true", () => {
98
+ process.env.MAINA_HOST_MODE = "true";
99
+
100
+ const result = checkAIAvailability();
101
+
102
+ expect(result.available).toBe(true);
103
+ expect(result.method).toBe("host-delegation");
104
+ expect(result.reason).toBeUndefined();
105
+ });
106
+
107
+ it("returns none when no key and no host environment", () => {
108
+ const result = checkAIAvailability();
109
+
110
+ expect(result.available).toBe(false);
111
+ expect(result.method).toBe("none");
112
+ });
113
+
114
+ it("includes a reason message when method is none", () => {
115
+ const result = checkAIAvailability();
116
+
117
+ expect(result.reason).toBeDefined();
118
+ expect(result.reason).toContain("No API key found");
119
+ expect(result.reason).toContain("maina init");
120
+ });
121
+
122
+ it("prefers api-key over host-delegation when both available", () => {
123
+ process.env.MAINA_API_KEY = "test-key";
124
+ process.env.CLAUDECODE = "1";
125
+
126
+ const result = checkAIAvailability();
127
+
128
+ expect(result.available).toBe(true);
129
+ expect(result.method).toBe("api-key");
130
+ });
131
+ });
@@ -0,0 +1,23 @@
1
+ import { getApiKey, isHostMode } from "../config/index";
2
+
3
+ export interface AIAvailability {
4
+ available: boolean;
5
+ method: "api-key" | "host-delegation" | "none";
6
+ reason?: string;
7
+ }
8
+
9
+ export function checkAIAvailability(): AIAvailability {
10
+ const apiKey = getApiKey();
11
+ if (apiKey !== null) {
12
+ return { available: true, method: "api-key" };
13
+ }
14
+ if (isHostMode()) {
15
+ return { available: true, method: "host-delegation" };
16
+ }
17
+ return {
18
+ available: false,
19
+ method: "none",
20
+ reason:
21
+ "No API key found and not running inside an AI agent. Run `maina init` to set up or run inside Claude Code/Cursor.",
22
+ };
23
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export const VERSION = "0.1.0";
2
2
 
3
3
  // AI
4
+ export { type AIAvailability, checkAIAvailability } from "./ai/availability";
4
5
  export { generateCommitMessage } from "./ai/commit-msg";
5
6
  // AI — Delegation
6
7
  export {
@@ -99,8 +100,9 @@ export {
99
100
  setVerificationResult,
100
101
  trackFile,
101
102
  } from "./context/working";
102
- // DB (Result type)
103
+ // DB
103
104
  export type { Result } from "./db/index";
105
+ export { getFeedbackDb } from "./db/index";
104
106
  // Design (ADR)
105
107
  export {
106
108
  type AdrSummary,
@@ -211,6 +213,7 @@ export {
211
213
  // Init
212
214
  export {
213
215
  bootstrap,
216
+ buildMainaSection,
214
217
  type DetectedStack,
215
218
  type InitOptions,
216
219
  type InitReport,
@@ -131,29 +131,21 @@ describe("bootstrap", () => {
131
131
  expect(existsSync(hooksDir)).toBe(true);
132
132
  });
133
133
 
134
- test("skips existing files (no overwrite)", async () => {
134
+ test("skips existing non-agent files (no overwrite)", async () => {
135
135
  // Pre-create constitution.md with custom content
136
136
  const mainaDir = join(tmpDir, ".maina");
137
137
  mkdirSync(mainaDir, { recursive: true });
138
138
  const constitutionPath = join(mainaDir, "constitution.md");
139
139
  writeFileSync(constitutionPath, "# My Custom Constitution\n");
140
140
 
141
- // Pre-create AGENTS.md with custom content
142
- const agentsPath = join(tmpDir, "AGENTS.md");
143
- writeFileSync(agentsPath, "# My Custom Agents\n");
144
-
145
141
  const result = await bootstrap(tmpDir);
146
142
  expect(result.ok).toBe(true);
147
143
  if (result.ok) {
148
144
  expect(result.value.skipped).toContain(".maina/constitution.md");
149
- expect(result.value.skipped).toContain("AGENTS.md");
150
145
 
151
146
  // Content should NOT have been overwritten
152
147
  const content = readFileSync(constitutionPath, "utf-8");
153
148
  expect(content).toBe("# My Custom Constitution\n");
154
-
155
- const agentsContent = readFileSync(agentsPath, "utf-8");
156
- expect(agentsContent).toBe("# My Custom Agents\n");
157
149
  }
158
150
  });
159
151
 
@@ -195,8 +187,11 @@ describe("bootstrap", () => {
195
187
  expect(result.value.created).toContain(".maina/prompts/review.md");
196
188
  expect(result.value.created).toContain(".maina/prompts/commit.md");
197
189
 
198
- // Total files should add up
199
- const total = result.value.created.length + result.value.skipped.length;
190
+ // Total files should add up (created + skipped + updated)
191
+ const total =
192
+ result.value.created.length +
193
+ result.value.skipped.length +
194
+ result.value.updated.length;
200
195
  expect(total).toBeGreaterThanOrEqual(5);
201
196
  }
202
197
  });
@@ -351,22 +346,89 @@ describe("bootstrap", () => {
351
346
  expect(content).toContain("maina verify");
352
347
  });
353
348
 
354
- test("does not overwrite existing agent files", async () => {
349
+ test("merges maina section into existing agent files without ## Maina", async () => {
355
350
  writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Custom CLAUDE.md\n");
356
351
  writeFileSync(join(tmpDir, "GEMINI.md"), "# My Custom GEMINI.md\n");
357
352
  writeFileSync(join(tmpDir, ".cursorrules"), "# My Custom Rules\n");
358
353
 
354
+ const result = await bootstrap(tmpDir);
355
+ expect(result.ok).toBe(true);
356
+ if (result.ok) {
357
+ expect(result.value.updated).toContain("CLAUDE.md");
358
+ expect(result.value.updated).toContain("GEMINI.md");
359
+ expect(result.value.updated).toContain(".cursorrules");
360
+ expect(result.value.skipped).not.toContain("CLAUDE.md");
361
+ expect(result.value.skipped).not.toContain("GEMINI.md");
362
+ expect(result.value.skipped).not.toContain(".cursorrules");
363
+ }
364
+
365
+ const claudeContent = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
366
+ expect(claudeContent).toContain("# My Custom CLAUDE.md");
367
+ expect(claudeContent).toContain("## Maina");
368
+ expect(claudeContent).toContain("constitution.md");
369
+ expect(claudeContent).toContain("getContext");
370
+
371
+ const geminiContent = readFileSync(join(tmpDir, "GEMINI.md"), "utf-8");
372
+ expect(geminiContent).toContain("# My Custom GEMINI.md");
373
+ expect(geminiContent).toContain("## Maina");
374
+
375
+ const cursorContent = readFileSync(join(tmpDir, ".cursorrules"), "utf-8");
376
+ expect(cursorContent).toContain("# My Custom Rules");
377
+ expect(cursorContent).toContain("## Maina");
378
+ });
379
+
380
+ test("skips agent files that already have ## Maina section", async () => {
381
+ writeFileSync(
382
+ join(tmpDir, "CLAUDE.md"),
383
+ "# My CLAUDE.md\n\n## Maina\n\nAlready configured.\n",
384
+ );
385
+ writeFileSync(
386
+ join(tmpDir, "GEMINI.md"),
387
+ "# My GEMINI.md\n\n## Maina\n\nAlready configured.\n",
388
+ );
389
+
359
390
  const result = await bootstrap(tmpDir);
360
391
  expect(result.ok).toBe(true);
361
392
  if (result.ok) {
362
393
  expect(result.value.skipped).toContain("CLAUDE.md");
363
394
  expect(result.value.skipped).toContain("GEMINI.md");
364
- expect(result.value.skipped).toContain(".cursorrules");
395
+ expect(result.value.updated).not.toContain("CLAUDE.md");
396
+ expect(result.value.updated).not.toContain("GEMINI.md");
365
397
  }
366
398
 
367
- expect(readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(
368
- "# My Custom CLAUDE.md\n",
399
+ // Content should not be modified
400
+ const claudeContent = readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8");
401
+ expect(claudeContent).toBe(
402
+ "# My CLAUDE.md\n\n## Maina\n\nAlready configured.\n",
403
+ );
404
+ });
405
+
406
+ test("merges AGENTS.md with maina section", async () => {
407
+ writeFileSync(
408
+ join(tmpDir, "AGENTS.md"),
409
+ "# My Agents File\n\nCustom content here.\n",
369
410
  );
411
+
412
+ const result = await bootstrap(tmpDir);
413
+ expect(result.ok).toBe(true);
414
+ if (result.ok) {
415
+ expect(result.value.updated).toContain("AGENTS.md");
416
+ }
417
+
418
+ const content = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
419
+ expect(content).toContain("# My Agents File");
420
+ expect(content).toContain("Custom content here.");
421
+ expect(content).toContain("## Maina");
422
+ expect(content).toContain("brainstorm");
423
+ expect(content).toContain("MCP Tools");
424
+ });
425
+
426
+ test("updated array is empty for fresh directory", async () => {
427
+ const result = await bootstrap(tmpDir);
428
+ expect(result.ok).toBe(true);
429
+ if (result.ok) {
430
+ expect(result.value.updated).toEqual([]);
431
+ }
370
432
  });
371
433
 
372
434
  // ── Workflow order in agent files ─────────────────────────────────────
package/src/init/index.ts CHANGED
@@ -28,6 +28,7 @@ export interface InitOptions {
28
28
  export interface InitReport {
29
29
  created: string[];
30
30
  skipped: string[];
31
+ updated: string[];
31
32
  directory: string;
32
33
  detectedStack: DetectedStack;
33
34
  detectedTools: DetectedTool[];
@@ -41,6 +42,14 @@ export interface DetectedStack {
41
42
  testRunner: string;
42
43
  linter: string;
43
44
  framework: string;
45
+ /** package.json scripts (e.g. { test: "vitest", build: "tsc" }) */
46
+ scripts: Record<string, string>;
47
+ /** Build tool detected (e.g. "vite", "webpack", "tsup", "bunup", "esbuild") */
48
+ buildTool: string;
49
+ /** Whether this is a monorepo (workspaces detected) */
50
+ monorepo: boolean;
51
+ /** Inferred conventions from project context */
52
+ conventions: string[];
44
53
  }
45
54
 
46
55
  // ── Project Detection ───────────────────────────────────────────────────────
@@ -53,6 +62,10 @@ function detectStack(repoRoot: string): DetectedStack {
53
62
  testRunner: "unknown",
54
63
  linter: "unknown",
55
64
  framework: "none",
65
+ scripts: {},
66
+ buildTool: "unknown",
67
+ monorepo: false,
68
+ conventions: [],
56
69
  };
57
70
 
58
71
  // ── Multi-language detection (file-marker based) ─────────────────────
@@ -118,6 +131,16 @@ function detectStack(repoRoot: string): DetectedStack {
118
131
  ...(pkg.devDependencies as Record<string, string> | undefined),
119
132
  ...(pkg.peerDependencies as Record<string, string> | undefined),
120
133
  };
134
+
135
+ // Extract scripts
136
+ if (pkg.scripts && typeof pkg.scripts === "object") {
137
+ stack.scripts = pkg.scripts as Record<string, string>;
138
+ }
139
+
140
+ // Detect monorepo (workspaces)
141
+ if (pkg.workspaces) {
142
+ stack.monorepo = true;
143
+ }
121
144
  } catch {
122
145
  // Malformed package.json — skip
123
146
  }
@@ -187,8 +210,108 @@ function detectStack(repoRoot: string): DetectedStack {
187
210
  } else if (allDeps.svelte) {
188
211
  stack.framework = "svelte";
189
212
  }
213
+
214
+ // Build tool detection
215
+ if (allDeps.bunup) {
216
+ stack.buildTool = "bunup";
217
+ } else if (allDeps.tsup) {
218
+ stack.buildTool = "tsup";
219
+ } else if (allDeps.vite) {
220
+ stack.buildTool = "vite";
221
+ } else if (allDeps.webpack) {
222
+ stack.buildTool = "webpack";
223
+ } else if (allDeps.esbuild) {
224
+ stack.buildTool = "esbuild";
225
+ } else if (allDeps.rollup) {
226
+ stack.buildTool = "rollup";
227
+ } else if (allDeps.turbo) {
228
+ stack.buildTool = "turborepo";
229
+ }
230
+
231
+ // Also check for monorepo tools
232
+ if (
233
+ allDeps.turbo ||
234
+ allDeps.nx ||
235
+ allDeps.lerna ||
236
+ existsSync(join(repoRoot, "pnpm-workspace.yaml"))
237
+ ) {
238
+ stack.monorepo = true;
239
+ }
240
+ }
241
+
242
+ // ── Infer conventions from project context ───────────────────────────
243
+ const conventions: string[] = [];
244
+
245
+ // Check for conventional commits
246
+ if (
247
+ existsSync(join(repoRoot, "commitlint.config.js")) ||
248
+ existsSync(join(repoRoot, "commitlint.config.ts")) ||
249
+ existsSync(join(repoRoot, ".commitlintrc.json")) ||
250
+ existsSync(join(repoRoot, ".commitlintrc.yml"))
251
+ ) {
252
+ conventions.push("Conventional commits enforced via commitlint");
253
+ }
254
+
255
+ // Check for git hooks
256
+ if (existsSync(join(repoRoot, "lefthook.yml"))) {
257
+ conventions.push("Git hooks via lefthook");
258
+ } else if (existsSync(join(repoRoot, ".husky"))) {
259
+ conventions.push("Git hooks via husky");
260
+ }
261
+
262
+ // Check for strict TypeScript
263
+ if (existsSync(join(repoRoot, "tsconfig.json"))) {
264
+ try {
265
+ const tsconfig = readFileSync(join(repoRoot, "tsconfig.json"), "utf-8");
266
+ if (tsconfig.includes('"strict"') && tsconfig.includes("true")) {
267
+ conventions.push("TypeScript strict mode enabled");
268
+ }
269
+ } catch {
270
+ // ignore
271
+ }
190
272
  }
191
273
 
274
+ // Check for Docker
275
+ if (
276
+ existsSync(join(repoRoot, "Dockerfile")) ||
277
+ existsSync(join(repoRoot, "docker-compose.yml")) ||
278
+ existsSync(join(repoRoot, "docker-compose.yaml"))
279
+ ) {
280
+ conventions.push("Docker containerization");
281
+ }
282
+
283
+ // Check for CI
284
+ if (existsSync(join(repoRoot, ".github/workflows"))) {
285
+ conventions.push("GitHub Actions CI/CD");
286
+ } else if (existsSync(join(repoRoot, ".gitlab-ci.yml"))) {
287
+ conventions.push("GitLab CI/CD");
288
+ } else if (existsSync(join(repoRoot, ".circleci"))) {
289
+ conventions.push("CircleCI");
290
+ }
291
+
292
+ // Check for env management
293
+ if (existsSync(join(repoRoot, ".env.example"))) {
294
+ conventions.push("Environment variables documented in .env.example");
295
+ }
296
+
297
+ // Infer from package.json scripts
298
+ if (stack.scripts.lint || stack.scripts["lint:fix"]) {
299
+ conventions.push(
300
+ `Lint command: \`${stack.runtime === "bun" ? "bun" : "npm"} run lint\``,
301
+ );
302
+ }
303
+ if (stack.scripts.test) {
304
+ conventions.push(`Test command: \`${stack.scripts.test}\``);
305
+ }
306
+ if (stack.scripts.build) {
307
+ conventions.push(`Build command: \`${stack.scripts.build}\``);
308
+ }
309
+ if (stack.scripts.typecheck || stack.scripts["type-check"]) {
310
+ conventions.push("Type checking enforced");
311
+ }
312
+
313
+ stack.conventions = conventions;
314
+
192
315
  // If no languages detected, mark as unknown
193
316
  stack.languages = languages.length > 0 ? languages : ["unknown"];
194
317
 
@@ -237,6 +360,53 @@ function buildConstitution(stack: DetectedStack): string {
237
360
  const frameworkLine =
238
361
  stack.framework !== "none" ? `- Framework: ${stack.framework}\n` : "";
239
362
 
363
+ const buildLine =
364
+ stack.buildTool !== "unknown" ? `- Build: ${stack.buildTool}\n` : "";
365
+
366
+ const monorepoLine = stack.monorepo ? "- Monorepo: yes (workspaces)\n" : "";
367
+
368
+ // Build architecture section from context
369
+ const archLines: string[] = [];
370
+ if (stack.monorepo) {
371
+ archLines.push("- Monorepo with shared packages");
372
+ }
373
+ if (stack.framework !== "none") {
374
+ archLines.push(`- ${stack.framework} application`);
375
+ }
376
+ if (stack.languages.length > 1) {
377
+ archLines.push(`- Multi-language: ${stack.languages.join(", ")}`);
378
+ }
379
+ const archSection =
380
+ archLines.length > 0
381
+ ? archLines.join("\n")
382
+ : "- [NEEDS CLARIFICATION] Define architectural constraints.";
383
+
384
+ // Build verification section from scripts
385
+ const verifyLines: string[] = [];
386
+ const runCmd = stack.runtime === "bun" ? "bun" : "npm";
387
+ if (stack.scripts.lint || stack.linter !== "unknown") {
388
+ verifyLines.push(
389
+ `- Lint: \`${stack.scripts.lint ?? `${runCmd} run lint`}\``,
390
+ );
391
+ }
392
+ if (stack.language === "typescript") {
393
+ verifyLines.push(
394
+ `- Typecheck: \`${stack.scripts.typecheck ?? stack.scripts["type-check"] ?? `${runCmd} run typecheck`}\``,
395
+ );
396
+ }
397
+ if (stack.scripts.test) {
398
+ verifyLines.push(`- Test: \`${stack.scripts.test}\``);
399
+ } else if (stack.testRunner !== "unknown") {
400
+ verifyLines.push(`- Test: \`${runCmd} test\``);
401
+ }
402
+ verifyLines.push("- Diff-only: only report findings on changed lines");
403
+
404
+ // Build conventions section from detected conventions
405
+ const conventionLines =
406
+ stack.conventions.length > 0
407
+ ? stack.conventions.map((c) => `- ${c}`).join("\n")
408
+ : "- [NEEDS CLARIFICATION] Add project-specific conventions.";
409
+
240
410
  return `# Project Constitution
241
411
 
242
412
  Non-negotiable rules. Injected into every AI call.
@@ -246,16 +416,15 @@ ${runtimeLine}
246
416
  ${langLine}
247
417
  ${lintLine}
248
418
  ${testLine}
249
- ${frameworkLine}
419
+ ${frameworkLine}${buildLine}${monorepoLine}
250
420
  ## Architecture
251
- - [NEEDS CLARIFICATION] Define architectural constraints.
421
+ ${archSection}
252
422
 
253
423
  ## Verification
254
- - All commits pass: lint + typecheck + test
255
- - Diff-only: only report findings on changed lines
424
+ ${verifyLines.join("\n")}
256
425
 
257
426
  ## Conventions
258
- - [NEEDS CLARIFICATION] Add project-specific conventions.
427
+ ${conventionLines}
259
428
  `;
260
429
  }
261
430
 
@@ -351,6 +520,35 @@ Issues labeled \`audit\` come from maina's daily verification. Fix the specific
351
520
  `;
352
521
  }
353
522
 
523
+ // ── Maina Section (for merging into existing agent files) ──────────────────
524
+
525
+ /** Agent file names that support maina section merging */
526
+ const MERGEABLE_AGENT_FILES = [
527
+ "AGENTS.md",
528
+ "CLAUDE.md",
529
+ "GEMINI.md",
530
+ ".cursorrules",
531
+ ".github/copilot-instructions.md",
532
+ ];
533
+
534
+ /**
535
+ * Build a standalone "## Maina" section that can be appended to an existing
536
+ * agent file. Contains essential maina workflow + MCP tools info.
537
+ */
538
+ export function buildMainaSection(_stack: DetectedStack): string {
539
+ return `## Maina
540
+
541
+ This repo uses [Maina](https://mainahq.com) for verification-first development.
542
+ Read \`.maina/constitution.md\` for project DNA.
543
+
544
+ ### Workflow
545
+ \`${WORKFLOW_ORDER}\`
546
+
547
+ ### MCP Tools
548
+ ${MCP_TOOLS_TABLE}
549
+ `;
550
+ }
551
+
354
552
  // ── .mcp.json ───────────────────────────────────────────────────────────────
355
553
 
356
554
  function buildMcpJson(): string {
@@ -742,6 +940,7 @@ export async function bootstrap(
742
940
  const mainaDir = join(repoRoot, ".maina");
743
941
  const created: string[] = [];
744
942
  const skipped: string[] = [];
943
+ const updated: string[] = [];
745
944
 
746
945
  try {
747
946
  // Detect project stack from package.json
@@ -782,7 +981,25 @@ export async function bootstrap(
782
981
  mkdirSync(dirPath, { recursive: true });
783
982
 
784
983
  if (existsSync(fullPath) && !force) {
785
- skipped.push(entry.relativePath);
984
+ // Try to merge maina section for agent files
985
+ if (
986
+ MERGEABLE_AGENT_FILES.some((af) => entry.relativePath.endsWith(af))
987
+ ) {
988
+ const existing = readFileSync(fullPath, "utf-8");
989
+ if (!existing.includes("## Maina")) {
990
+ const mainaSection = buildMainaSection(detectedStack);
991
+ writeFileSync(
992
+ fullPath,
993
+ `${existing.trimEnd()}\n\n${mainaSection}`,
994
+ "utf-8",
995
+ );
996
+ updated.push(entry.relativePath);
997
+ } else {
998
+ skipped.push(entry.relativePath);
999
+ }
1000
+ } else {
1001
+ skipped.push(entry.relativePath);
1002
+ }
786
1003
  } else {
787
1004
  writeFileSync(fullPath, entry.content, "utf-8");
788
1005
  created.push(entry.relativePath);
@@ -805,6 +1022,7 @@ export async function bootstrap(
805
1022
  value: {
806
1023
  created,
807
1024
  skipped,
1025
+ updated,
808
1026
  directory: mainaDir,
809
1027
  detectedStack,
810
1028
  detectedTools: detectedToolsList,
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Tests for built-in verify checks.
3
+ *
4
+ * Each check is a pure function: (filePath, content) => Finding[].
5
+ * No I/O, no side effects — just string analysis.
6
+ */
7
+
8
+ import { describe, expect, it } from "bun:test";
9
+ import {
10
+ checkAnyType,
11
+ checkConsoleLogs,
12
+ checkEmptyCatch,
13
+ checkFileSize,
14
+ checkSecrets,
15
+ checkTodoComments,
16
+ checkUnusedImports,
17
+ runBuiltinChecks,
18
+ } from "../builtin";
19
+
20
+ // ─── checkConsoleLogs ────────────────────────────────────────────────────
21
+
22
+ describe("checkConsoleLogs", () => {
23
+ it("detects console.log in a .ts file", () => {
24
+ const content = `const x = 1;\nconsole.log(x);\nconst y = 2;\n`;
25
+ const findings = checkConsoleLogs("src/app.ts", content);
26
+ expect(findings).toHaveLength(1);
27
+ expect(findings[0]?.line).toBe(2);
28
+ expect(findings[0]?.severity).toBe("warning");
29
+ expect(findings[0]?.ruleId).toBe("no-console-log");
30
+ expect(findings[0]?.tool).toBe("builtin");
31
+ expect(findings[0]?.file).toBe("src/app.ts");
32
+ });
33
+
34
+ it("skips .test.ts files", () => {
35
+ const content = `console.log("debug");\n`;
36
+ const findings = checkConsoleLogs("src/app.test.ts", content);
37
+ expect(findings).toHaveLength(0);
38
+ });
39
+
40
+ it("skips .spec.ts files", () => {
41
+ const content = `console.log("debug");\n`;
42
+ const findings = checkConsoleLogs("src/app.spec.ts", content);
43
+ expect(findings).toHaveLength(0);
44
+ });
45
+
46
+ it("skips files in __tests__ directories", () => {
47
+ const content = `console.log("debug");\n`;
48
+ const findings = checkConsoleLogs("src/__tests__/app.ts", content);
49
+ expect(findings).toHaveLength(0);
50
+ });
51
+
52
+ it("detects console.warn and console.error too", () => {
53
+ const content = `console.warn("w");\nconsole.error("e");\n`;
54
+ const findings = checkConsoleLogs("src/app.ts", content);
55
+ expect(findings).toHaveLength(2);
56
+ });
57
+
58
+ it("returns empty for clean files", () => {
59
+ const content = `const x = 1;\nconst y = 2;\n`;
60
+ const findings = checkConsoleLogs("src/app.ts", content);
61
+ expect(findings).toHaveLength(0);
62
+ });
63
+ });
64
+
65
+ // ─── checkTodoComments ───────────────────────────────────────────────────
66
+
67
+ describe("checkTodoComments", () => {
68
+ it("detects TODO with correct line numbers", () => {
69
+ const content = `const x = 1;\n// TODO: fix this\nconst y = 2;\n// FIXME: broken\n`;
70
+ const findings = checkTodoComments("src/app.ts", content);
71
+ expect(findings).toHaveLength(2);
72
+ expect(findings[0]?.line).toBe(2);
73
+ expect(findings[0]?.message).toContain("TODO");
74
+ expect(findings[1]?.line).toBe(4);
75
+ expect(findings[1]?.message).toContain("FIXME");
76
+ });
77
+
78
+ it("detects HACK comments", () => {
79
+ const content = `// HACK: temporary workaround\n`;
80
+ const findings = checkTodoComments("src/app.ts", content);
81
+ expect(findings).toHaveLength(1);
82
+ expect(findings[0]?.ruleId).toBe("todo-comment");
83
+ });
84
+
85
+ it("returns empty when no markers present", () => {
86
+ const content = `const x = 1;\n// This is a normal comment\n`;
87
+ const findings = checkTodoComments("src/app.ts", content);
88
+ expect(findings).toHaveLength(0);
89
+ });
90
+ });
91
+
92
+ // ─── checkFileSize ───────────────────────────────────────────────────────
93
+
94
+ describe("checkFileSize", () => {
95
+ it("flags files over 500 lines", () => {
96
+ const lines = Array.from({ length: 501 }, (_, i) => `const x${i} = ${i};`);
97
+ const content = lines.join("\n");
98
+ const findings = checkFileSize("src/big.ts", content);
99
+ expect(findings).toHaveLength(1);
100
+ expect(findings[0]?.severity).toBe("warning");
101
+ expect(findings[0]?.ruleId).toBe("file-too-long");
102
+ expect(findings[0]?.message).toContain("501");
103
+ });
104
+
105
+ it("does not flag files with exactly 500 lines", () => {
106
+ const lines = Array.from({ length: 500 }, (_, i) => `const x${i} = ${i};`);
107
+ const content = lines.join("\n");
108
+ const findings = checkFileSize("src/ok.ts", content);
109
+ expect(findings).toHaveLength(0);
110
+ });
111
+ });
112
+
113
+ // ─── checkSecrets ────────────────────────────────────────────────────────
114
+
115
+ describe("checkSecrets", () => {
116
+ it("detects hardcoded password patterns", () => {
117
+ const content = `const config = {\n password="s3cret123"\n};\n`;
118
+ const findings = checkSecrets("src/config.ts", content);
119
+ expect(findings).toHaveLength(1);
120
+ expect(findings[0]?.severity).toBe("error");
121
+ expect(findings[0]?.ruleId).toBe("hardcoded-secret");
122
+ });
123
+
124
+ it("detects api_key patterns", () => {
125
+ const content = `const api_key = "abc123def456";\n`;
126
+ const findings = checkSecrets("src/config.ts", content);
127
+ expect(findings).toHaveLength(1);
128
+ });
129
+
130
+ it("ignores variable references (not hardcoded)", () => {
131
+ const content = `const password = process.env.PASSWORD;\n`;
132
+ const findings = checkSecrets("src/config.ts", content);
133
+ expect(findings).toHaveLength(0);
134
+ });
135
+
136
+ it("detects token patterns", () => {
137
+ const content = `const token = "ghp_abc123def456";\n`;
138
+ const findings = checkSecrets("src/config.ts", content);
139
+ expect(findings).toHaveLength(1);
140
+ });
141
+
142
+ it("detects secret patterns", () => {
143
+ const content = `secret="mySecretValue123";\n`;
144
+ const findings = checkSecrets("src/config.ts", content);
145
+ expect(findings).toHaveLength(1);
146
+ });
147
+ });
148
+
149
+ // ─── checkEmptyCatch ─────────────────────────────────────────────────────
150
+
151
+ describe("checkEmptyCatch", () => {
152
+ it("detects empty catch blocks", () => {
153
+ const content = `try {\n doSomething();\n} catch (e) {\n}\n`;
154
+ const findings = checkEmptyCatch("src/app.ts", content);
155
+ expect(findings).toHaveLength(1);
156
+ expect(findings[0]?.ruleId).toBe("empty-catch");
157
+ expect(findings[0]?.severity).toBe("warning");
158
+ });
159
+
160
+ it("does not flag catch blocks with content", () => {
161
+ const content = `try {\n doSomething();\n} catch (e) {\n console.error(e);\n}\n`;
162
+ const findings = checkEmptyCatch("src/app.ts", content);
163
+ expect(findings).toHaveLength(0);
164
+ });
165
+
166
+ it("detects catch blocks with only whitespace", () => {
167
+ const content = `try {\n doSomething();\n} catch (e) {\n \n}\n`;
168
+ const findings = checkEmptyCatch("src/app.ts", content);
169
+ expect(findings).toHaveLength(1);
170
+ });
171
+
172
+ it("allows catch with a comment (intentional empty catch)", () => {
173
+ const content = `try {\n doSomething();\n} catch (e) {\n // intentionally empty\n}\n`;
174
+ const findings = checkEmptyCatch("src/app.ts", content);
175
+ expect(findings).toHaveLength(0);
176
+ });
177
+ });
178
+
179
+ // ─── checkAnyType ────────────────────────────────────────────────────────
180
+
181
+ describe("checkAnyType", () => {
182
+ it("detects 'any' type annotation in .ts files", () => {
183
+ const content = `function foo(x: any): void {\n return;\n}\n`;
184
+ const findings = checkAnyType("src/app.ts", content);
185
+ expect(findings).toHaveLength(1);
186
+ expect(findings[0]?.line).toBe(1);
187
+ expect(findings[0]?.ruleId).toBe("no-any-type");
188
+ expect(findings[0]?.severity).toBe("warning");
189
+ });
190
+
191
+ it("skips .d.ts files", () => {
192
+ const content = `declare function foo(x: any): void;\n`;
193
+ const findings = checkAnyType("src/types.d.ts", content);
194
+ expect(findings).toHaveLength(0);
195
+ });
196
+
197
+ it("does not flag 'any' in comments or strings", () => {
198
+ const content = `// any type is bad\nconst msg = "any value";\n`;
199
+ const findings = checkAnyType("src/app.ts", content);
200
+ expect(findings).toHaveLength(0);
201
+ });
202
+
203
+ it("detects multiple any usages", () => {
204
+ const content = `const x: any = 1;\nconst y: any = 2;\n`;
205
+ const findings = checkAnyType("src/app.ts", content);
206
+ expect(findings).toHaveLength(2);
207
+ });
208
+
209
+ it("does not flag words containing 'any' like 'many' or 'company'", () => {
210
+ const content = `const many = 1;\nconst company = "acme";\n`;
211
+ const findings = checkAnyType("src/app.ts", content);
212
+ expect(findings).toHaveLength(0);
213
+ });
214
+ });
215
+
216
+ // ─── checkUnusedImports ──────────────────────────────────────────────────
217
+
218
+ describe("checkUnusedImports", () => {
219
+ it("detects unused named imports", () => {
220
+ const content = `import { foo, bar } from "./mod";\nconst x = foo();\n`;
221
+ const findings = checkUnusedImports("src/app.ts", content);
222
+ // bar is unused
223
+ expect(findings).toHaveLength(1);
224
+ expect(findings[0]?.message).toContain("bar");
225
+ expect(findings[0]?.ruleId).toBe("unused-import");
226
+ });
227
+
228
+ it("does not flag used imports", () => {
229
+ const content = `import { foo } from "./mod";\nconst x = foo();\n`;
230
+ const findings = checkUnusedImports("src/app.ts", content);
231
+ expect(findings).toHaveLength(0);
232
+ });
233
+
234
+ it("handles type imports (should not flag)", () => {
235
+ const content = `import type { Foo } from "./mod";\nconst x: Foo = {};\n`;
236
+ const findings = checkUnusedImports("src/app.ts", content);
237
+ expect(findings).toHaveLength(0);
238
+ });
239
+ });
240
+
241
+ // ─── runBuiltinChecks ────────────────────────────────────────────────────
242
+
243
+ describe("runBuiltinChecks", () => {
244
+ it("aggregates findings from all checks", () => {
245
+ const content = [
246
+ `import { unused } from "./mod";`,
247
+ `console.log("bad");`,
248
+ `// TODO: fix later`,
249
+ `const x: any = 1;`,
250
+ `try { f(); } catch (e) {}`,
251
+ `password="secret123"`,
252
+ ].join("\n");
253
+
254
+ const findings = runBuiltinChecks("src/app.ts", content);
255
+ // Should have findings from multiple checks
256
+ expect(findings.length).toBeGreaterThanOrEqual(4);
257
+
258
+ // Verify all findings have correct tool
259
+ for (const f of findings) {
260
+ expect(f.tool).toBe("builtin");
261
+ expect(f.file).toBe("src/app.ts");
262
+ }
263
+ });
264
+
265
+ it("returns empty for clean files", () => {
266
+ const content = `import { foo } from "./mod";\nconst x = foo();\n`;
267
+ const findings = runBuiltinChecks("src/app.ts", content);
268
+ expect(findings).toHaveLength(0);
269
+ });
270
+ });
@@ -272,8 +272,8 @@ describe("VerifyPipeline", () => {
272
272
  expect(callOrder).toContain("runTrivy");
273
273
  expect(callOrder).toContain("runSecretlint");
274
274
 
275
- // 10 tool reports (slop + semgrep + trivy + secretlint + sonarqube + stryker + diff-cover + typecheck + consistency + ai-review)
276
- expect(result.tools).toHaveLength(10);
275
+ // 11 tool reports (slop + semgrep + trivy + secretlint + sonarqube + stryker + diff-cover + typecheck + consistency + builtin + ai-review)
276
+ expect(result.tools).toHaveLength(11);
277
277
  expect(result.findings).toHaveLength(3);
278
278
  });
279
279
 
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Built-in Verify Checks — pure-function checks that always run.
3
+ *
4
+ * These provide baseline verification without requiring external linters.
5
+ * Each check is a pure function: (filePath, content) => Finding[].
6
+ * No I/O, no side effects, no subprocess spawns.
7
+ */
8
+
9
+ import type { Finding } from "./diff-filter";
10
+
11
+ // ─── Helpers ─────────────────────────────────────────────────────────────
12
+
13
+ function isTestFile(filePath: string): boolean {
14
+ return (
15
+ filePath.endsWith(".test.ts") ||
16
+ filePath.endsWith(".test.tsx") ||
17
+ filePath.endsWith(".test.js") ||
18
+ filePath.endsWith(".test.jsx") ||
19
+ filePath.endsWith(".spec.ts") ||
20
+ filePath.endsWith(".spec.tsx") ||
21
+ filePath.endsWith(".spec.js") ||
22
+ filePath.endsWith(".spec.jsx") ||
23
+ filePath.includes("__tests__/")
24
+ );
25
+ }
26
+
27
+ function isDeclarationFile(filePath: string): boolean {
28
+ return filePath.endsWith(".d.ts");
29
+ }
30
+
31
+ function isTypeScriptFile(filePath: string): boolean {
32
+ return (
33
+ filePath.endsWith(".ts") ||
34
+ filePath.endsWith(".tsx") ||
35
+ filePath.endsWith(".mts") ||
36
+ filePath.endsWith(".cts")
37
+ );
38
+ }
39
+
40
+ // ─── Check 1: console.log in non-test files ─────────────────────────────
41
+
42
+ /**
43
+ * Detect console.log/warn/error/debug/info calls in production code.
44
+ * Test files are excluded since console usage is acceptable there.
45
+ */
46
+ export function checkConsoleLogs(filePath: string, content: string): Finding[] {
47
+ if (isTestFile(filePath)) return [];
48
+
49
+ const findings: Finding[] = [];
50
+ const lines = content.split("\n");
51
+ const consolePattern = /\bconsole\.(log|warn|error|debug|info)\s*\(/;
52
+
53
+ for (const [i, line] of lines.entries()) {
54
+ if (consolePattern.test(line)) {
55
+ findings.push({
56
+ tool: "builtin",
57
+ file: filePath,
58
+ line: i + 1,
59
+ message: `console.${line.match(consolePattern)?.[1]} found in production code`,
60
+ severity: "warning",
61
+ ruleId: "no-console-log",
62
+ });
63
+ }
64
+ }
65
+
66
+ return findings;
67
+ }
68
+
69
+ // ─── Check 2: Unused imports ─────────────────────────────────────────────
70
+
71
+ /**
72
+ * Best-effort regex check for unused named imports.
73
+ * Looks for `import { X, Y }` where identifiers don't appear
74
+ * elsewhere in the file. Prefers false negatives over false positives.
75
+ */
76
+ export function checkUnusedImports(
77
+ filePath: string,
78
+ content: string,
79
+ ): Finding[] {
80
+ const findings: Finding[] = [];
81
+ const lines = content.split("\n");
82
+
83
+ // Match named imports: import { A, B } from "..." or import type { A } from "..."
84
+ const importLinePattern =
85
+ /^import\s+(?:type\s+)?{([^}]+)}\s+from\s+["'][^"']+["'];?\s*$/;
86
+
87
+ for (const [i, line] of lines.entries()) {
88
+ const match = line.match(importLinePattern);
89
+ if (!match) continue;
90
+
91
+ // Check if this is a type-only import (import type { ... })
92
+ const isTypeImport = /^import\s+type\s+\{/.test(line);
93
+
94
+ const rawNames = match[1]?.split(",") ?? [];
95
+ const importedNames: string[] = [];
96
+ for (const raw of rawNames) {
97
+ // Handle "X as Y" — the local name is Y
98
+ const parts = raw.trim().split(/\s+as\s+/);
99
+ const resolved = (parts.length > 1 ? parts[1] : parts[0])?.trim() ?? "";
100
+ if (resolved.length > 0) {
101
+ importedNames.push(resolved);
102
+ }
103
+ }
104
+
105
+ // Get the rest of the file content (excluding import lines)
106
+ const restOfFile = lines
107
+ .filter((l) => !importLinePattern.test(l))
108
+ .join("\n");
109
+
110
+ for (const name of importedNames) {
111
+ // Check if the identifier appears in the rest of the file
112
+ // Use word boundary to avoid matching substrings
113
+ const usagePattern = new RegExp(`\\b${escapeRegex(name)}\\b`);
114
+ if (!usagePattern.test(restOfFile)) {
115
+ findings.push({
116
+ tool: "builtin",
117
+ file: filePath,
118
+ line: i + 1,
119
+ message: `Import '${name}' appears unused${isTypeImport ? " (type import)" : ""}`,
120
+ severity: "warning",
121
+ ruleId: "unused-import",
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ return findings;
128
+ }
129
+
130
+ function escapeRegex(str: string): string {
131
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
132
+ }
133
+
134
+ // ─── Check 3: TODO/FIXME/HACK comments ──────────────────────────────────
135
+
136
+ /**
137
+ * Count and report TODO, FIXME, and HACK markers.
138
+ * These are informational — they don't block verification.
139
+ */
140
+ export function checkTodoComments(
141
+ filePath: string,
142
+ content: string,
143
+ ): Finding[] {
144
+ const findings: Finding[] = [];
145
+ const lines = content.split("\n");
146
+ const todoPattern = /\b(TODO|FIXME|HACK)\b/;
147
+
148
+ for (const [i, line] of lines.entries()) {
149
+ const match = line.match(todoPattern);
150
+ if (match) {
151
+ findings.push({
152
+ tool: "builtin",
153
+ file: filePath,
154
+ line: i + 1,
155
+ message: `${match[1]} comment found: ${line.trim()}`,
156
+ severity: "info",
157
+ ruleId: "todo-comment",
158
+ });
159
+ }
160
+ }
161
+
162
+ return findings;
163
+ }
164
+
165
+ // ─── Check 4: File size ──────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Flag files exceeding 500 lines. Large files are harder to review
169
+ * and maintain — consider splitting them.
170
+ */
171
+ export function checkFileSize(filePath: string, content: string): Finding[] {
172
+ const lineCount = content.split("\n").length;
173
+
174
+ if (lineCount > 500) {
175
+ return [
176
+ {
177
+ tool: "builtin",
178
+ file: filePath,
179
+ line: 1,
180
+ message: `File has ${lineCount} lines (exceeds 500 line limit). Consider splitting.`,
181
+ severity: "warning",
182
+ ruleId: "file-too-long",
183
+ },
184
+ ];
185
+ }
186
+
187
+ return [];
188
+ }
189
+
190
+ // ─── Check 5: Secrets patterns ──────────────────────────────────────────
191
+
192
+ /**
193
+ * Detect hardcoded secrets: password=, secret=, token=, api_key=
194
+ * followed by a quoted or literal non-empty value (not a variable reference).
195
+ */
196
+ export function checkSecrets(filePath: string, content: string): Finding[] {
197
+ const findings: Finding[] = [];
198
+ const lines = content.split("\n");
199
+
200
+ // Patterns: key followed by = and a hardcoded value (quoted string or bare literal)
201
+ // Does NOT match variable references like process.env.X, ${VAR}, etc.
202
+ const secretPattern =
203
+ /\b(password|secret|token|api_key|apikey|api_secret|private_key|auth_token)\s*[=:]\s*["'`]([^"'`\s$]{2,})["'`]/i;
204
+
205
+ for (const [i, line] of lines.entries()) {
206
+ const match = line.match(secretPattern);
207
+ if (match) {
208
+ findings.push({
209
+ tool: "builtin",
210
+ file: filePath,
211
+ line: i + 1,
212
+ message: `Possible hardcoded ${match[1]} detected`,
213
+ severity: "error",
214
+ ruleId: "hardcoded-secret",
215
+ });
216
+ }
217
+ }
218
+
219
+ return findings;
220
+ }
221
+
222
+ // ─── Check 6: Empty catch blocks ─────────────────────────────────────────
223
+
224
+ /**
225
+ * Detect empty catch blocks (no statements, no comments).
226
+ * A catch with only whitespace is still flagged.
227
+ * A catch with a comment is considered intentional and allowed.
228
+ */
229
+ export function checkEmptyCatch(filePath: string, content: string): Finding[] {
230
+ const findings: Finding[] = [];
231
+ const lines = content.split("\n");
232
+
233
+ for (const [i, line] of lines.entries()) {
234
+ // Match catch on same line: catch (e) {}
235
+ // or catch (e) { } (with just whitespace)
236
+ const inlineMatch = line.match(/\bcatch\s*\([^)]*\)\s*\{\s*\}\s*$/);
237
+ if (inlineMatch) {
238
+ findings.push({
239
+ tool: "builtin",
240
+ file: filePath,
241
+ line: i + 1,
242
+ message: "Empty catch block — errors are silently swallowed",
243
+ severity: "warning",
244
+ ruleId: "empty-catch",
245
+ });
246
+ continue;
247
+ }
248
+
249
+ // Multi-line catch: catch (e) { on this line, } on a later line
250
+ const catchOpenMatch = line.match(/\bcatch\s*\([^)]*\)\s*\{\s*$/);
251
+ if (catchOpenMatch) {
252
+ // Look ahead for the closing brace
253
+ let blockContent = "";
254
+ let closingLine = -1;
255
+ for (let j = i + 1; j < lines.length && j < i + 20; j++) {
256
+ const nextLine = lines[j] ?? "";
257
+ if (nextLine.trim() === "}") {
258
+ closingLine = j;
259
+ break;
260
+ }
261
+ blockContent += nextLine;
262
+ }
263
+
264
+ if (closingLine !== -1) {
265
+ const trimmed = blockContent.trim();
266
+ // Empty or whitespace-only is flagged
267
+ // Comments are intentional — not flagged
268
+ if (trimmed === "") {
269
+ findings.push({
270
+ tool: "builtin",
271
+ file: filePath,
272
+ line: i + 1,
273
+ message: "Empty catch block — errors are silently swallowed",
274
+ severity: "warning",
275
+ ruleId: "empty-catch",
276
+ });
277
+ }
278
+ // If it contains a comment (// or /* or *), it's intentional
279
+ // If it contains actual code, it's not empty
280
+ // Either way, no finding needed
281
+ }
282
+ }
283
+ }
284
+
285
+ return findings;
286
+ }
287
+
288
+ // ─── Check 7: `any` type usage ───────────────────────────────────────────
289
+
290
+ /**
291
+ * Detect `any` type annotations in TypeScript files.
292
+ * Skips .d.ts files where `any` is sometimes necessary.
293
+ * Avoids false positives on words containing "any" (e.g., "many", "company")
294
+ * and on comments/strings.
295
+ */
296
+ export function checkAnyType(filePath: string, content: string): Finding[] {
297
+ if (!isTypeScriptFile(filePath)) return [];
298
+ if (isDeclarationFile(filePath)) return [];
299
+
300
+ const findings: Finding[] = [];
301
+ const lines = content.split("\n");
302
+
303
+ // Match `: any`, `as any`, `<any>`, `any[]`, `any,`, `any)`, `any;`
304
+ // — basically `any` used as a type annotation, not as a substring in identifiers
305
+ const anyTypePattern =
306
+ /(?::\s*any\b|(?:as|extends|implements)\s+any\b|<any\b|any\s*[[\]>,);|&])/;
307
+
308
+ for (const [i, line] of lines.entries()) {
309
+ // Skip comment lines
310
+ const trimmed = line.trim();
311
+ if (trimmed.startsWith("//") || trimmed.startsWith("*")) continue;
312
+
313
+ // Skip lines where 'any' only appears in a string literal
314
+ // Simple heuristic: remove string contents and check again
315
+ const withoutStrings = line
316
+ .replace(/"(?:[^"\\]|\\.)*"/g, '""')
317
+ .replace(/'(?:[^'\\]|\\.)*'/g, "''")
318
+ .replace(/`(?:[^`\\]|\\.)*`/g, "``");
319
+
320
+ if (anyTypePattern.test(withoutStrings)) {
321
+ findings.push({
322
+ tool: "builtin",
323
+ file: filePath,
324
+ line: i + 1,
325
+ message: "Usage of 'any' type — prefer explicit types or 'unknown'",
326
+ severity: "warning",
327
+ ruleId: "no-any-type",
328
+ });
329
+ }
330
+ }
331
+
332
+ return findings;
333
+ }
334
+
335
+ // ─── Aggregator ──────────────────────────────────────────────────────────
336
+
337
+ /**
338
+ * Run all built-in checks on a single file and return aggregated findings.
339
+ */
340
+ export function runBuiltinChecks(filePath: string, content: string): Finding[] {
341
+ return [
342
+ ...checkConsoleLogs(filePath, content),
343
+ ...checkUnusedImports(filePath, content),
344
+ ...checkTodoComments(filePath, content),
345
+ ...checkFileSize(filePath, content),
346
+ ...checkSecrets(filePath, content),
347
+ ...checkEmptyCatch(filePath, content),
348
+ ...checkAnyType(filePath, content),
349
+ ];
350
+ }
@@ -5,7 +5,7 @@
5
5
  * 1. Get files to check (staged files, or provided list)
6
6
  * 2. Run syntax guard FIRST — abort immediately if it fails
7
7
  * 3. Auto-detect available tools
8
- * 4. Run all available tools in PARALLEL (slop, semgrep, trivy, secretlint)
8
+ * 4. Run all available tools in PARALLEL (slop, builtin, semgrep, trivy, secretlint)
9
9
  * 5. Collect all findings
10
10
  * 6. Apply diff-only filter (unless diffOnly === false)
11
11
  * 7. Determine pass/fail: passed = no error-severity findings
@@ -19,6 +19,7 @@ import { detectLanguages } from "../language/detect";
19
19
  import type { LanguageId } from "../language/profile";
20
20
  import { getProfile } from "../language/profile";
21
21
  import { type AIReviewResult, runAIReview } from "./ai-review";
22
+ import { runBuiltinChecks } from "./builtin";
22
23
  import { checkConsistency } from "./consistency";
23
24
  import { runCoverage } from "./coverage";
24
25
  import type { DetectedTool } from "./detect";
@@ -240,10 +241,27 @@ export async function runPipeline(
240
241
  }),
241
242
  );
242
243
 
244
+ // Built-in checks (always run, pure functions, no external dependencies)
245
+ toolPromises.push(
246
+ runToolWithTiming("builtin", async () => {
247
+ const findings: Finding[] = [];
248
+ for (const file of files) {
249
+ try {
250
+ const fullPath = file.startsWith("/") ? file : `${cwd}/${file}`;
251
+ const text = await Bun.file(fullPath).text();
252
+ findings.push(...runBuiltinChecks(file, text));
253
+ } catch {
254
+ // File read failure should not block pipeline
255
+ }
256
+ }
257
+ return { findings, skipped: false };
258
+ }),
259
+ );
260
+
243
261
  const toolReports = await Promise.all(toolPromises);
244
262
 
245
263
  // ── Step 4b: Warn if all external tools were skipped ─────────────────
246
- const builtInTools = new Set(["slop", "typecheck", "consistency"]);
264
+ const builtInTools = new Set(["slop", "typecheck", "consistency", "builtin"]);
247
265
  const externalTools = toolReports.filter((r) => !builtInTools.has(r.tool));
248
266
  const allExternalSkipped =
249
267
  externalTools.length > 0 && externalTools.every((r) => r.skipped);