@mainahq/core 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/src/ai/__tests__/delegation.test.ts +105 -0
- package/src/ai/delegation.ts +111 -0
- package/src/ai/try-generate.ts +17 -0
- package/src/cloud/__tests__/auth.test.ts +164 -0
- package/src/cloud/__tests__/client.test.ts +253 -0
- package/src/cloud/auth.ts +232 -0
- package/src/cloud/client.ts +190 -0
- package/src/cloud/types.ts +106 -0
- package/src/context/relevance.ts +5 -0
- package/src/context/retrieval.ts +3 -0
- package/src/context/semantic.ts +3 -0
- package/src/feedback/__tests__/trace-analysis.test.ts +98 -0
- package/src/feedback/trace-analysis.ts +153 -0
- package/src/index.ts +55 -0
- package/src/init/__tests__/init.test.ts +51 -0
- package/src/init/index.ts +43 -0
- package/src/language/__tests__/detect.test.ts +61 -1
- package/src/language/__tests__/profile.test.ts +68 -1
- package/src/language/detect.ts +33 -3
- package/src/language/profile.ts +67 -2
- package/src/ticket/index.ts +5 -0
- package/src/verify/__tests__/consistency.test.ts +98 -0
- package/src/verify/__tests__/lighthouse.test.ts +215 -0
- package/src/verify/__tests__/linters/checkstyle.test.ts +23 -0
- package/src/verify/__tests__/linters/dotnet-format.test.ts +18 -0
- package/src/verify/__tests__/pipeline.test.ts +21 -2
- package/src/verify/__tests__/typecheck.test.ts +160 -0
- package/src/verify/__tests__/zap.test.ts +188 -0
- package/src/verify/consistency.ts +199 -0
- package/src/verify/detect.ts +13 -1
- package/src/verify/lighthouse.ts +173 -0
- package/src/verify/linters/checkstyle.ts +41 -0
- package/src/verify/linters/dotnet-format.ts +37 -0
- package/src/verify/pipeline.ts +20 -2
- package/src/verify/syntax-guard.ts +8 -0
- package/src/verify/typecheck.ts +178 -0
- package/src/verify/zap.ts +189 -0
package/src/ticket/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface TicketOptions {
|
|
|
16
16
|
body: string;
|
|
17
17
|
labels?: string[];
|
|
18
18
|
cwd?: string;
|
|
19
|
+
repo?: string; // Cross-repo: "owner/name" for gh --repo flag
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export interface TicketResult {
|
|
@@ -156,6 +157,10 @@ export async function createTicket(
|
|
|
156
157
|
args.push("--label", options.labels.join(","));
|
|
157
158
|
}
|
|
158
159
|
|
|
160
|
+
if (options.repo) {
|
|
161
|
+
args.push("--repo", options.repo);
|
|
162
|
+
}
|
|
163
|
+
|
|
159
164
|
const { exitCode, stdout, stderr } = await deps.spawn(args, {
|
|
160
165
|
cwd: options.cwd,
|
|
161
166
|
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for cross-function consistency checking.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that checkConsistency() catches cases where related functions
|
|
5
|
+
* use inconsistent patterns (e.g., calling isURL but not isIP).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
describe("checkConsistency", () => {
|
|
14
|
+
let testDir: string;
|
|
15
|
+
let mainaDir: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
testDir = join(tmpdir(), `maina-consistency-test-${Date.now()}`);
|
|
19
|
+
mainaDir = join(testDir, ".maina");
|
|
20
|
+
mkdirSync(mainaDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("should detect inconsistent validator usage from spec constraints", async () => {
|
|
28
|
+
// Spec says "use isIP for IP hosts"
|
|
29
|
+
writeFileSync(
|
|
30
|
+
join(mainaDir, "constitution.md"),
|
|
31
|
+
"## Rules\n- Always validate IP addresses with isIP when checking hosts\n",
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const code = [
|
|
35
|
+
"function processUrl(host: string) {",
|
|
36
|
+
" if (isURL(host)) {",
|
|
37
|
+
" // should also call isIP but doesn't",
|
|
38
|
+
" return fetch(host);",
|
|
39
|
+
" }",
|
|
40
|
+
"}",
|
|
41
|
+
].join("\n");
|
|
42
|
+
|
|
43
|
+
writeFileSync(join(testDir, "handler.ts"), code);
|
|
44
|
+
|
|
45
|
+
const { checkConsistency } = await import("../consistency");
|
|
46
|
+
const result = await checkConsistency(["handler.ts"], testDir, mainaDir);
|
|
47
|
+
|
|
48
|
+
expect(result.rulesChecked).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("should return empty findings when no spec exists", async () => {
|
|
52
|
+
writeFileSync(join(testDir, "clean.ts"), "const x = 1;\n");
|
|
53
|
+
|
|
54
|
+
const { checkConsistency } = await import("../consistency");
|
|
55
|
+
const result = await checkConsistency(
|
|
56
|
+
["clean.ts"],
|
|
57
|
+
testDir,
|
|
58
|
+
join(testDir, "nonexistent-maina"),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(result.findings).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("should detect heuristic pattern: validator used on one path but not another", async () => {
|
|
65
|
+
const code = [
|
|
66
|
+
'import { isValid } from "./validators";',
|
|
67
|
+
"",
|
|
68
|
+
"function handleA(input: string) {",
|
|
69
|
+
" if (isValid(input)) return process(input);",
|
|
70
|
+
"}",
|
|
71
|
+
"",
|
|
72
|
+
"function handleB(input: string) {",
|
|
73
|
+
" // Missing isValid check — inconsistent with handleA",
|
|
74
|
+
" return process(input);",
|
|
75
|
+
"}",
|
|
76
|
+
].join("\n");
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(testDir, "handlers.ts"), code);
|
|
79
|
+
|
|
80
|
+
const { checkConsistency } = await import("../consistency");
|
|
81
|
+
const result = await checkConsistency(["handlers.ts"], testDir, mainaDir);
|
|
82
|
+
|
|
83
|
+
// Heuristic mode should at least check for patterns
|
|
84
|
+
expect(result.rulesChecked).toBeGreaterThanOrEqual(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should return ConsistencyResult with correct shape", async () => {
|
|
88
|
+
writeFileSync(join(testDir, "simple.ts"), "const x = 1;\n");
|
|
89
|
+
|
|
90
|
+
const { checkConsistency } = await import("../consistency");
|
|
91
|
+
const result = await checkConsistency(["simple.ts"], testDir, mainaDir);
|
|
92
|
+
|
|
93
|
+
expect(result).toHaveProperty("findings");
|
|
94
|
+
expect(result).toHaveProperty("rulesChecked");
|
|
95
|
+
expect(Array.isArray(result.findings)).toBe(true);
|
|
96
|
+
expect(typeof result.rulesChecked).toBe("number");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseLighthouseJson, runLighthouse } from "../lighthouse";
|
|
3
|
+
|
|
4
|
+
// ─── parseLighthouseJson ──────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("parseLighthouseJson", () => {
|
|
7
|
+
it("should return empty findings when all scores are above thresholds", () => {
|
|
8
|
+
const json = JSON.stringify({
|
|
9
|
+
categories: {
|
|
10
|
+
performance: { score: 0.95 },
|
|
11
|
+
accessibility: { score: 0.92 },
|
|
12
|
+
seo: { score: 0.98 },
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const result = parseLighthouseJson(json);
|
|
17
|
+
expect(result.findings).toEqual([]);
|
|
18
|
+
expect(result.scores).toEqual({
|
|
19
|
+
performance: 95,
|
|
20
|
+
accessibility: 92,
|
|
21
|
+
seo: 98,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should generate findings when scores fall below default thresholds", () => {
|
|
26
|
+
const json = JSON.stringify({
|
|
27
|
+
categories: {
|
|
28
|
+
performance: { score: 0.72 },
|
|
29
|
+
accessibility: { score: 0.85 },
|
|
30
|
+
seo: { score: 0.6 },
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const result = parseLighthouseJson(json);
|
|
35
|
+
expect(result.findings.length).toBe(3);
|
|
36
|
+
|
|
37
|
+
const perfFinding = result.findings.find((f) =>
|
|
38
|
+
f.message.includes("performance"),
|
|
39
|
+
);
|
|
40
|
+
expect(perfFinding?.tool).toBe("lighthouse");
|
|
41
|
+
expect(perfFinding?.severity).toBe("warning");
|
|
42
|
+
expect(perfFinding?.message).toContain("72");
|
|
43
|
+
expect(perfFinding?.message).toContain("90");
|
|
44
|
+
|
|
45
|
+
const a11yFinding = result.findings.find((f) =>
|
|
46
|
+
f.message.includes("accessibility"),
|
|
47
|
+
);
|
|
48
|
+
expect(a11yFinding?.severity).toBe("warning");
|
|
49
|
+
|
|
50
|
+
const seoFinding = result.findings.find((f) => f.message.includes("seo"));
|
|
51
|
+
expect(seoFinding?.severity).toBe("warning");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should use custom thresholds when provided", () => {
|
|
55
|
+
const json = JSON.stringify({
|
|
56
|
+
categories: {
|
|
57
|
+
performance: { score: 0.72 },
|
|
58
|
+
accessibility: { score: 0.85 },
|
|
59
|
+
seo: { score: 0.6 },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = parseLighthouseJson(json, {
|
|
64
|
+
performance: 70,
|
|
65
|
+
accessibility: 80,
|
|
66
|
+
seo: 50,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// All scores are above custom thresholds
|
|
70
|
+
expect(result.findings).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should generate error severity for very low scores", () => {
|
|
74
|
+
const json = JSON.stringify({
|
|
75
|
+
categories: {
|
|
76
|
+
performance: { score: 0.3 },
|
|
77
|
+
accessibility: { score: 0.4 },
|
|
78
|
+
seo: { score: 0.25 },
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = parseLighthouseJson(json);
|
|
83
|
+
for (const finding of result.findings) {
|
|
84
|
+
expect(finding.severity).toBe("error");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should handle missing categories gracefully", () => {
|
|
89
|
+
const json = JSON.stringify({
|
|
90
|
+
categories: {
|
|
91
|
+
performance: { score: 0.95 },
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = parseLighthouseJson(json);
|
|
96
|
+
expect(result.findings).toEqual([]);
|
|
97
|
+
expect(result.scores).toEqual({ performance: 95 });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should return empty findings for invalid JSON", () => {
|
|
101
|
+
const result = parseLighthouseJson("not valid json {{{");
|
|
102
|
+
expect(result.findings).toEqual([]);
|
|
103
|
+
expect(result.scores).toEqual({});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should return empty findings for malformed structure", () => {
|
|
107
|
+
const result = parseLighthouseJson(JSON.stringify({ unexpected: true }));
|
|
108
|
+
expect(result.findings).toEqual([]);
|
|
109
|
+
expect(result.scores).toEqual({});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should handle categories with null scores", () => {
|
|
113
|
+
const json = JSON.stringify({
|
|
114
|
+
categories: {
|
|
115
|
+
performance: { score: null },
|
|
116
|
+
accessibility: { score: 0.95 },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = parseLighthouseJson(json);
|
|
121
|
+
expect(result.scores).toEqual({ accessibility: 95 });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should include the URL in findings when provided", () => {
|
|
125
|
+
const json = JSON.stringify({
|
|
126
|
+
requestedUrl: "https://example.com",
|
|
127
|
+
categories: {
|
|
128
|
+
performance: { score: 0.5 },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const result = parseLighthouseJson(json);
|
|
133
|
+
expect(result.findings.length).toBe(1);
|
|
134
|
+
expect(result.findings[0]?.file).toBe("https://example.com");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("should handle best-practices category", () => {
|
|
138
|
+
const json = JSON.stringify({
|
|
139
|
+
categories: {
|
|
140
|
+
"best-practices": { score: 0.7 },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const result = parseLighthouseJson(json);
|
|
145
|
+
// best-practices is not in default thresholds, so no findings
|
|
146
|
+
expect(result.findings).toEqual([]);
|
|
147
|
+
expect(result.scores).toEqual({ "best-practices": 70 });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should generate findings for best-practices when threshold set", () => {
|
|
151
|
+
const json = JSON.stringify({
|
|
152
|
+
categories: {
|
|
153
|
+
"best-practices": { score: 0.7 },
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const result = parseLighthouseJson(json, {
|
|
158
|
+
"best-practices": 90,
|
|
159
|
+
});
|
|
160
|
+
expect(result.findings.length).toBe(1);
|
|
161
|
+
expect(result.findings[0]?.message).toContain("best-practices");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── runLighthouse ────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("runLighthouse", () => {
|
|
168
|
+
it("should skip when lighthouse is not available", async () => {
|
|
169
|
+
const result = await runLighthouse({
|
|
170
|
+
url: "https://example.com",
|
|
171
|
+
cwd: "/tmp",
|
|
172
|
+
available: false,
|
|
173
|
+
});
|
|
174
|
+
expect(result.findings).toEqual([]);
|
|
175
|
+
expect(result.skipped).toBe(true);
|
|
176
|
+
expect(result.scores).toEqual({});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should skip when no URL is provided", async () => {
|
|
180
|
+
const result = await runLighthouse({
|
|
181
|
+
url: "",
|
|
182
|
+
cwd: "/tmp",
|
|
183
|
+
available: true,
|
|
184
|
+
});
|
|
185
|
+
expect(result.findings).toEqual([]);
|
|
186
|
+
expect(result.skipped).toBe(true);
|
|
187
|
+
expect(result.scores).toEqual({});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("should return correct result shape", async () => {
|
|
191
|
+
const result = await runLighthouse({
|
|
192
|
+
url: "https://example.com",
|
|
193
|
+
cwd: "/tmp",
|
|
194
|
+
available: false,
|
|
195
|
+
});
|
|
196
|
+
expect(result).toHaveProperty("findings");
|
|
197
|
+
expect(result).toHaveProperty("skipped");
|
|
198
|
+
expect(result).toHaveProperty("scores");
|
|
199
|
+
expect(Array.isArray(result.findings)).toBe(true);
|
|
200
|
+
expect(typeof result.skipped).toBe("boolean");
|
|
201
|
+
expect(typeof result.scores).toBe("object");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should accept custom thresholds without crashing", async () => {
|
|
205
|
+
const result = await runLighthouse({
|
|
206
|
+
url: "https://example.com",
|
|
207
|
+
cwd: "/tmp",
|
|
208
|
+
available: false,
|
|
209
|
+
thresholds: { performance: 80, accessibility: 85 },
|
|
210
|
+
});
|
|
211
|
+
expect(result).toHaveProperty("findings");
|
|
212
|
+
expect(result).toHaveProperty("skipped");
|
|
213
|
+
expect(result).toHaveProperty("scores");
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseCheckstyleOutput } from "../../linters/checkstyle";
|
|
3
|
+
|
|
4
|
+
describe("parseCheckstyleOutput", () => {
|
|
5
|
+
it("should parse checkstyle XML", () => {
|
|
6
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
7
|
+
<checkstyle>
|
|
8
|
+
<file name="src/Main.java">
|
|
9
|
+
<error line="10" column="5" severity="error" message="Missing Javadoc comment" source="com.puppycrawl.tools.checkstyle.checks.javadoc"/>
|
|
10
|
+
<error line="20" severity="warning" message="Line is longer than 120 characters" source="com.puppycrawl.tools.checkstyle.checks.sizes"/>
|
|
11
|
+
</file>
|
|
12
|
+
</checkstyle>`;
|
|
13
|
+
const diagnostics = parseCheckstyleOutput(xml);
|
|
14
|
+
expect(diagnostics).toHaveLength(2);
|
|
15
|
+
expect(diagnostics[0]?.file).toBe("src/Main.java");
|
|
16
|
+
expect(diagnostics[0]?.severity).toBe("error");
|
|
17
|
+
expect(diagnostics[1]?.severity).toBe("warning");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should handle empty XML", () => {
|
|
21
|
+
expect(parseCheckstyleOutput("")).toHaveLength(0);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { parseDotnetFormatOutput } from "../../linters/dotnet-format";
|
|
3
|
+
|
|
4
|
+
describe("parseDotnetFormatOutput", () => {
|
|
5
|
+
it("should parse dotnet format diagnostics", () => {
|
|
6
|
+
const output = `Program.cs(10,5): warning CS1234: Unused variable 'x'
|
|
7
|
+
Service.cs(20,1): error CS0246: The type or namespace name 'Foo' could not be found`;
|
|
8
|
+
const diagnostics = parseDotnetFormatOutput(output);
|
|
9
|
+
expect(diagnostics).toHaveLength(2);
|
|
10
|
+
expect(diagnostics[0]?.file).toBe("Program.cs");
|
|
11
|
+
expect(diagnostics[0]?.severity).toBe("warning");
|
|
12
|
+
expect(diagnostics[1]?.severity).toBe("error");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should handle empty output", () => {
|
|
16
|
+
expect(parseDotnetFormatOutput("")).toHaveLength(0);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -33,6 +33,11 @@ let mockStagedFiles: string[] = ["src/app.ts"];
|
|
|
33
33
|
let callOrder: string[] = [];
|
|
34
34
|
|
|
35
35
|
// Mock the modules
|
|
36
|
+
// NOTE: These mocks are intentionally minimal — they only export what pipeline.ts
|
|
37
|
+
// needs. Tests MUST be run via `bun run test` (scripts/test-isolated.ts) which
|
|
38
|
+
// runs each test file in its own subprocess, preventing mock.module() bleed.
|
|
39
|
+
// Running `bun test` directly (single process) will cause cross-file mock leaks.
|
|
40
|
+
|
|
36
41
|
mock.module("../syntax-guard", () => ({
|
|
37
42
|
syntaxGuard: async (..._args: unknown[]) => {
|
|
38
43
|
callOrder.push("syntaxGuard");
|
|
@@ -140,6 +145,20 @@ mock.module("../ai-review", () => ({
|
|
|
140
145
|
},
|
|
141
146
|
}));
|
|
142
147
|
|
|
148
|
+
mock.module("../typecheck", () => ({
|
|
149
|
+
runTypecheck: async (..._args: unknown[]) => {
|
|
150
|
+
callOrder.push("runTypecheck");
|
|
151
|
+
return { findings: [], duration: 0, tool: "tsc", skipped: true };
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
mock.module("../consistency", () => ({
|
|
156
|
+
checkConsistency: async (..._args: unknown[]) => {
|
|
157
|
+
callOrder.push("checkConsistency");
|
|
158
|
+
return { findings: [], rulesChecked: 0 };
|
|
159
|
+
},
|
|
160
|
+
}));
|
|
161
|
+
|
|
143
162
|
mock.module("../../language/detect", () => ({
|
|
144
163
|
detectLanguages: (..._args: unknown[]) => ["typescript"],
|
|
145
164
|
}));
|
|
@@ -253,8 +272,8 @@ describe("VerifyPipeline", () => {
|
|
|
253
272
|
expect(callOrder).toContain("runTrivy");
|
|
254
273
|
expect(callOrder).toContain("runSecretlint");
|
|
255
274
|
|
|
256
|
-
//
|
|
257
|
-
expect(result.tools).toHaveLength(
|
|
275
|
+
// 10 tool reports (slop + semgrep + trivy + secretlint + sonarqube + stryker + diff-cover + typecheck + consistency + ai-review)
|
|
276
|
+
expect(result.tools).toHaveLength(10);
|
|
258
277
|
expect(result.findings).toHaveLength(3);
|
|
259
278
|
});
|
|
260
279
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for built-in type checking in the verify pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that runTypecheck() spawns the correct language-specific
|
|
5
|
+
* type checker and parses its output into Finding[].
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
9
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
describe("runTypecheck", () => {
|
|
14
|
+
let testDir: string;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
testDir = join(tmpdir(), `maina-typecheck-test-${Date.now()}`);
|
|
18
|
+
mkdirSync(testDir, { recursive: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("should parse tsc --noEmit output into Finding[]", async () => {
|
|
26
|
+
// Create a tsconfig.json and install typescript locally
|
|
27
|
+
writeFileSync(
|
|
28
|
+
join(testDir, "tsconfig.json"),
|
|
29
|
+
JSON.stringify({
|
|
30
|
+
compilerOptions: { strict: true, noEmit: true },
|
|
31
|
+
include: ["*.ts"],
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
// Install typescript locally so tsc is in node_modules/.bin
|
|
35
|
+
const install = Bun.spawnSync(["bun", "add", "typescript"], {
|
|
36
|
+
cwd: testDir,
|
|
37
|
+
});
|
|
38
|
+
if (install.exitCode !== 0) {
|
|
39
|
+
// Skip if we can't install (CI without network, etc.)
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create a file with a type error
|
|
44
|
+
writeFileSync(
|
|
45
|
+
join(testDir, "bad.ts"),
|
|
46
|
+
'const x: number = "not a number";\n',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const { runTypecheck } = await import("../typecheck");
|
|
50
|
+
const result = await runTypecheck(["bad.ts"], testDir);
|
|
51
|
+
|
|
52
|
+
expect(result.findings.length).toBeGreaterThan(0);
|
|
53
|
+
const first = result.findings[0];
|
|
54
|
+
if (!first) throw new Error("Expected at least one finding");
|
|
55
|
+
expect(first.tool).toBe("tsc");
|
|
56
|
+
expect(first.severity).toBe("error");
|
|
57
|
+
expect(first.file).toContain("bad.ts");
|
|
58
|
+
expect(first.line).toBeGreaterThan(0);
|
|
59
|
+
expect(result.duration).toBeGreaterThanOrEqual(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("should return empty findings for clean file", async () => {
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(testDir, "tsconfig.json"),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
compilerOptions: { strict: true, noEmit: true },
|
|
67
|
+
include: ["*.ts"],
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
Bun.spawnSync(["bun", "add", "typescript"], { cwd: testDir });
|
|
71
|
+
|
|
72
|
+
writeFileSync(join(testDir, "good.ts"), "const x: number = 42;\n");
|
|
73
|
+
|
|
74
|
+
const { runTypecheck } = await import("../typecheck");
|
|
75
|
+
const result = await runTypecheck(["good.ts"], testDir);
|
|
76
|
+
|
|
77
|
+
expect(result.findings).toEqual([]);
|
|
78
|
+
expect(result.tool).toBe("tsc");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should skip with info when tsc is not found", async () => {
|
|
82
|
+
// Use a non-existent tool path
|
|
83
|
+
const { runTypecheck } = await import("../typecheck");
|
|
84
|
+
const result = await runTypecheck(["file.ts"], testDir, {
|
|
85
|
+
command: "nonexistent-tsc-binary",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.findings).toEqual([]);
|
|
89
|
+
expect(result.skipped).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("should detect language and use appropriate checker", async () => {
|
|
93
|
+
const { getTypecheckCommand } = await import("../typecheck");
|
|
94
|
+
|
|
95
|
+
expect(getTypecheckCommand("typescript")).toEqual(
|
|
96
|
+
expect.objectContaining({ tool: "tsc" }),
|
|
97
|
+
);
|
|
98
|
+
expect(getTypecheckCommand("python")).toEqual(
|
|
99
|
+
expect.objectContaining({ tool: "mypy" }),
|
|
100
|
+
);
|
|
101
|
+
expect(getTypecheckCommand("go")).toEqual(
|
|
102
|
+
expect.objectContaining({ tool: "go-vet" }),
|
|
103
|
+
);
|
|
104
|
+
expect(getTypecheckCommand("rust")).toEqual(
|
|
105
|
+
expect.objectContaining({ tool: "cargo-check" }),
|
|
106
|
+
);
|
|
107
|
+
expect(getTypecheckCommand("csharp")).toEqual(
|
|
108
|
+
expect.objectContaining({ tool: "dotnet-build" }),
|
|
109
|
+
);
|
|
110
|
+
expect(getTypecheckCommand("java")).toEqual(
|
|
111
|
+
expect.objectContaining({ tool: "javac" }),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should parse multiple errors from tsc output", async () => {
|
|
116
|
+
const { parseTscOutput } = await import("../typecheck");
|
|
117
|
+
|
|
118
|
+
const tscOutput = [
|
|
119
|
+
"src/foo.ts(10,5): error TS2322: Type 'string' is not assignable to type 'number'.",
|
|
120
|
+
"src/bar.ts(3,1): error TS2304: Cannot find name 'x'.",
|
|
121
|
+
].join("\n");
|
|
122
|
+
|
|
123
|
+
const findings = parseTscOutput(tscOutput);
|
|
124
|
+
|
|
125
|
+
expect(findings).toHaveLength(2);
|
|
126
|
+
expect(findings[0]).toEqual({
|
|
127
|
+
tool: "tsc",
|
|
128
|
+
file: "src/foo.ts",
|
|
129
|
+
line: 10,
|
|
130
|
+
column: 5,
|
|
131
|
+
message: "TS2322: Type 'string' is not assignable to type 'number'.",
|
|
132
|
+
severity: "error",
|
|
133
|
+
ruleId: "TS2322",
|
|
134
|
+
});
|
|
135
|
+
expect(findings[1]).toEqual({
|
|
136
|
+
tool: "tsc",
|
|
137
|
+
file: "src/bar.ts",
|
|
138
|
+
line: 3,
|
|
139
|
+
column: 1,
|
|
140
|
+
message: "TS2304: Cannot find name 'x'.",
|
|
141
|
+
severity: "error",
|
|
142
|
+
ruleId: "TS2304",
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should return empty for empty tsc output", async () => {
|
|
147
|
+
const { parseTscOutput } = await import("../typecheck");
|
|
148
|
+
const findings = parseTscOutput("");
|
|
149
|
+
expect(findings).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should handle no tsconfig.json gracefully", async () => {
|
|
153
|
+
// No tsconfig.json in testDir
|
|
154
|
+
const { runTypecheck } = await import("../typecheck");
|
|
155
|
+
const result = await runTypecheck(["file.ts"], testDir);
|
|
156
|
+
|
|
157
|
+
// Should not crash, should skip or return empty
|
|
158
|
+
expect(result.skipped).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
});
|