@mainahq/core 0.7.0 → 1.0.1
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 +3 -3
- package/src/cloud/__tests__/client.test.ts +171 -0
- package/src/cloud/auth.ts +1 -0
- package/src/cloud/client.ts +94 -0
- package/src/cloud/types.ts +48 -0
- package/src/context/engine.ts +72 -5
- package/src/feedback/__tests__/sync.test.ts +63 -1
- package/src/feedback/collector.ts +54 -0
- package/src/feedback/sync.ts +92 -3
- package/src/git/__tests__/git.test.ts +15 -0
- package/src/git/index.ts +25 -0
- package/src/index.ts +12 -1
- package/src/init/__tests__/detect-stack.test.ts +237 -0
- package/src/init/__tests__/init.test.ts +184 -0
- package/src/init/index.ts +479 -66
- package/src/verify/__tests__/detect-filter.test.ts +303 -0
- package/src/verify/detect.ts +162 -25
- package/src/language/__tests__/__fixtures__/detect/composer.lock +0 -1
package/src/feedback/sync.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Feedback sync — exports local feedback records for cloud upload.
|
|
2
|
+
* Feedback sync — exports local feedback records and workflow stats for cloud upload.
|
|
3
3
|
*
|
|
4
4
|
* Reads from the local SQLite feedback.db and maps records to the
|
|
5
5
|
* cloud-compatible FeedbackEvent format for batch upload.
|
|
6
|
+
* Also exports workflow step counts for analytics.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
import type { FeedbackEvent } from "../cloud/types";
|
|
9
|
-
import {
|
|
9
|
+
import type { EpisodicCloudEntry, FeedbackEvent } from "../cloud/types";
|
|
10
|
+
import { getEntries } from "../context/episodic";
|
|
11
|
+
import { getFeedbackDb, getStatsDb } from "../db/index";
|
|
10
12
|
|
|
11
13
|
/** Raw row shape from the feedback table. */
|
|
12
14
|
interface FeedbackRow {
|
|
@@ -52,3 +54,90 @@ export function exportFeedbackForCloud(mainaDir: string): FeedbackEvent[] {
|
|
|
52
54
|
return event;
|
|
53
55
|
});
|
|
54
56
|
}
|
|
57
|
+
|
|
58
|
+
/** Workflow stats summary for cloud analytics. */
|
|
59
|
+
export interface WorkflowStats {
|
|
60
|
+
totalCommits: number;
|
|
61
|
+
totalVerifyTimeMs: number;
|
|
62
|
+
avgVerifyTimeMs: number;
|
|
63
|
+
totalFindings: number;
|
|
64
|
+
totalContextTokens: number;
|
|
65
|
+
cacheHitRate: number;
|
|
66
|
+
passRate: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Export workflow stats from the local stats.db for cloud analytics.
|
|
71
|
+
* Returns aggregated numbers the dashboard can display.
|
|
72
|
+
*/
|
|
73
|
+
export function exportWorkflowStats(mainaDir: string): WorkflowStats | null {
|
|
74
|
+
const dbResult = getStatsDb(mainaDir);
|
|
75
|
+
if (!dbResult.ok) return null;
|
|
76
|
+
|
|
77
|
+
const { db } = dbResult.value;
|
|
78
|
+
try {
|
|
79
|
+
const row = db
|
|
80
|
+
.query(
|
|
81
|
+
`SELECT
|
|
82
|
+
COUNT(*) as total_commits,
|
|
83
|
+
COALESCE(SUM(verify_duration_ms), 0) as total_verify_ms,
|
|
84
|
+
COALESCE(AVG(verify_duration_ms), 0) as avg_verify_ms,
|
|
85
|
+
COALESCE(SUM(findings_total), 0) as total_findings,
|
|
86
|
+
COALESCE(SUM(context_tokens), 0) as total_context_tokens,
|
|
87
|
+
COALESCE(SUM(cache_hits), 0) as total_cache_hits,
|
|
88
|
+
COALESCE(SUM(cache_misses), 0) as total_cache_misses,
|
|
89
|
+
COALESCE(SUM(CASE WHEN pipeline_passed = 1 THEN 1 ELSE 0 END), 0) as passed_count
|
|
90
|
+
FROM commit_snapshots`,
|
|
91
|
+
)
|
|
92
|
+
.get() as {
|
|
93
|
+
total_commits: number;
|
|
94
|
+
total_verify_ms: number;
|
|
95
|
+
avg_verify_ms: number;
|
|
96
|
+
total_findings: number;
|
|
97
|
+
total_context_tokens: number;
|
|
98
|
+
total_cache_hits: number;
|
|
99
|
+
total_cache_misses: number;
|
|
100
|
+
passed_count: number;
|
|
101
|
+
} | null;
|
|
102
|
+
|
|
103
|
+
if (!row || row.total_commits === 0) return null;
|
|
104
|
+
|
|
105
|
+
const totalCacheOps = row.total_cache_hits + row.total_cache_misses;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
totalCommits: row.total_commits,
|
|
109
|
+
totalVerifyTimeMs: row.total_verify_ms,
|
|
110
|
+
avgVerifyTimeMs: Math.round(row.avg_verify_ms),
|
|
111
|
+
totalFindings: row.total_findings,
|
|
112
|
+
totalContextTokens: row.total_context_tokens,
|
|
113
|
+
cacheHitRate:
|
|
114
|
+
totalCacheOps > 0 ? row.total_cache_hits / totalCacheOps : 0,
|
|
115
|
+
passRate: row.passed_count / row.total_commits,
|
|
116
|
+
};
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Export local episodic entries for cloud upload.
|
|
124
|
+
*
|
|
125
|
+
* Reads from the episodic_entries table in the context DB and maps
|
|
126
|
+
* each entry to the cloud-compatible EpisodicCloudEntry format.
|
|
127
|
+
*
|
|
128
|
+
* @param repo - Repository identifier (e.g. "owner/repo") to tag entries with.
|
|
129
|
+
*/
|
|
130
|
+
export function exportEpisodicForCloud(
|
|
131
|
+
mainaDir: string,
|
|
132
|
+
repo: string,
|
|
133
|
+
): EpisodicCloudEntry[] {
|
|
134
|
+
const entries = getEntries(mainaDir);
|
|
135
|
+
|
|
136
|
+
return entries.map((entry) => ({
|
|
137
|
+
repo,
|
|
138
|
+
entryType: entry.type,
|
|
139
|
+
title: entry.summary || entry.type,
|
|
140
|
+
summary: entry.content,
|
|
141
|
+
relevanceScore: entry.relevance,
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getDiff,
|
|
7
7
|
getRecentCommits,
|
|
8
8
|
getRepoRoot,
|
|
9
|
+
getRepoSlug,
|
|
9
10
|
getStagedFiles,
|
|
10
11
|
} from "../index";
|
|
11
12
|
|
|
@@ -59,4 +60,18 @@ describe("git operations", () => {
|
|
|
59
60
|
const diff = await getDiff();
|
|
60
61
|
expect(typeof diff).toBe("string");
|
|
61
62
|
});
|
|
63
|
+
|
|
64
|
+
test("getRepoSlug() returns owner/repo format", async () => {
|
|
65
|
+
const slug = await getRepoSlug();
|
|
66
|
+
expect(typeof slug).toBe("string");
|
|
67
|
+
// Should contain a slash (owner/repo) or at minimum be non-empty
|
|
68
|
+
expect(slug.length).toBeGreaterThan(0);
|
|
69
|
+
// If remote exists, should be owner/repo format
|
|
70
|
+
if (slug !== "unknown" && slug.includes("/")) {
|
|
71
|
+
const parts = slug.split("/");
|
|
72
|
+
expect(parts).toHaveLength(2);
|
|
73
|
+
expect(parts[0]?.length).toBeGreaterThan(0);
|
|
74
|
+
expect(parts[1]?.length).toBeGreaterThan(0);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
62
77
|
});
|
package/src/git/index.ts
CHANGED
|
@@ -108,3 +108,28 @@ export async function getTrackedFiles(cwd?: string): Promise<string[]> {
|
|
|
108
108
|
if (!output) return [];
|
|
109
109
|
return output.split("\n").filter((line) => line.trim().length > 0);
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract the "owner/repo" slug from the git remote origin URL.
|
|
114
|
+
* Handles HTTPS (https://github.com/owner/repo.git) and
|
|
115
|
+
* SSH (git@github.com:owner/repo.git) formats.
|
|
116
|
+
* Returns the directory basename as fallback if parsing fails.
|
|
117
|
+
*/
|
|
118
|
+
export async function getRepoSlug(cwd?: string): Promise<string> {
|
|
119
|
+
const url = await exec(["remote", "get-url", "origin"], cwd);
|
|
120
|
+
if (url) {
|
|
121
|
+
// SSH: git@github.com:owner/repo.git
|
|
122
|
+
const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
123
|
+
if (sshMatch?.[1]) return sshMatch[1];
|
|
124
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
125
|
+
const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
126
|
+
if (httpsMatch?.[1]) return httpsMatch[1];
|
|
127
|
+
}
|
|
128
|
+
// Fallback: use directory name
|
|
129
|
+
const root = await exec(["rev-parse", "--show-toplevel"], cwd);
|
|
130
|
+
if (root) {
|
|
131
|
+
const parts = root.split("/");
|
|
132
|
+
return parts[parts.length - 1] ?? "unknown";
|
|
133
|
+
}
|
|
134
|
+
return "unknown";
|
|
135
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,9 +63,11 @@ export { type CloudClient, createCloudClient } from "./cloud/client";
|
|
|
63
63
|
export type {
|
|
64
64
|
ApiResponse,
|
|
65
65
|
CloudConfig,
|
|
66
|
+
CloudEpisodicEntry,
|
|
66
67
|
CloudFeedbackPayload,
|
|
67
68
|
CloudPromptImprovement,
|
|
68
69
|
DeviceCodeResponse,
|
|
70
|
+
EpisodicCloudEntry,
|
|
69
71
|
FeedbackBatchPayload,
|
|
70
72
|
FeedbackEvent,
|
|
71
73
|
FeedbackImprovementsResponse,
|
|
@@ -172,7 +174,12 @@ export {
|
|
|
172
174
|
type RulePreference,
|
|
173
175
|
savePreferences,
|
|
174
176
|
} from "./feedback/preferences";
|
|
175
|
-
export {
|
|
177
|
+
export {
|
|
178
|
+
exportEpisodicForCloud,
|
|
179
|
+
exportFeedbackForCloud,
|
|
180
|
+
exportWorkflowStats,
|
|
181
|
+
type WorkflowStats,
|
|
182
|
+
} from "./feedback/sync";
|
|
176
183
|
export {
|
|
177
184
|
analyzeWorkflowTrace,
|
|
178
185
|
type PromptImprovement,
|
|
@@ -188,6 +195,7 @@ export {
|
|
|
188
195
|
getDiff,
|
|
189
196
|
getRecentCommits,
|
|
190
197
|
getRepoRoot,
|
|
198
|
+
getRepoSlug,
|
|
191
199
|
getStagedFiles,
|
|
192
200
|
getTrackedFiles,
|
|
193
201
|
} from "./git/index";
|
|
@@ -317,8 +325,11 @@ export {
|
|
|
317
325
|
export {
|
|
318
326
|
detectTool,
|
|
319
327
|
detectTools,
|
|
328
|
+
getToolsForLanguages,
|
|
320
329
|
isToolAvailable,
|
|
321
330
|
TOOL_REGISTRY,
|
|
331
|
+
type ToolRegistryEntry,
|
|
332
|
+
type ToolTier,
|
|
322
333
|
} from "./verify/detect";
|
|
323
334
|
export {
|
|
324
335
|
filterByDiff,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { bootstrap } from "../index";
|
|
6
|
+
|
|
7
|
+
function makeTmpDir(): string {
|
|
8
|
+
const dir = join(
|
|
9
|
+
tmpdir(),
|
|
10
|
+
`maina-detect-stack-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
11
|
+
);
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("detectStack — languages field", () => {
|
|
17
|
+
let tmpDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = makeTmpDir();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("empty directory returns languages with 'unknown'", async () => {
|
|
28
|
+
const result = await bootstrap(tmpDir);
|
|
29
|
+
expect(result.ok).toBe(true);
|
|
30
|
+
if (result.ok) {
|
|
31
|
+
expect(result.value.detectedStack.languages).toBeDefined();
|
|
32
|
+
expect(Array.isArray(result.value.detectedStack.languages)).toBe(true);
|
|
33
|
+
expect(result.value.detectedStack.languages).toContain("unknown");
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("TypeScript project includes 'typescript' in languages", async () => {
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(tmpDir, "package.json"),
|
|
40
|
+
JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
|
|
41
|
+
);
|
|
42
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
|
|
43
|
+
|
|
44
|
+
const result = await bootstrap(tmpDir);
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
if (result.ok) {
|
|
47
|
+
expect(result.value.detectedStack.languages).toContain("typescript");
|
|
48
|
+
expect(result.value.detectedStack.languages).not.toContain("unknown");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("JavaScript project includes 'javascript' in languages", async () => {
|
|
53
|
+
writeFileSync(
|
|
54
|
+
join(tmpDir, "package.json"),
|
|
55
|
+
JSON.stringify({ dependencies: { express: "^4.0.0" } }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const result = await bootstrap(tmpDir);
|
|
59
|
+
expect(result.ok).toBe(true);
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
expect(result.value.detectedStack.languages).toContain("javascript");
|
|
62
|
+
expect(result.value.detectedStack.languages).not.toContain("unknown");
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("go.mod detected as 'go' language", async () => {
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(tmpDir, "go.mod"),
|
|
69
|
+
"module example.com/foo\n\ngo 1.21\n",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const result = await bootstrap(tmpDir);
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
if (result.ok) {
|
|
75
|
+
expect(result.value.detectedStack.languages).toContain("go");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("Cargo.toml detected as 'rust' language", async () => {
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(tmpDir, "Cargo.toml"),
|
|
82
|
+
'[package]\nname = "test"\nversion = "0.1.0"\n',
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const result = await bootstrap(tmpDir);
|
|
86
|
+
expect(result.ok).toBe(true);
|
|
87
|
+
if (result.ok) {
|
|
88
|
+
expect(result.value.detectedStack.languages).toContain("rust");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("pyproject.toml detected as 'python' language", async () => {
|
|
93
|
+
writeFileSync(join(tmpDir, "pyproject.toml"), "[project]\nname = 'test'\n");
|
|
94
|
+
|
|
95
|
+
const result = await bootstrap(tmpDir);
|
|
96
|
+
expect(result.ok).toBe(true);
|
|
97
|
+
if (result.ok) {
|
|
98
|
+
expect(result.value.detectedStack.languages).toContain("python");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("requirements.txt detected as 'python' language", async () => {
|
|
103
|
+
writeFileSync(join(tmpDir, "requirements.txt"), "flask==2.0.0\n");
|
|
104
|
+
|
|
105
|
+
const result = await bootstrap(tmpDir);
|
|
106
|
+
expect(result.ok).toBe(true);
|
|
107
|
+
if (result.ok) {
|
|
108
|
+
expect(result.value.detectedStack.languages).toContain("python");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("setup.py detected as 'python' language", async () => {
|
|
113
|
+
writeFileSync(join(tmpDir, "setup.py"), "from setuptools import setup\n");
|
|
114
|
+
|
|
115
|
+
const result = await bootstrap(tmpDir);
|
|
116
|
+
expect(result.ok).toBe(true);
|
|
117
|
+
if (result.ok) {
|
|
118
|
+
expect(result.value.detectedStack.languages).toContain("python");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("pom.xml detected as 'java' language", async () => {
|
|
123
|
+
writeFileSync(join(tmpDir, "pom.xml"), "<project></project>\n");
|
|
124
|
+
|
|
125
|
+
const result = await bootstrap(tmpDir);
|
|
126
|
+
expect(result.ok).toBe(true);
|
|
127
|
+
if (result.ok) {
|
|
128
|
+
expect(result.value.detectedStack.languages).toContain("java");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("build.gradle detected as 'java' language", async () => {
|
|
133
|
+
writeFileSync(join(tmpDir, "build.gradle"), "apply plugin: 'java'\n");
|
|
134
|
+
|
|
135
|
+
const result = await bootstrap(tmpDir);
|
|
136
|
+
expect(result.ok).toBe(true);
|
|
137
|
+
if (result.ok) {
|
|
138
|
+
expect(result.value.detectedStack.languages).toContain("java");
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("build.gradle.kts detected as 'java' language", async () => {
|
|
143
|
+
writeFileSync(join(tmpDir, "build.gradle.kts"), 'plugins { id("java") }\n');
|
|
144
|
+
|
|
145
|
+
const result = await bootstrap(tmpDir);
|
|
146
|
+
expect(result.ok).toBe(true);
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
expect(result.value.detectedStack.languages).toContain("java");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test(".csproj file detected as 'dotnet' language", async () => {
|
|
153
|
+
writeFileSync(join(tmpDir, "MyApp.csproj"), "<Project></Project>\n");
|
|
154
|
+
|
|
155
|
+
const result = await bootstrap(tmpDir);
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
if (result.ok) {
|
|
158
|
+
expect(result.value.detectedStack.languages).toContain("dotnet");
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test(".sln file detected as 'dotnet' language", async () => {
|
|
163
|
+
writeFileSync(join(tmpDir, "MyApp.sln"), "Microsoft Visual Studio\n");
|
|
164
|
+
|
|
165
|
+
const result = await bootstrap(tmpDir);
|
|
166
|
+
expect(result.ok).toBe(true);
|
|
167
|
+
if (result.ok) {
|
|
168
|
+
expect(result.value.detectedStack.languages).toContain("dotnet");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test(".fsproj file detected as 'dotnet' language", async () => {
|
|
173
|
+
writeFileSync(join(tmpDir, "MyApp.fsproj"), "<Project></Project>\n");
|
|
174
|
+
|
|
175
|
+
const result = await bootstrap(tmpDir);
|
|
176
|
+
expect(result.ok).toBe(true);
|
|
177
|
+
if (result.ok) {
|
|
178
|
+
expect(result.value.detectedStack.languages).toContain("dotnet");
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("multi-language project detects all languages", async () => {
|
|
183
|
+
// TypeScript + Python
|
|
184
|
+
writeFileSync(
|
|
185
|
+
join(tmpDir, "package.json"),
|
|
186
|
+
JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
|
|
187
|
+
);
|
|
188
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
|
|
189
|
+
writeFileSync(join(tmpDir, "requirements.txt"), "flask==2.0.0\n");
|
|
190
|
+
|
|
191
|
+
const result = await bootstrap(tmpDir);
|
|
192
|
+
expect(result.ok).toBe(true);
|
|
193
|
+
if (result.ok) {
|
|
194
|
+
expect(result.value.detectedStack.languages).toContain("typescript");
|
|
195
|
+
expect(result.value.detectedStack.languages).toContain("python");
|
|
196
|
+
expect(
|
|
197
|
+
result.value.detectedStack.languages.length,
|
|
198
|
+
).toBeGreaterThanOrEqual(2);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("TypeScript + Go + Rust multi-language detection", async () => {
|
|
203
|
+
writeFileSync(
|
|
204
|
+
join(tmpDir, "package.json"),
|
|
205
|
+
JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
|
|
206
|
+
);
|
|
207
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
|
|
208
|
+
writeFileSync(join(tmpDir, "go.mod"), "module test\n\ngo 1.21\n");
|
|
209
|
+
writeFileSync(join(tmpDir, "Cargo.toml"), '[package]\nname = "test"\n');
|
|
210
|
+
|
|
211
|
+
const result = await bootstrap(tmpDir);
|
|
212
|
+
expect(result.ok).toBe(true);
|
|
213
|
+
if (result.ok) {
|
|
214
|
+
const langs = result.value.detectedStack.languages;
|
|
215
|
+
expect(langs).toContain("typescript");
|
|
216
|
+
expect(langs).toContain("go");
|
|
217
|
+
expect(langs).toContain("rust");
|
|
218
|
+
expect(langs.length).toBeGreaterThanOrEqual(3);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("languages does not contain duplicates", async () => {
|
|
223
|
+
writeFileSync(
|
|
224
|
+
join(tmpDir, "package.json"),
|
|
225
|
+
JSON.stringify({ devDependencies: { typescript: "^5.0.0" } }),
|
|
226
|
+
);
|
|
227
|
+
writeFileSync(join(tmpDir, "tsconfig.json"), "{}");
|
|
228
|
+
|
|
229
|
+
const result = await bootstrap(tmpDir);
|
|
230
|
+
expect(result.ok).toBe(true);
|
|
231
|
+
if (result.ok) {
|
|
232
|
+
const langs = result.value.detectedStack.languages;
|
|
233
|
+
const unique = [...new Set(langs)];
|
|
234
|
+
expect(langs.length).toBe(unique.length);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -276,4 +276,188 @@ describe("bootstrap", () => {
|
|
|
276
276
|
expect(result.value.detectedStack.linter).toBe("eslint");
|
|
277
277
|
}
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
// ── .mcp.json generation ──────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
test("creates .mcp.json at repo root", async () => {
|
|
283
|
+
const result = await bootstrap(tmpDir);
|
|
284
|
+
expect(result.ok).toBe(true);
|
|
285
|
+
|
|
286
|
+
const mcpPath = join(tmpDir, ".mcp.json");
|
|
287
|
+
expect(existsSync(mcpPath)).toBe(true);
|
|
288
|
+
|
|
289
|
+
const content = JSON.parse(readFileSync(mcpPath, "utf-8"));
|
|
290
|
+
expect(content.mcpServers).toBeDefined();
|
|
291
|
+
expect(content.mcpServers.maina).toBeDefined();
|
|
292
|
+
expect(content.mcpServers.maina.command).toBe("maina");
|
|
293
|
+
expect(content.mcpServers.maina.args).toEqual(["--mcp"]);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("does not overwrite existing .mcp.json", async () => {
|
|
297
|
+
writeFileSync(join(tmpDir, ".mcp.json"), '{"custom": true}');
|
|
298
|
+
|
|
299
|
+
const result = await bootstrap(tmpDir);
|
|
300
|
+
expect(result.ok).toBe(true);
|
|
301
|
+
if (result.ok) {
|
|
302
|
+
expect(result.value.skipped).toContain(".mcp.json");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const content = readFileSync(join(tmpDir, ".mcp.json"), "utf-8");
|
|
306
|
+
expect(JSON.parse(content)).toEqual({ custom: true });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ── Agent instruction files ───────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
test("creates CLAUDE.md at repo root", async () => {
|
|
312
|
+
const result = await bootstrap(tmpDir);
|
|
313
|
+
expect(result.ok).toBe(true);
|
|
314
|
+
|
|
315
|
+
const claudePath = join(tmpDir, "CLAUDE.md");
|
|
316
|
+
expect(existsSync(claudePath)).toBe(true);
|
|
317
|
+
|
|
318
|
+
const content = readFileSync(claudePath, "utf-8");
|
|
319
|
+
expect(content).toContain("# CLAUDE.md");
|
|
320
|
+
expect(content).toContain("constitution.md");
|
|
321
|
+
expect(content).toContain("brainstorm");
|
|
322
|
+
expect(content).toContain("getContext");
|
|
323
|
+
expect(content).toContain("maina verify");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("creates GEMINI.md at repo root", async () => {
|
|
327
|
+
const result = await bootstrap(tmpDir);
|
|
328
|
+
expect(result.ok).toBe(true);
|
|
329
|
+
|
|
330
|
+
const geminiPath = join(tmpDir, "GEMINI.md");
|
|
331
|
+
expect(existsSync(geminiPath)).toBe(true);
|
|
332
|
+
|
|
333
|
+
const content = readFileSync(geminiPath, "utf-8");
|
|
334
|
+
expect(content).toContain("# GEMINI.md");
|
|
335
|
+
expect(content).toContain("constitution.md");
|
|
336
|
+
expect(content).toContain("brainstorm");
|
|
337
|
+
expect(content).toContain("getContext");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("creates .cursorrules at repo root", async () => {
|
|
341
|
+
const result = await bootstrap(tmpDir);
|
|
342
|
+
expect(result.ok).toBe(true);
|
|
343
|
+
|
|
344
|
+
const cursorPath = join(tmpDir, ".cursorrules");
|
|
345
|
+
expect(existsSync(cursorPath)).toBe(true);
|
|
346
|
+
|
|
347
|
+
const content = readFileSync(cursorPath, "utf-8");
|
|
348
|
+
expect(content).toContain("Cursor Rules");
|
|
349
|
+
expect(content).toContain("constitution.md");
|
|
350
|
+
expect(content).toContain("brainstorm");
|
|
351
|
+
expect(content).toContain("maina verify");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("does not overwrite existing agent files", async () => {
|
|
355
|
+
writeFileSync(join(tmpDir, "CLAUDE.md"), "# My Custom CLAUDE.md\n");
|
|
356
|
+
writeFileSync(join(tmpDir, "GEMINI.md"), "# My Custom GEMINI.md\n");
|
|
357
|
+
writeFileSync(join(tmpDir, ".cursorrules"), "# My Custom Rules\n");
|
|
358
|
+
|
|
359
|
+
const result = await bootstrap(tmpDir);
|
|
360
|
+
expect(result.ok).toBe(true);
|
|
361
|
+
if (result.ok) {
|
|
362
|
+
expect(result.value.skipped).toContain("CLAUDE.md");
|
|
363
|
+
expect(result.value.skipped).toContain("GEMINI.md");
|
|
364
|
+
expect(result.value.skipped).toContain(".cursorrules");
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
expect(readFileSync(join(tmpDir, "CLAUDE.md"), "utf-8")).toBe(
|
|
368
|
+
"# My Custom CLAUDE.md\n",
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── Workflow order in agent files ─────────────────────────────────────
|
|
373
|
+
|
|
374
|
+
test("AGENTS.md includes workflow order", async () => {
|
|
375
|
+
const result = await bootstrap(tmpDir);
|
|
376
|
+
expect(result.ok).toBe(true);
|
|
377
|
+
|
|
378
|
+
const content = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
|
|
379
|
+
expect(content).toContain("Workflow Order");
|
|
380
|
+
expect(content).toContain("brainstorm");
|
|
381
|
+
expect(content).toContain("ticket");
|
|
382
|
+
expect(content).toContain("implement");
|
|
383
|
+
expect(content).toContain("verify");
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("copilot instructions include workflow order", async () => {
|
|
387
|
+
const result = await bootstrap(tmpDir);
|
|
388
|
+
expect(result.ok).toBe(true);
|
|
389
|
+
|
|
390
|
+
const content = readFileSync(
|
|
391
|
+
join(tmpDir, ".github", "copilot-instructions.md"),
|
|
392
|
+
"utf-8",
|
|
393
|
+
);
|
|
394
|
+
expect(content).toContain("Workflow Order");
|
|
395
|
+
expect(content).toContain("brainstorm");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ── MCP tools in agent files ──────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
test("AGENTS.md includes MCP tools table", async () => {
|
|
401
|
+
const result = await bootstrap(tmpDir);
|
|
402
|
+
expect(result.ok).toBe(true);
|
|
403
|
+
|
|
404
|
+
const content = readFileSync(join(tmpDir, "AGENTS.md"), "utf-8");
|
|
405
|
+
expect(content).toContain("MCP Tools");
|
|
406
|
+
expect(content).toContain("getContext");
|
|
407
|
+
expect(content).toContain("checkSlop");
|
|
408
|
+
expect(content).toContain("reviewCode");
|
|
409
|
+
expect(content).toContain("suggestTests");
|
|
410
|
+
expect(content).toContain("explainModule");
|
|
411
|
+
expect(content).toContain("analyzeFeature");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ── aiGenerate option ─────────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
test("aiGenerate defaults to false (backward compatible)", async () => {
|
|
417
|
+
const result = await bootstrap(tmpDir);
|
|
418
|
+
expect(result.ok).toBe(true);
|
|
419
|
+
if (result.ok) {
|
|
420
|
+
// Without aiGenerate, should not have AI-generated constitution
|
|
421
|
+
expect(result.value.aiGenerated).toBeFalsy();
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("aiGenerate falls back to static template when AI unavailable", async () => {
|
|
426
|
+
// No API key in env, AI will fail
|
|
427
|
+
const result = await bootstrap(tmpDir, { aiGenerate: true });
|
|
428
|
+
expect(result.ok).toBe(true);
|
|
429
|
+
if (result.ok) {
|
|
430
|
+
// Constitution should still be created (fallback to static)
|
|
431
|
+
expect(result.value.created).toContain(".maina/constitution.md");
|
|
432
|
+
const content = readFileSync(
|
|
433
|
+
join(tmpDir, ".maina", "constitution.md"),
|
|
434
|
+
"utf-8",
|
|
435
|
+
);
|
|
436
|
+
expect(content).toContain("# Project Constitution");
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── Complete file manifest ────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
test("creates all expected files in fresh directory", async () => {
|
|
443
|
+
const result = await bootstrap(tmpDir);
|
|
444
|
+
expect(result.ok).toBe(true);
|
|
445
|
+
if (result.ok) {
|
|
446
|
+
const expectedFiles = [
|
|
447
|
+
".maina/constitution.md",
|
|
448
|
+
".maina/prompts/review.md",
|
|
449
|
+
".maina/prompts/commit.md",
|
|
450
|
+
"AGENTS.md",
|
|
451
|
+
".github/workflows/maina-ci.yml",
|
|
452
|
+
".github/copilot-instructions.md",
|
|
453
|
+
".mcp.json",
|
|
454
|
+
"CLAUDE.md",
|
|
455
|
+
"GEMINI.md",
|
|
456
|
+
".cursorrules",
|
|
457
|
+
];
|
|
458
|
+
for (const f of expectedFiles) {
|
|
459
|
+
expect(result.value.created).toContain(f);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
});
|
|
279
463
|
});
|