@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 +1 -1
- package/src/ai/__tests__/availability.test.ts +131 -0
- package/src/ai/availability.ts +23 -0
- package/src/index.ts +4 -1
- package/src/init/__tests__/init.test.ts +77 -15
- package/src/init/index.ts +224 -6
- package/src/verify/__tests__/builtin.test.ts +270 -0
- package/src/verify/__tests__/pipeline.test.ts +2 -2
- package/src/verify/builtin.ts +350 -0
- package/src/verify/pipeline.ts +20 -2
package/package.json
CHANGED
|
@@ -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
|
|
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 =
|
|
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("
|
|
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.
|
|
395
|
+
expect(result.value.updated).not.toContain("CLAUDE.md");
|
|
396
|
+
expect(result.value.updated).not.toContain("GEMINI.md");
|
|
365
397
|
}
|
|
366
398
|
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
421
|
+
${archSection}
|
|
252
422
|
|
|
253
423
|
## Verification
|
|
254
|
-
|
|
255
|
-
- Diff-only: only report findings on changed lines
|
|
424
|
+
${verifyLines.join("\n")}
|
|
256
425
|
|
|
257
426
|
## Conventions
|
|
258
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
276
|
-
expect(result.tools).toHaveLength(
|
|
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
|
+
}
|
package/src/verify/pipeline.ts
CHANGED
|
@@ -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);
|