@mainahq/core 0.2.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/README.md +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Visual Verification — screenshot capture and pixel comparison.
|
|
3
|
+
*
|
|
4
|
+
* Uses Playwright CLI for screenshots and a simple pixel diff for comparison.
|
|
5
|
+
* Gracefully skips if Playwright is not installed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { isToolAvailable } from "./detect";
|
|
11
|
+
import type { Finding } from "./diff-filter";
|
|
12
|
+
|
|
13
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface VisualConfig {
|
|
16
|
+
urls: string[];
|
|
17
|
+
threshold: number;
|
|
18
|
+
viewport: { width: number; height: number };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScreenshotOptions {
|
|
22
|
+
viewport?: { width: number; height: number };
|
|
23
|
+
available?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ScreenshotResult {
|
|
27
|
+
captured: boolean;
|
|
28
|
+
skipped: boolean;
|
|
29
|
+
path?: string;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VisualDiffResult {
|
|
34
|
+
diffPixels: number;
|
|
35
|
+
diffPercentage: number;
|
|
36
|
+
totalPixels: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VisualVerifyResult {
|
|
40
|
+
findings: Finding[];
|
|
41
|
+
skipped: boolean;
|
|
42
|
+
screenshotsTaken: number;
|
|
43
|
+
comparisons: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Constants ────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const WEB_FRAMEWORKS = [
|
|
49
|
+
"next",
|
|
50
|
+
"astro",
|
|
51
|
+
"vite",
|
|
52
|
+
"nuxt",
|
|
53
|
+
"remix",
|
|
54
|
+
"gatsby",
|
|
55
|
+
"webpack serve",
|
|
56
|
+
"webpack-dev-server",
|
|
57
|
+
"react-scripts",
|
|
58
|
+
"vue-cli-service",
|
|
59
|
+
"angular",
|
|
60
|
+
"svelte",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const WEB_DEPS = [
|
|
64
|
+
"next",
|
|
65
|
+
"astro",
|
|
66
|
+
"vite",
|
|
67
|
+
"nuxt",
|
|
68
|
+
"@remix-run/dev",
|
|
69
|
+
"gatsby",
|
|
70
|
+
"react-scripts",
|
|
71
|
+
"@vue/cli-service",
|
|
72
|
+
"@angular/cli",
|
|
73
|
+
"@sveltejs/kit",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const DEFAULT_CONFIG: VisualConfig = {
|
|
77
|
+
urls: [],
|
|
78
|
+
threshold: 0.001,
|
|
79
|
+
viewport: { width: 1280, height: 720 },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ─── Web Project Detection ────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect if the project is a web project by checking package.json
|
|
86
|
+
* for dev server scripts or web framework dependencies.
|
|
87
|
+
*/
|
|
88
|
+
export function detectWebProject(cwd: string): boolean {
|
|
89
|
+
const pkgPath = join(cwd, "package.json");
|
|
90
|
+
if (!existsSync(pkgPath)) return false;
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
94
|
+
|
|
95
|
+
// Check scripts for web framework commands
|
|
96
|
+
const scripts = pkg.scripts ?? {};
|
|
97
|
+
const allScripts = Object.values(scripts).join(" ");
|
|
98
|
+
for (const framework of WEB_FRAMEWORKS) {
|
|
99
|
+
if (allScripts.includes(framework)) return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check dependencies for web frameworks
|
|
103
|
+
const allDeps = {
|
|
104
|
+
...(pkg.dependencies ?? {}),
|
|
105
|
+
...(pkg.devDependencies ?? {}),
|
|
106
|
+
};
|
|
107
|
+
for (const dep of WEB_DEPS) {
|
|
108
|
+
if (allDeps[dep]) return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Config ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load visual verification config from .maina/preferences.json.
|
|
121
|
+
* Returns defaults if no config exists.
|
|
122
|
+
*/
|
|
123
|
+
export function loadVisualConfig(mainaDir: string): VisualConfig {
|
|
124
|
+
const prefsPath = join(mainaDir, "preferences.json");
|
|
125
|
+
if (!existsSync(prefsPath)) return { ...DEFAULT_CONFIG };
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const prefs = JSON.parse(readFileSync(prefsPath, "utf-8"));
|
|
129
|
+
const visual = prefs.visual;
|
|
130
|
+
if (!visual || typeof visual !== "object") return { ...DEFAULT_CONFIG };
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
urls: Array.isArray(visual.urls) ? visual.urls : [],
|
|
134
|
+
threshold:
|
|
135
|
+
typeof visual.threshold === "number"
|
|
136
|
+
? visual.threshold
|
|
137
|
+
: DEFAULT_CONFIG.threshold,
|
|
138
|
+
viewport: {
|
|
139
|
+
width:
|
|
140
|
+
typeof visual.viewport?.width === "number"
|
|
141
|
+
? visual.viewport.width
|
|
142
|
+
: DEFAULT_CONFIG.viewport.width,
|
|
143
|
+
height:
|
|
144
|
+
typeof visual.viewport?.height === "number"
|
|
145
|
+
? visual.viewport.height
|
|
146
|
+
: DEFAULT_CONFIG.viewport.height,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
} catch {
|
|
150
|
+
return { ...DEFAULT_CONFIG };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── Screenshot Capture ───────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Capture a screenshot of a URL using Playwright CLI.
|
|
158
|
+
* Returns captured=false with skipped=true if Playwright is not installed.
|
|
159
|
+
*/
|
|
160
|
+
export async function captureScreenshot(
|
|
161
|
+
url: string,
|
|
162
|
+
outputPath: string,
|
|
163
|
+
options?: ScreenshotOptions,
|
|
164
|
+
): Promise<ScreenshotResult> {
|
|
165
|
+
const playwrightAvailable =
|
|
166
|
+
options?.available ?? (await isToolAvailable("playwright"));
|
|
167
|
+
if (!playwrightAvailable) {
|
|
168
|
+
return { captured: false, skipped: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const viewport = options?.viewport ?? DEFAULT_CONFIG.viewport;
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const dir = join(outputPath, "..");
|
|
175
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
176
|
+
|
|
177
|
+
const proc = Bun.spawn(
|
|
178
|
+
[
|
|
179
|
+
"npx",
|
|
180
|
+
"playwright",
|
|
181
|
+
"screenshot",
|
|
182
|
+
"--browser",
|
|
183
|
+
"chromium",
|
|
184
|
+
`--viewport-size=${viewport.width},${viewport.height}`,
|
|
185
|
+
url,
|
|
186
|
+
outputPath,
|
|
187
|
+
],
|
|
188
|
+
{
|
|
189
|
+
stdout: "pipe",
|
|
190
|
+
stderr: "pipe",
|
|
191
|
+
},
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
await new Response(proc.stderr).text();
|
|
195
|
+
const exitCode = await proc.exited;
|
|
196
|
+
|
|
197
|
+
if (exitCode === 0) {
|
|
198
|
+
return { captured: true, skipped: false, path: outputPath };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
captured: false,
|
|
203
|
+
skipped: false,
|
|
204
|
+
error: `Playwright exited with code ${exitCode}`,
|
|
205
|
+
};
|
|
206
|
+
} catch (e) {
|
|
207
|
+
return {
|
|
208
|
+
captured: false,
|
|
209
|
+
skipped: true,
|
|
210
|
+
error: e instanceof Error ? e.message : String(e),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── Pixel Comparison ─────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Compare two RGBA image buffers pixel by pixel.
|
|
219
|
+
* Returns the number and percentage of differing pixels.
|
|
220
|
+
* Simple threshold-based comparison — no external dependency.
|
|
221
|
+
*/
|
|
222
|
+
export function compareImages(
|
|
223
|
+
img1: Buffer,
|
|
224
|
+
img2: Buffer,
|
|
225
|
+
width: number,
|
|
226
|
+
height: number,
|
|
227
|
+
colorThreshold = 10,
|
|
228
|
+
): VisualDiffResult {
|
|
229
|
+
const totalPixels = width * height;
|
|
230
|
+
let diffPixels = 0;
|
|
231
|
+
|
|
232
|
+
for (let i = 0; i < totalPixels; i++) {
|
|
233
|
+
const offset = i * 4;
|
|
234
|
+
const r1 = img1[offset] ?? 0;
|
|
235
|
+
const g1 = img1[offset + 1] ?? 0;
|
|
236
|
+
const b1 = img1[offset + 2] ?? 0;
|
|
237
|
+
const r2 = img2[offset] ?? 0;
|
|
238
|
+
const g2 = img2[offset + 1] ?? 0;
|
|
239
|
+
const b2 = img2[offset + 2] ?? 0;
|
|
240
|
+
|
|
241
|
+
const dr = Math.abs(r1 - r2);
|
|
242
|
+
const dg = Math.abs(g1 - g2);
|
|
243
|
+
const db = Math.abs(b1 - b2);
|
|
244
|
+
|
|
245
|
+
if (dr > colorThreshold || dg > colorThreshold || db > colorThreshold) {
|
|
246
|
+
diffPixels++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
diffPixels,
|
|
252
|
+
diffPercentage: totalPixels > 0 ? diffPixels / totalPixels : 0,
|
|
253
|
+
totalPixels,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── Visual Verification Runner ───────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run visual verification: capture screenshots and compare against baselines.
|
|
261
|
+
*
|
|
262
|
+
* For each configured URL:
|
|
263
|
+
* 1. Capture current screenshot to .maina/visual-current/
|
|
264
|
+
* 2. Compare against .maina/visual-baselines/
|
|
265
|
+
* 3. If diff exceeds threshold, emit a Finding
|
|
266
|
+
*
|
|
267
|
+
* Skips gracefully if Playwright is not installed or no baselines exist.
|
|
268
|
+
*/
|
|
269
|
+
export async function runVisualVerification(
|
|
270
|
+
mainaDir: string,
|
|
271
|
+
config?: VisualConfig,
|
|
272
|
+
): Promise<VisualVerifyResult> {
|
|
273
|
+
const cfg = config ?? loadVisualConfig(mainaDir);
|
|
274
|
+
|
|
275
|
+
if (cfg.urls.length === 0) {
|
|
276
|
+
return { findings: [], skipped: true, screenshotsTaken: 0, comparisons: 0 };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const baselineDir = join(mainaDir, "visual-baselines");
|
|
280
|
+
const currentDir = join(mainaDir, "visual-current");
|
|
281
|
+
|
|
282
|
+
if (!existsSync(baselineDir)) {
|
|
283
|
+
return {
|
|
284
|
+
findings: [
|
|
285
|
+
{
|
|
286
|
+
tool: "visual",
|
|
287
|
+
file: "",
|
|
288
|
+
line: 0,
|
|
289
|
+
message:
|
|
290
|
+
"No visual baselines found. Run `maina visual update` to create baselines.",
|
|
291
|
+
severity: "info",
|
|
292
|
+
},
|
|
293
|
+
],
|
|
294
|
+
skipped: true,
|
|
295
|
+
screenshotsTaken: 0,
|
|
296
|
+
comparisons: 0,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (!existsSync(currentDir)) {
|
|
301
|
+
mkdirSync(currentDir, { recursive: true });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const findings: Finding[] = [];
|
|
305
|
+
let screenshotsTaken = 0;
|
|
306
|
+
let comparisons = 0;
|
|
307
|
+
|
|
308
|
+
for (const url of cfg.urls) {
|
|
309
|
+
// Generate filename from URL
|
|
310
|
+
const name = url
|
|
311
|
+
.replace(/https?:\/\//, "")
|
|
312
|
+
.replace(/[^a-zA-Z0-9]/g, "-")
|
|
313
|
+
.replace(/-+/g, "-")
|
|
314
|
+
.replace(/^-|-$/g, "");
|
|
315
|
+
const filename = `${name || "page"}.png`;
|
|
316
|
+
|
|
317
|
+
const currentPath = join(currentDir, filename);
|
|
318
|
+
const baselinePath = join(baselineDir, filename);
|
|
319
|
+
|
|
320
|
+
// Capture current screenshot
|
|
321
|
+
const result = await captureScreenshot(url, currentPath, {
|
|
322
|
+
viewport: cfg.viewport,
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
if (result.skipped) {
|
|
326
|
+
return {
|
|
327
|
+
findings: [
|
|
328
|
+
{
|
|
329
|
+
tool: "visual",
|
|
330
|
+
file: "",
|
|
331
|
+
line: 0,
|
|
332
|
+
message:
|
|
333
|
+
"Playwright not installed. Run `npx playwright install chromium` to enable visual verification.",
|
|
334
|
+
severity: "info",
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
skipped: true,
|
|
338
|
+
screenshotsTaken,
|
|
339
|
+
comparisons,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (!result.captured) {
|
|
344
|
+
findings.push({
|
|
345
|
+
tool: "visual",
|
|
346
|
+
file: url,
|
|
347
|
+
line: 0,
|
|
348
|
+
message: `Screenshot capture failed: ${result.error ?? "unknown error"}`,
|
|
349
|
+
severity: "warning",
|
|
350
|
+
});
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
screenshotsTaken++;
|
|
355
|
+
|
|
356
|
+
// Compare against baseline if it exists
|
|
357
|
+
if (!existsSync(baselinePath)) {
|
|
358
|
+
findings.push({
|
|
359
|
+
tool: "visual",
|
|
360
|
+
file: url,
|
|
361
|
+
line: 0,
|
|
362
|
+
message: `No baseline for ${url}. Run \`maina visual update\` to create.`,
|
|
363
|
+
severity: "info",
|
|
364
|
+
});
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Read both images as raw buffers (PNG comparison requires decoding)
|
|
369
|
+
// For now, do byte-level comparison as a simple heuristic
|
|
370
|
+
try {
|
|
371
|
+
const currentBuf = readFileSync(currentPath);
|
|
372
|
+
const baselineBuf = readFileSync(baselinePath);
|
|
373
|
+
|
|
374
|
+
// Simple size comparison first
|
|
375
|
+
if (currentBuf.length !== baselineBuf.length) {
|
|
376
|
+
findings.push({
|
|
377
|
+
tool: "visual",
|
|
378
|
+
file: url,
|
|
379
|
+
line: 0,
|
|
380
|
+
message: `Visual regression: screenshot size changed (baseline: ${baselineBuf.length}B, current: ${currentBuf.length}B)`,
|
|
381
|
+
severity: "warning",
|
|
382
|
+
ruleId: "visual/regression",
|
|
383
|
+
});
|
|
384
|
+
} else {
|
|
385
|
+
// Byte-level diff
|
|
386
|
+
let diffBytes = 0;
|
|
387
|
+
for (let i = 0; i < currentBuf.length; i++) {
|
|
388
|
+
if (currentBuf[i] !== baselineBuf[i]) diffBytes++;
|
|
389
|
+
}
|
|
390
|
+
const diffPercentage = diffBytes / currentBuf.length;
|
|
391
|
+
|
|
392
|
+
if (diffPercentage > cfg.threshold) {
|
|
393
|
+
findings.push({
|
|
394
|
+
tool: "visual",
|
|
395
|
+
file: url,
|
|
396
|
+
line: 0,
|
|
397
|
+
message: `Visual regression: ${(diffPercentage * 100).toFixed(2)}% pixels differ (threshold: ${(cfg.threshold * 100).toFixed(2)}%)`,
|
|
398
|
+
severity: "warning",
|
|
399
|
+
ruleId: "visual/regression",
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
comparisons++;
|
|
405
|
+
} catch (e) {
|
|
406
|
+
findings.push({
|
|
407
|
+
tool: "visual",
|
|
408
|
+
file: url,
|
|
409
|
+
line: 0,
|
|
410
|
+
message: `Comparison failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
411
|
+
severity: "warning",
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { findings, skipped: false, screenshotsTaken, comparisons };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ─── Baseline Management ──────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Update visual baselines by capturing current screenshots.
|
|
423
|
+
* Saves to .maina/visual-baselines/.
|
|
424
|
+
*/
|
|
425
|
+
export async function updateBaselines(
|
|
426
|
+
mainaDir: string,
|
|
427
|
+
config?: VisualConfig,
|
|
428
|
+
): Promise<{ updated: string[]; errors: string[] }> {
|
|
429
|
+
const cfg = config ?? loadVisualConfig(mainaDir);
|
|
430
|
+
const baselineDir = join(mainaDir, "visual-baselines");
|
|
431
|
+
|
|
432
|
+
if (!existsSync(baselineDir)) {
|
|
433
|
+
mkdirSync(baselineDir, { recursive: true });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const updated: string[] = [];
|
|
437
|
+
const errors: string[] = [];
|
|
438
|
+
|
|
439
|
+
for (const url of cfg.urls) {
|
|
440
|
+
const name = url
|
|
441
|
+
.replace(/https?:\/\//, "")
|
|
442
|
+
.replace(/[^a-zA-Z0-9]/g, "-")
|
|
443
|
+
.replace(/-+/g, "-")
|
|
444
|
+
.replace(/^-|-$/g, "");
|
|
445
|
+
const filename = `${name || "page"}.png`;
|
|
446
|
+
const outputPath = join(baselineDir, filename);
|
|
447
|
+
|
|
448
|
+
const result = await captureScreenshot(url, outputPath, {
|
|
449
|
+
viewport: cfg.viewport,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
if (result.captured) {
|
|
453
|
+
updated.push(filename);
|
|
454
|
+
} else {
|
|
455
|
+
errors.push(`${url}: ${result.error ?? "capture failed"}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return { updated, errors };
|
|
460
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
appendWorkflowStep,
|
|
6
|
+
loadWorkflowContext,
|
|
7
|
+
resetWorkflowContext,
|
|
8
|
+
} from "../context";
|
|
9
|
+
|
|
10
|
+
describe("WorkflowContext", () => {
|
|
11
|
+
const testDir = join(import.meta.dir, "__fixtures__/workflow");
|
|
12
|
+
const mainaDir = join(testDir, ".maina");
|
|
13
|
+
|
|
14
|
+
function cleanup() {
|
|
15
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setup() {
|
|
19
|
+
cleanup();
|
|
20
|
+
mkdirSync(mainaDir, { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
it("should reset workflow context with feature name header", () => {
|
|
24
|
+
setup();
|
|
25
|
+
resetWorkflowContext(mainaDir, "feature/014-workflow-context");
|
|
26
|
+
|
|
27
|
+
const content = loadWorkflowContext(mainaDir);
|
|
28
|
+
expect(content).toContain("# Workflow: feature/014-workflow-context");
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should append a workflow step", () => {
|
|
33
|
+
setup();
|
|
34
|
+
resetWorkflowContext(mainaDir, "feature/014-test");
|
|
35
|
+
appendWorkflowStep(
|
|
36
|
+
mainaDir,
|
|
37
|
+
"plan",
|
|
38
|
+
"Feature 014 scaffolded. Branch created.",
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const content = loadWorkflowContext(mainaDir);
|
|
42
|
+
expect(content).toContain("## plan");
|
|
43
|
+
expect(content).toContain("Feature 014 scaffolded");
|
|
44
|
+
cleanup();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should append multiple steps in order", () => {
|
|
48
|
+
setup();
|
|
49
|
+
resetWorkflowContext(mainaDir, "feature/014-test");
|
|
50
|
+
appendWorkflowStep(mainaDir, "plan", "Scaffolded.");
|
|
51
|
+
appendWorkflowStep(mainaDir, "design", "ADR 0004 created.");
|
|
52
|
+
appendWorkflowStep(mainaDir, "commit", "Verified and committed.");
|
|
53
|
+
|
|
54
|
+
const content = loadWorkflowContext(mainaDir);
|
|
55
|
+
expect(content).toContain("## plan");
|
|
56
|
+
expect(content).toContain("## design");
|
|
57
|
+
expect(content).toContain("## commit");
|
|
58
|
+
|
|
59
|
+
// Steps should appear in order
|
|
60
|
+
const c = content as string;
|
|
61
|
+
const planIdx = c.indexOf("## plan");
|
|
62
|
+
const designIdx = c.indexOf("## design");
|
|
63
|
+
const commitIdx = c.indexOf("## commit");
|
|
64
|
+
expect(planIdx).toBeLessThan(designIdx);
|
|
65
|
+
expect(designIdx).toBeLessThan(commitIdx);
|
|
66
|
+
cleanup();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("should return null when no workflow context exists", () => {
|
|
70
|
+
setup();
|
|
71
|
+
const content = loadWorkflowContext(mainaDir);
|
|
72
|
+
expect(content).toBeNull();
|
|
73
|
+
cleanup();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should include timestamp in each step", () => {
|
|
77
|
+
setup();
|
|
78
|
+
resetWorkflowContext(mainaDir, "test");
|
|
79
|
+
appendWorkflowStep(mainaDir, "plan", "Test step.");
|
|
80
|
+
|
|
81
|
+
const content = loadWorkflowContext(mainaDir);
|
|
82
|
+
// ISO timestamp pattern
|
|
83
|
+
expect(content).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
|
84
|
+
cleanup();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("should create workflow directory if it doesn't exist", () => {
|
|
88
|
+
cleanup();
|
|
89
|
+
// Don't create mainaDir — the function should handle it
|
|
90
|
+
mkdirSync(testDir, { recursive: true });
|
|
91
|
+
resetWorkflowContext(join(testDir, ".maina"), "test");
|
|
92
|
+
|
|
93
|
+
expect(existsSync(join(testDir, ".maina", "workflow", "current.md"))).toBe(
|
|
94
|
+
true,
|
|
95
|
+
);
|
|
96
|
+
cleanup();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should overwrite previous workflow on reset", () => {
|
|
100
|
+
setup();
|
|
101
|
+
resetWorkflowContext(mainaDir, "old-feature");
|
|
102
|
+
appendWorkflowStep(mainaDir, "plan", "Old stuff.");
|
|
103
|
+
resetWorkflowContext(mainaDir, "new-feature");
|
|
104
|
+
|
|
105
|
+
const content = loadWorkflowContext(mainaDir);
|
|
106
|
+
expect(content).toContain("new-feature");
|
|
107
|
+
expect(content).not.toContain("Old stuff");
|
|
108
|
+
cleanup();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Context — rolling summary forwarded between maina lifecycle steps.
|
|
3
|
+
*
|
|
4
|
+
* Each maina command appends a step summary to `.maina/workflow/current.md`.
|
|
5
|
+
* The context engine includes this in the working layer for AI calls.
|
|
6
|
+
* `maina plan` resets it for new features.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
appendFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
// ─── Paths ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function workflowFilePath(mainaDir: string): string {
|
|
21
|
+
return join(mainaDir, "workflow", "current.md");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Public API ───────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reset the workflow context for a new feature.
|
|
28
|
+
* Overwrites any existing workflow context.
|
|
29
|
+
*/
|
|
30
|
+
export function resetWorkflowContext(
|
|
31
|
+
mainaDir: string,
|
|
32
|
+
featureName: string,
|
|
33
|
+
): void {
|
|
34
|
+
const filePath = workflowFilePath(mainaDir);
|
|
35
|
+
const dir = dirname(filePath);
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
writeFileSync(filePath, `# Workflow: ${featureName}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Append a workflow step summary.
|
|
44
|
+
* Each step includes the step name, ISO timestamp, and a concise summary.
|
|
45
|
+
*/
|
|
46
|
+
export function appendWorkflowStep(
|
|
47
|
+
mainaDir: string,
|
|
48
|
+
step: string,
|
|
49
|
+
summary: string,
|
|
50
|
+
): void {
|
|
51
|
+
const filePath = workflowFilePath(mainaDir);
|
|
52
|
+
if (!existsSync(filePath)) {
|
|
53
|
+
// If no workflow file exists, create a minimal one
|
|
54
|
+
const dir = dirname(filePath);
|
|
55
|
+
if (!existsSync(dir)) {
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
writeFileSync(filePath, "# Workflow\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const timestamp = new Date().toISOString();
|
|
62
|
+
const entry = `\n## ${step} (${timestamp})\n${summary}\n`;
|
|
63
|
+
appendFileSync(filePath, entry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Load the current workflow context.
|
|
68
|
+
* Returns the full markdown content, or null if no workflow is active.
|
|
69
|
+
*/
|
|
70
|
+
export function loadWorkflowContext(mainaDir: string): string | null {
|
|
71
|
+
const filePath = workflowFilePath(mainaDir);
|
|
72
|
+
if (!existsSync(filePath)) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const content = readFileSync(filePath, "utf-8");
|
|
77
|
+
return content.trim() || null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|