@mainahq/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- 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/feedback/__tests__/trace-analysis.test.ts +98 -0
- package/src/feedback/trace-analysis.ts +153 -0
- package/src/index.ts +46 -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 +53 -1
- package/src/language/detect.ts +18 -2
- package/src/language/profile.ts +24 -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__/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 +5 -1
- package/src/verify/lighthouse.ts +173 -0
- package/src/verify/pipeline.ts +20 -2
- package/src/verify/typecheck.ts +178 -0
- package/src/verify/zap.ts +189 -0
package/src/init/index.ts
CHANGED
|
@@ -291,6 +291,38 @@ function getFileManifest(stack: DetectedStack): FileEntry[] {
|
|
|
291
291
|
];
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
/**
|
|
295
|
+
* Build a sensible default biome.json for projects without a linter.
|
|
296
|
+
* Ensures every maina-initialized project has at least one real linter.
|
|
297
|
+
*/
|
|
298
|
+
function buildBiomeConfig(): string {
|
|
299
|
+
return JSON.stringify(
|
|
300
|
+
{
|
|
301
|
+
$schema: "https://biomejs.dev/schemas/2.0.0/schema.json",
|
|
302
|
+
linter: {
|
|
303
|
+
enabled: true,
|
|
304
|
+
rules: {
|
|
305
|
+
recommended: true,
|
|
306
|
+
correctness: {
|
|
307
|
+
noUnusedVariables: "warn",
|
|
308
|
+
noUnusedImports: "warn",
|
|
309
|
+
},
|
|
310
|
+
style: {
|
|
311
|
+
useConst: "error",
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
formatter: {
|
|
316
|
+
enabled: true,
|
|
317
|
+
indentStyle: "tab",
|
|
318
|
+
lineWidth: 100,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
null,
|
|
322
|
+
2,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
294
326
|
/** Directories to create even if they have no files */
|
|
295
327
|
const EXTRA_DIRS = [".maina/hooks"];
|
|
296
328
|
|
|
@@ -345,6 +377,17 @@ export async function bootstrap(
|
|
|
345
377
|
}
|
|
346
378
|
}
|
|
347
379
|
|
|
380
|
+
// ── Auto-configure Biome if no linter detected ──────────────────
|
|
381
|
+
if (detectedStack.linter === "unknown") {
|
|
382
|
+
const biomePath = join(repoRoot, "biome.json");
|
|
383
|
+
if (!existsSync(biomePath) || force) {
|
|
384
|
+
const biomeConfig = buildBiomeConfig();
|
|
385
|
+
writeFileSync(biomePath, biomeConfig, "utf-8");
|
|
386
|
+
created.push("biome.json");
|
|
387
|
+
detectedStack.linter = "biome";
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
348
391
|
return {
|
|
349
392
|
ok: true,
|
|
350
393
|
value: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { detectLanguages } from "../detect";
|
|
4
|
+
import { detectFileLanguage, detectLanguages } from "../detect";
|
|
5
5
|
|
|
6
6
|
describe("detectLanguages", () => {
|
|
7
7
|
const testDir = join(import.meta.dir, "__fixtures__/detect");
|
|
@@ -74,4 +74,64 @@ describe("detectLanguages", () => {
|
|
|
74
74
|
expect(result).toContain("python");
|
|
75
75
|
cleanup();
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it("should detect PHP from composer.json", () => {
|
|
79
|
+
setup({ "composer.json": '{"require": {"php": ">=8.1"}}' });
|
|
80
|
+
const result = detectLanguages(testDir);
|
|
81
|
+
expect(result).toContain("php");
|
|
82
|
+
cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("should detect PHP from composer.lock", () => {
|
|
86
|
+
setup({ "composer.lock": '{"packages": []}' });
|
|
87
|
+
const result = detectLanguages(testDir);
|
|
88
|
+
expect(result).toContain("php");
|
|
89
|
+
cleanup();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("detectFileLanguage", () => {
|
|
94
|
+
it("should detect TypeScript from .ts extension", () => {
|
|
95
|
+
expect(detectFileLanguage("src/index.ts")).toBe("typescript");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should detect TypeScript from .tsx extension", () => {
|
|
99
|
+
expect(detectFileLanguage("components/App.tsx")).toBe("typescript");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should detect Python from .py extension", () => {
|
|
103
|
+
expect(detectFileLanguage("app/main.py")).toBe("python");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should detect Go from .go extension", () => {
|
|
107
|
+
expect(detectFileLanguage("cmd/server.go")).toBe("go");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should detect Rust from .rs extension", () => {
|
|
111
|
+
expect(detectFileLanguage("src/lib.rs")).toBe("rust");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should detect C# from .cs extension", () => {
|
|
115
|
+
expect(detectFileLanguage("Controllers/HomeController.cs")).toBe("csharp");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("should detect Java from .java extension", () => {
|
|
119
|
+
expect(detectFileLanguage("src/Main.java")).toBe("java");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should detect PHP from .php extension", () => {
|
|
123
|
+
expect(detectFileLanguage("src/Controller.php")).toBe("php");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("should return null for unknown extensions", () => {
|
|
127
|
+
expect(detectFileLanguage("data.csv")).toBeNull();
|
|
128
|
+
expect(detectFileLanguage("README.md")).toBeNull();
|
|
129
|
+
expect(detectFileLanguage("Dockerfile")).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should handle case-insensitive extensions", () => {
|
|
133
|
+
expect(detectFileLanguage("script.PY")).toBe("python");
|
|
134
|
+
expect(detectFileLanguage("main.Go")).toBe("go");
|
|
135
|
+
expect(detectFileLanguage("index.PHP")).toBe("php");
|
|
136
|
+
});
|
|
77
137
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getProfile,
|
|
4
|
+
getSupportedLanguages,
|
|
5
|
+
TYPESCRIPT_PROFILE,
|
|
6
|
+
} from "../profile";
|
|
3
7
|
|
|
4
8
|
describe("LanguageProfile", () => {
|
|
5
9
|
it("should return TypeScript profile by default", () => {
|
|
@@ -63,4 +67,52 @@ describe("LanguageProfile", () => {
|
|
|
63
67
|
expect(getProfile("go").printPattern.test("fmt.Println(x)")).toBe(true);
|
|
64
68
|
expect(getProfile("rust").printPattern.test("println!(x)")).toBe(true);
|
|
65
69
|
});
|
|
70
|
+
|
|
71
|
+
it("should return PHP profile", () => {
|
|
72
|
+
const profile = getProfile("php");
|
|
73
|
+
expect(profile.id).toBe("php");
|
|
74
|
+
expect(profile.displayName).toBe("PHP");
|
|
75
|
+
expect(profile.extensions).toContain(".php");
|
|
76
|
+
expect(profile.syntaxTool).toBe("phpstan");
|
|
77
|
+
expect(profile.commentPrefixes).toContain("//");
|
|
78
|
+
expect(profile.commentPrefixes).toContain("#");
|
|
79
|
+
expect(profile.commentPrefixes).toContain("/*");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should have PHP test file pattern", () => {
|
|
83
|
+
const profile = getProfile("php");
|
|
84
|
+
expect(profile.testFilePattern.test("UserTest.php")).toBe(true);
|
|
85
|
+
expect(profile.testFilePattern.test("tests/UserTest.php")).toBe(true);
|
|
86
|
+
expect(profile.testFilePattern.test("src/User.php")).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should have PHP print/debug patterns", () => {
|
|
90
|
+
const profile = getProfile("php");
|
|
91
|
+
expect(profile.printPattern.test("echo('hello')")).toBe(true);
|
|
92
|
+
expect(profile.printPattern.test("print('hello')")).toBe(true);
|
|
93
|
+
expect(profile.printPattern.test("var_dump($x)")).toBe(true);
|
|
94
|
+
expect(profile.printPattern.test("print_r($arr)")).toBe(true);
|
|
95
|
+
expect(profile.printPattern.test("error_log('msg')")).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("should have PHP lint ignore patterns", () => {
|
|
99
|
+
const profile = getProfile("php");
|
|
100
|
+
expect(profile.lintIgnorePattern.test("@phpstan-ignore")).toBe(true);
|
|
101
|
+
expect(profile.lintIgnorePattern.test("@psalm-suppress")).toBe(true);
|
|
102
|
+
expect(profile.lintIgnorePattern.test("phpcs:ignore")).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("should have PHP import pattern", () => {
|
|
106
|
+
const profile = getProfile("php");
|
|
107
|
+
expect(profile.importPattern.test("use App\\Models\\User;")).toBe(true);
|
|
108
|
+
expect(profile.importPattern.test("require 'vendor/autoload.php';")).toBe(
|
|
109
|
+
true,
|
|
110
|
+
);
|
|
111
|
+
expect(profile.importPattern.test("include 'config.php';")).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("should include php in supported languages", () => {
|
|
115
|
+
const languages = getSupportedLanguages();
|
|
116
|
+
expect(languages).toContain("php");
|
|
117
|
+
});
|
|
66
118
|
});
|
package/src/language/detect.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import type
|
|
6
|
+
import { extname, join } from "node:path";
|
|
7
|
+
import { type LanguageId, PROFILES } from "./profile";
|
|
8
8
|
|
|
9
9
|
const LANGUAGE_MARKERS: Record<LanguageId, string[]> = {
|
|
10
10
|
typescript: ["tsconfig.json", "tsconfig.build.json"],
|
|
@@ -19,6 +19,7 @@ const LANGUAGE_MARKERS: Record<LanguageId, string[]> = {
|
|
|
19
19
|
rust: ["Cargo.toml", "Cargo.lock"],
|
|
20
20
|
csharp: ["global.json", "Directory.Build.props"],
|
|
21
21
|
java: ["pom.xml", "build.gradle", "build.gradle.kts", "settings.gradle"],
|
|
22
|
+
php: ["composer.json", "composer.lock"],
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -82,3 +83,18 @@ export function getPrimaryLanguage(cwd: string): LanguageId {
|
|
|
82
83
|
const languages = detectLanguages(cwd);
|
|
83
84
|
return languages[0] ?? "typescript";
|
|
84
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Detect language for a single file based on its extension.
|
|
89
|
+
* Returns the LanguageId if matched, or null if unknown.
|
|
90
|
+
*/
|
|
91
|
+
export function detectFileLanguage(filePath: string): LanguageId | null {
|
|
92
|
+
const ext = extname(filePath).toLowerCase();
|
|
93
|
+
if (!ext) return null;
|
|
94
|
+
for (const profile of Object.values(PROFILES)) {
|
|
95
|
+
if (profile.extensions.includes(ext)) {
|
|
96
|
+
return profile.id;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
package/src/language/profile.ts
CHANGED
|
@@ -9,7 +9,8 @@ export type LanguageId =
|
|
|
9
9
|
| "go"
|
|
10
10
|
| "rust"
|
|
11
11
|
| "csharp"
|
|
12
|
-
| "java"
|
|
12
|
+
| "java"
|
|
13
|
+
| "php";
|
|
13
14
|
|
|
14
15
|
export interface LanguageProfile {
|
|
15
16
|
id: LanguageId;
|
|
@@ -135,13 +136,34 @@ export const JAVA_PROFILE: LanguageProfile = {
|
|
|
135
136
|
fileGlobs: ["*.java", "*.kt"],
|
|
136
137
|
};
|
|
137
138
|
|
|
138
|
-
const
|
|
139
|
+
export const PHP_PROFILE: LanguageProfile = {
|
|
140
|
+
id: "php",
|
|
141
|
+
displayName: "PHP",
|
|
142
|
+
extensions: [".php"],
|
|
143
|
+
syntaxTool: "phpstan",
|
|
144
|
+
syntaxArgs: (files, _cwd) => [
|
|
145
|
+
"phpstan",
|
|
146
|
+
"analyse",
|
|
147
|
+
"--error-format=json",
|
|
148
|
+
"--no-progress",
|
|
149
|
+
...files,
|
|
150
|
+
],
|
|
151
|
+
commentPrefixes: ["//", "/*", "#"],
|
|
152
|
+
testFilePattern: /(?:Test\.php$|tests\/)/,
|
|
153
|
+
printPattern: /\b(?:echo|print|var_dump|print_r|error_log)\s*\(/,
|
|
154
|
+
lintIgnorePattern: /@phpstan-ignore|@psalm-suppress|phpcs:ignore/,
|
|
155
|
+
importPattern: /^(?:use|require|include)\s+/,
|
|
156
|
+
fileGlobs: ["*.php"],
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const PROFILES: Record<LanguageId, LanguageProfile> = {
|
|
139
160
|
typescript: TYPESCRIPT_PROFILE,
|
|
140
161
|
python: PYTHON_PROFILE,
|
|
141
162
|
go: GO_PROFILE,
|
|
142
163
|
rust: RUST_PROFILE,
|
|
143
164
|
csharp: CSHARP_PROFILE,
|
|
144
165
|
java: JAVA_PROFILE,
|
|
166
|
+
php: PHP_PROFILE,
|
|
145
167
|
};
|
|
146
168
|
|
|
147
169
|
export function getProfile(id: LanguageId): LanguageProfile {
|
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
|
+
});
|
|
@@ -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
|
|