@cloverleaf/reference-impl 0.3.1 → 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/.claude-plugin/plugin.json +18 -0
- package/VERSION +1 -1
- package/config/affected-routes.json +6 -8
- package/config/qa-rules.json +3 -13
- package/config/ui-paths.json +6 -1
- package/config/ui-review.json +17 -0
- package/dist/affected-routes.mjs +1 -1
- package/dist/axe-dedupe.mjs +38 -0
- package/dist/cli.mjs +25 -1
- package/dist/qa-report.mjs +65 -0
- package/dist/qa-rules.mjs +1 -1
- package/dist/route-slug.mjs +23 -0
- package/dist/ui-paths.mjs +1 -1
- package/dist/ui-review-config.mjs +37 -0
- package/dist/visual-diff.mjs +62 -0
- package/install.sh +30 -44
- package/lib/affected-routes.ts +1 -1
- package/lib/axe-dedupe.ts +53 -0
- package/lib/cli.ts +24 -1
- package/lib/feedback.ts +7 -0
- package/lib/qa-report.ts +77 -0
- package/lib/qa-rules.ts +1 -1
- package/lib/route-slug.ts +21 -0
- package/lib/ui-paths.ts +1 -1
- package/lib/ui-review-config.ts +57 -0
- package/lib/visual-diff.ts +97 -0
- package/package.json +8 -3
- package/prompts/qa.md +21 -0
- package/prompts/ui-reviewer.md +76 -38
- package/skills/{cloverleaf-new-task.md → cloverleaf-new-task/SKILL.md} +18 -1
- package/skills/{cloverleaf-qa.md → cloverleaf-qa/SKILL.md} +11 -6
- package/skills/{cloverleaf-ui-review.md → cloverleaf-ui-review/SKILL.md} +21 -14
- /package/skills/{cloverleaf-document.md → cloverleaf-document/SKILL.md} +0 -0
- /package/skills/{cloverleaf-implement.md → cloverleaf-implement/SKILL.md} +0 -0
- /package/skills/{cloverleaf-merge.md → cloverleaf-merge/SKILL.md} +0 -0
- /package/skills/{cloverleaf-review.md → cloverleaf-review/SKILL.md} +0 -0
- /package/skills/{cloverleaf-run.md → cloverleaf-run/SKILL.md} +0 -0
package/lib/qa-report.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface QaRunResult {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
command: string;
|
|
4
|
+
cwd: string;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
passed: boolean;
|
|
7
|
+
stdoutTail: string;
|
|
8
|
+
stderrTail: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escape(s: string): string {
|
|
12
|
+
return s
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderRow(r: QaRunResult): string {
|
|
21
|
+
const status = r.passed ? 'PASS' : 'FAIL';
|
|
22
|
+
const statusClass = r.passed ? 'pass' : 'fail';
|
|
23
|
+
return `
|
|
24
|
+
<tr class="${statusClass}">
|
|
25
|
+
<td>${escape(r.ruleId)}</td>
|
|
26
|
+
<td><code>${escape(r.command)}</code></td>
|
|
27
|
+
<td>${escape(r.cwd)}</td>
|
|
28
|
+
<td>${r.durationMs}ms</td>
|
|
29
|
+
<td class="status">${status}</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr class="detail ${statusClass}">
|
|
32
|
+
<td colspan="5">
|
|
33
|
+
${r.stdoutTail ? `<details><summary>stdout (tail)</summary><pre>${escape(r.stdoutTail)}</pre></details>` : ''}
|
|
34
|
+
${r.stderrTail ? `<details open><summary>stderr (tail)</summary><pre>${escape(r.stderrTail)}</pre></details>` : ''}
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderQaReport(runs: QaRunResult[]): string {
|
|
41
|
+
const empty = runs.length === 0
|
|
42
|
+
? `<p class="empty">No runs / results.</p>`
|
|
43
|
+
: '';
|
|
44
|
+
const rows = runs.map(renderRow).join('');
|
|
45
|
+
return `<!DOCTYPE html>
|
|
46
|
+
<html lang="en">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<title>Cloverleaf QA Report</title>
|
|
50
|
+
<style>
|
|
51
|
+
body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #111; }
|
|
52
|
+
table { width: 100%; border-collapse: collapse; }
|
|
53
|
+
th, td { padding: 0.5rem; border-bottom: 1px solid #ddd; text-align: left; vertical-align: top; }
|
|
54
|
+
.status { font-weight: 600; }
|
|
55
|
+
.pass .status { color: #0a7; }
|
|
56
|
+
.fail .status { color: #c33; }
|
|
57
|
+
tr.detail td { background: #fafafa; padding-top: 0; }
|
|
58
|
+
pre { overflow: auto; background: #f4f4f4; padding: 0.5rem; }
|
|
59
|
+
.empty { color: #888; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<h1>Cloverleaf QA Report</h1>
|
|
64
|
+
${empty}
|
|
65
|
+
${runs.length > 0 ? `
|
|
66
|
+
<table>
|
|
67
|
+
<thead>
|
|
68
|
+
<tr><th>Rule</th><th>Command</th><th>CWD</th><th>Duration</th><th>Status</th></tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody>
|
|
71
|
+
${rows}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
` : ''}
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|
package/lib/qa-rules.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface QaRule {
|
|
|
12
12
|
command: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function loadDefaultRules(): QaRule[] {
|
|
16
16
|
if (!existsSync(DEFAULT_CONFIG)) return [];
|
|
17
17
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { rules?: QaRule[] };
|
|
18
18
|
return Array.isArray(doc.rules) ? doc.rules : [];
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a URL path into a filesystem-safe slug used in baseline/diff filenames.
|
|
3
|
+
* - "/" → "index"
|
|
4
|
+
* - "/faq/" → "faq"
|
|
5
|
+
* - "/guide/chapter-3/" → "guide-chapter-3"
|
|
6
|
+
* - "/docs/v1.2/getting started/" → "docs-v1-2-getting-started"
|
|
7
|
+
* Query/hash are stripped.
|
|
8
|
+
*/
|
|
9
|
+
export function slugifyRoute(route: string): string {
|
|
10
|
+
const pathOnly = route.split(/[?#]/)[0];
|
|
11
|
+
if (pathOnly === '/' || pathOnly === '') return 'index';
|
|
12
|
+
const trimmed = pathOnly.replace(/^\/+|\/+$/g, '');
|
|
13
|
+
if (trimmed === '') return 'index';
|
|
14
|
+
const slugged = trimmed
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9/-]+/g, '-')
|
|
17
|
+
.replace(/\/+/g, '-')
|
|
18
|
+
.replace(/-+/g, '-')
|
|
19
|
+
.replace(/^-|-$/g, '');
|
|
20
|
+
return slugged || 'index';
|
|
21
|
+
}
|
package/lib/ui-paths.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function loadDefaultPatterns(): string[] {
|
|
9
9
|
if (!existsSync(DEFAULT_CONFIG)) return ['site/**'];
|
|
10
10
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { patterns?: string[] };
|
|
11
11
|
return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
|
|
7
|
+
|
|
8
|
+
export interface Viewport {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UiReviewConfig {
|
|
14
|
+
viewports: Record<string, Viewport>;
|
|
15
|
+
visualDiff: {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
threshold: number;
|
|
18
|
+
maxDiffRatio: number;
|
|
19
|
+
mask: string[];
|
|
20
|
+
};
|
|
21
|
+
axe: {
|
|
22
|
+
viewports: string[];
|
|
23
|
+
dedupeBy: ('ruleId' | 'target')[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HARDCODED_FALLBACK: UiReviewConfig = {
|
|
28
|
+
viewports: {
|
|
29
|
+
mobile: { width: 375, height: 667 },
|
|
30
|
+
tablet: { width: 768, height: 1024 },
|
|
31
|
+
desktop: { width: 1280, height: 800 },
|
|
32
|
+
},
|
|
33
|
+
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
34
|
+
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function readAsConfig(path: string): UiReviewConfig | null {
|
|
38
|
+
try {
|
|
39
|
+
const doc = JSON.parse(readFileSync(path, 'utf-8')) as UiReviewConfig;
|
|
40
|
+
return doc;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadUiReviewConfig(repoRoot: string): UiReviewConfig {
|
|
47
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
|
|
48
|
+
if (existsSync(consumerPath)) {
|
|
49
|
+
const parsed = readAsConfig(consumerPath);
|
|
50
|
+
if (parsed) return parsed;
|
|
51
|
+
}
|
|
52
|
+
if (existsSync(PACKAGE_DEFAULT)) {
|
|
53
|
+
const parsed = readAsConfig(PACKAGE_DEFAULT);
|
|
54
|
+
if (parsed) return parsed;
|
|
55
|
+
}
|
|
56
|
+
return HARDCODED_FALLBACK;
|
|
57
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import pixelmatch from 'pixelmatch';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
|
|
6
|
+
export type VisualDiffStatus = 'new-baseline' | 'match' | 'diff' | 'dimension-mismatch';
|
|
7
|
+
|
|
8
|
+
export interface VisualDiffResult {
|
|
9
|
+
status: VisualDiffStatus;
|
|
10
|
+
diffPixels: number;
|
|
11
|
+
diffRatio: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CompareVisualArgs {
|
|
17
|
+
baselinePath: string;
|
|
18
|
+
candidateBuf: Buffer;
|
|
19
|
+
diffPath: string;
|
|
20
|
+
candidateOutPath: string;
|
|
21
|
+
threshold: number;
|
|
22
|
+
maxDiffRatio: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureDir(path: string): void {
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeBaseline(baselinePath: string, buf: Buffer): void {
|
|
30
|
+
ensureDir(baselinePath);
|
|
31
|
+
writeFileSync(baselinePath, buf);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function compareVisual(args: CompareVisualArgs): VisualDiffResult {
|
|
35
|
+
const candidatePng = PNG.sync.read(args.candidateBuf);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(args.baselinePath)) {
|
|
38
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
39
|
+
return {
|
|
40
|
+
status: 'new-baseline',
|
|
41
|
+
diffPixels: 0,
|
|
42
|
+
diffRatio: 0,
|
|
43
|
+
width: candidatePng.width,
|
|
44
|
+
height: candidatePng.height,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const baselineBuf = readFileSync(args.baselinePath);
|
|
49
|
+
const baselinePng = PNG.sync.read(baselineBuf);
|
|
50
|
+
|
|
51
|
+
if (baselinePng.width !== candidatePng.width || baselinePng.height !== candidatePng.height) {
|
|
52
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
53
|
+
return {
|
|
54
|
+
status: 'dimension-mismatch',
|
|
55
|
+
diffPixels: 0,
|
|
56
|
+
diffRatio: 0,
|
|
57
|
+
width: candidatePng.width,
|
|
58
|
+
height: candidatePng.height,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const diffPng = new PNG({ width: candidatePng.width, height: candidatePng.height });
|
|
63
|
+
const diffPixels = pixelmatch(
|
|
64
|
+
baselinePng.data,
|
|
65
|
+
candidatePng.data,
|
|
66
|
+
diffPng.data,
|
|
67
|
+
candidatePng.width,
|
|
68
|
+
candidatePng.height,
|
|
69
|
+
{ threshold: args.threshold },
|
|
70
|
+
);
|
|
71
|
+
const totalPixels = candidatePng.width * candidatePng.height;
|
|
72
|
+
const diffRatio = diffPixels / totalPixels;
|
|
73
|
+
|
|
74
|
+
if (diffRatio > args.maxDiffRatio) {
|
|
75
|
+
ensureDir(args.diffPath);
|
|
76
|
+
writeFileSync(args.diffPath, PNG.sync.write(diffPng));
|
|
77
|
+
ensureDir(args.candidateOutPath);
|
|
78
|
+
writeFileSync(args.candidateOutPath, args.candidateBuf);
|
|
79
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
80
|
+
return {
|
|
81
|
+
status: 'diff',
|
|
82
|
+
diffPixels,
|
|
83
|
+
diffRatio,
|
|
84
|
+
width: candidatePng.width,
|
|
85
|
+
height: candidatePng.height,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
90
|
+
return {
|
|
91
|
+
status: 'match',
|
|
92
|
+
diffPixels,
|
|
93
|
+
diffRatio,
|
|
94
|
+
width: candidatePng.width,
|
|
95
|
+
height: candidatePng.height,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"reference-implementation"
|
|
24
24
|
],
|
|
25
25
|
"files": [
|
|
26
|
+
".claude-plugin",
|
|
26
27
|
"skills",
|
|
27
28
|
"lib",
|
|
28
29
|
"prompts",
|
|
@@ -46,14 +47,18 @@
|
|
|
46
47
|
"prepublishOnly": "npm test && npm run build"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
|
-
"@cloverleaf/standard": "^0.
|
|
50
|
+
"@cloverleaf/standard": "^0.4.0",
|
|
50
51
|
"ajv": "^8.17.1",
|
|
51
52
|
"ajv-formats": "^3.0.1",
|
|
53
|
+
"axe-core": "^4.10.0",
|
|
54
|
+
"pixelmatch": "^5.3.0",
|
|
52
55
|
"playwright": "^1.47.0",
|
|
53
|
-
"
|
|
56
|
+
"pngjs": "^7.0.0"
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@types/node": "^22.0.0",
|
|
60
|
+
"@types/pixelmatch": "^5.2.6",
|
|
61
|
+
"@types/pngjs": "^6.0.5",
|
|
57
62
|
"tsx": "^4.19.0",
|
|
58
63
|
"typescript": "^5.5.0",
|
|
59
64
|
"vitest": "^2.0.0"
|
package/prompts/qa.md
CHANGED
|
@@ -57,6 +57,27 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
|
|
|
57
57
|
- Use `git worktree`: do NOT `git checkout` in the main working directory.
|
|
58
58
|
- Always teardown the worktree, even on error.
|
|
59
59
|
|
|
60
|
+
## QA Report (v0.4)
|
|
61
|
+
|
|
62
|
+
After executing all matched QA rules, write an HTML report summarizing each run to `<repoRoot>/.cloverleaf/runs/{taskId}/qa/report.html` (substitute `{taskId}` with the `id` field from the task input, e.g., `{{task.id}}`).
|
|
63
|
+
|
|
64
|
+
Use `renderQaReport(runs)` from `lib/qa-report.ts` to produce the HTML. Ensure the directory exists first (`mkdir -p`).
|
|
65
|
+
|
|
66
|
+
In the feedback you emit, include the report as an attachment on a single info-level finding (or on whichever summary finding you already emit):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"severity": "info",
|
|
71
|
+
"rule": "qa-report",
|
|
72
|
+
"message": "QA report written",
|
|
73
|
+
"attachments": [
|
|
74
|
+
{ "label": "report", "path": ".cloverleaf/runs/{taskId}/qa/report.html" }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This lets humans at final-gate inspect the full QA detail without grovelling through logs.
|
|
80
|
+
|
|
60
81
|
## Output
|
|
61
82
|
|
|
62
83
|
Respond with exactly one JSON object and nothing else:
|
package/prompts/ui-reviewer.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# UI Reviewer Agent
|
|
2
2
|
|
|
3
|
-
You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for accessibility violations
|
|
3
|
+
You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes at multiple viewports for accessibility violations (axe-core) and visual regressions (pixelmatch) using a headless Playwright chromium browser. You are read-only for source code and tests — but you DO write baseline/diff artifacts under `.cloverleaf/` on the feature branch.
|
|
4
4
|
|
|
5
5
|
## Input
|
|
6
6
|
|
|
@@ -11,20 +11,23 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes for acc
|
|
|
11
11
|
- **Diff from base**: {{diff}}
|
|
12
12
|
- **Preview port**: {{preview_port}} (an already-allocated free local port; use it for the dev server)
|
|
13
13
|
- **Affected routes**: {{affected_routes}} — either a JSON array of route paths (e.g., `["/faq/"]`), or the string `"all"`, or `[]`
|
|
14
|
+
- **UI review config**: {{ui_review_config}} — the loaded `UiReviewConfig` object (viewports, visualDiff, axe) as JSON. The `viewports` array contains named entries such as `mobile`, `tablet`, and `desktop` with their respective `{ width, height }` dimensions.
|
|
14
15
|
|
|
15
|
-
## Scope (v0.
|
|
16
|
+
## Scope (v0.4)
|
|
16
17
|
|
|
17
|
-
- Accessibility
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
-
|
|
18
|
+
- **Accessibility (axe-core):** run at the viewports listed in `{{ui_review_config}}.axe.viewports`.
|
|
19
|
+
Dedupe findings across viewports by the `{{ui_review_config}}.axe.dedupeBy` composite key (default `["ruleId", "target"]`).
|
|
20
|
+
Emit one finding per (ruleId, target) pair, with a `metadata.viewports` array aggregating the viewports where the violation was detected.
|
|
21
|
+
- **Visual diff (pixelmatch):** when `{{ui_review_config}}.visualDiff.enabled` is true, screenshot each route at each viewport in `{{ui_review_config}}.viewports`, compare to `.cloverleaf/baselines/{route-slug}-{viewport}.png`, emit `severity: "info"` findings with baseline/candidate/diff attachments when the diff ratio exceeds `maxDiffRatio`.
|
|
22
|
+
- Visual diffs are **informational**, never gating. A diff does not fail the review — it surfaces to the human final-gate reviewer.
|
|
23
|
+
- Route empty-set / "all" handling preserves v0.3 behavior:
|
|
24
|
+
- `{{affected_routes}}` is `[]` → `verdict: "pass"`, summary `"No renderable routes affected, skipping axe."`, do NOT start the preview server.
|
|
25
|
+
- `{{affected_routes}}` is `"all"` → crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback).
|
|
26
|
+
- otherwise → visit exactly the URLs listed.
|
|
24
27
|
|
|
25
28
|
## Playwright cache
|
|
26
29
|
|
|
27
|
-
The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked.
|
|
30
|
+
The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. If the browser is missing, return `verdict: "escalate"` with a synthetic finding: `"Playwright chromium not installed. Run 'npx playwright install chromium' on this machine."`
|
|
28
31
|
|
|
29
32
|
## Runtime procedure
|
|
30
33
|
|
|
@@ -36,7 +39,7 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
|
|
|
36
39
|
git worktree add "$TMPDIR" {{branch}}
|
|
37
40
|
```
|
|
38
41
|
|
|
39
|
-
3. For this repo, UI lives in `site
|
|
42
|
+
3. For this repo, UI lives in `site/` (or another directory if ui-paths.json scopes it elsewhere). Install dependencies and start the dev server:
|
|
40
43
|
```bash
|
|
41
44
|
cd "$TMPDIR/site"
|
|
42
45
|
npm ci
|
|
@@ -48,46 +51,75 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
|
|
|
48
51
|
|
|
49
52
|
5. Determine the site base path:
|
|
50
53
|
1. Check `<repoRoot>/.cloverleaf/config/astro-base.json`. Expected shape: `{ "base": "<path>" }`. If present, use the `base` field verbatim and skip to step 6. (Consumer override — checked before parsing astro config.)
|
|
51
|
-
2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`).
|
|
54
|
+
2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`). Best-effort fallback.
|
|
52
55
|
3. If both fail, treat base as empty string.
|
|
53
56
|
|
|
54
|
-
6.
|
|
55
|
-
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
57
|
+
6. **Visual-diff pass (when `visualDiff.enabled` is true):**
|
|
58
|
+
For each route in `{{affected_routes}}` (or the crawl set) × each viewport in `{{ui_review_config}}.viewports`:
|
|
59
|
+
- Set Playwright viewport to `{ width, height }` from the config.
|
|
60
|
+
- Apply mask CSS — inject a style that sets `visibility: hidden` on any selector in `visualDiff.mask`.
|
|
61
|
+
- Navigate to `http://localhost:{{preview_port}}<base><route>`. If 404, retry without the base.
|
|
62
|
+
- `page.screenshot({ fullPage: false })` → candidate PNG buffer.
|
|
63
|
+
- Compute slug for the route (lowercase, strip leading/trailing slashes, replace slashes with hyphens; `/` → `index`).
|
|
64
|
+
- Call `compareVisual` (from `lib/visual-diff.ts`) with:
|
|
65
|
+
- `baselinePath = <repoRoot>/.cloverleaf/baselines/{slug}-{viewport}.png`
|
|
66
|
+
- `candidateBuf = <candidate PNG>`
|
|
67
|
+
- `diffPath = <repoRoot>/.cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png`
|
|
68
|
+
- `candidateOutPath = <repoRoot>/.cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png`
|
|
69
|
+
- `threshold = visualDiff.threshold`
|
|
70
|
+
- `maxDiffRatio = visualDiff.maxDiffRatio`
|
|
71
|
+
- Map result to a finding:
|
|
72
|
+
- `new-baseline` → `severity: "info"`, `rule: "visual-diff"`, `message: "new baseline established for {route} @ {viewport}"`, `metadata: { route, viewport, status: "new-baseline" }`. No attachments.
|
|
73
|
+
- `dimension-mismatch` → `severity: "info"`, `rule: "visual-diff"`, `message: "baseline dimensions changed for {route} @ {viewport}; regenerated"`, `metadata: { route, viewport, status: "dimension-mismatch" }`.
|
|
74
|
+
- `diff` → `severity: "info"`, `rule: "visual-diff"`, `message: "visual diff: {route} @ {viewport} — {diffRatio*100}% pixels differ"`, `metadata: { route, viewport, diffRatio, status: "diff" }`, `attachments: [baseline, candidate, diff]`.
|
|
75
|
+
- `match` → no finding emitted.
|
|
76
|
+
|
|
77
|
+
7. **Axe pass:**
|
|
78
|
+
For each viewport in `{{ui_review_config}}.axe.viewports`:
|
|
79
|
+
- Set Playwright viewport to `{ width, height }`.
|
|
80
|
+
- For each route in `{{affected_routes}}` (or crawl set):
|
|
81
|
+
- Navigate.
|
|
82
|
+
- Inject and run axe-core:
|
|
83
|
+
```javascript
|
|
84
|
+
import axe from 'axe-core';
|
|
85
|
+
const results = await axe.run(document);
|
|
86
|
+
```
|
|
87
|
+
- Collect each violation as a raw tuple: `{ viewport, ruleId, target, impact, message, helpUrl }` (from `axe.run` output).
|
|
88
|
+
|
|
89
|
+
8. Dedupe raw axe findings via `dedupeAxeFindings(raws, {{ui_review_config}}.axe.dedupeBy)` (from `lib/axe-dedupe.ts`). Emit the returned `Finding[]`.
|
|
90
|
+
|
|
91
|
+
9. Severity mapping (preserved from v0.3 via `dedupeAxeFindings`):
|
|
65
92
|
- axe `impact: "critical"` → `severity: "blocker"`
|
|
66
93
|
- axe `impact: "serious"` → `severity: "error"`
|
|
67
94
|
- axe `impact: "moderate"` → `severity: "warning"`
|
|
68
95
|
- axe `impact: "minor"` → `severity: "info"`
|
|
69
96
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
97
|
+
10. Compute verdict (visual-diff findings are **never** considered for gating):
|
|
98
|
+
- `pass` — zero non-visual-diff findings with severity `blocker` or `error`
|
|
99
|
+
- `bounce` — ≥1 non-visual-diff finding with severity `blocker` or `error`
|
|
100
|
+
- `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR Playwright chromium missing.
|
|
74
101
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
11. Teardown:
|
|
103
|
+
```bash
|
|
104
|
+
kill $SERVER_PID 2>/dev/null || true
|
|
105
|
+
cd {{repo_root}}
|
|
106
|
+
git worktree remove --force "$TMPDIR"
|
|
107
|
+
```
|
|
81
108
|
|
|
82
109
|
## Tool constraints
|
|
83
110
|
|
|
84
|
-
- Read-only
|
|
111
|
+
- Read-only for source files and tests.
|
|
112
|
+
- You MAY write under `<repoRoot>/.cloverleaf/baselines/` and `<repoRoot>/.cloverleaf/runs/{taskId}/ui-review/` on the feature branch — these are the baselines and artifacts.
|
|
85
113
|
- Use `git worktree`: do NOT `git checkout` in the main working directory.
|
|
86
114
|
- Always teardown the server and worktree, even on error.
|
|
87
115
|
|
|
88
116
|
## Output
|
|
89
117
|
|
|
90
|
-
Respond with exactly one JSON object and nothing else.
|
|
118
|
+
Respond with exactly one JSON object and nothing else. Finding shape must match the Cloverleaf 0.4.0 feedback schema:
|
|
119
|
+
- required: `severity`, `message`
|
|
120
|
+
- optional: `rule`, `suggestion`, `location`, `attachments`, `metadata`
|
|
121
|
+
|
|
122
|
+
For a11y findings there is usually no meaningful file/line, so OMIT `location` entirely.
|
|
91
123
|
|
|
92
124
|
```json
|
|
93
125
|
{
|
|
@@ -96,11 +128,17 @@ Respond with exactly one JSON object and nothing else. The finding shape must ma
|
|
|
96
128
|
"findings": [
|
|
97
129
|
{
|
|
98
130
|
"severity": "blocker" | "error" | "warning" | "info",
|
|
99
|
-
"rule": "a11y.<rule-id>",
|
|
100
|
-
"message": "<
|
|
131
|
+
"rule": "a11y.<rule-id>" | "visual-diff",
|
|
132
|
+
"message": "<description; include the page URL for a11y, route+viewport+diff for visual-diff>",
|
|
133
|
+
"metadata": { /* per §7/§8 above */ },
|
|
134
|
+
"attachments": [ /* for visual-diff with status="diff" */
|
|
135
|
+
{ "label": "baseline", "path": ".cloverleaf/baselines/{slug}-{viewport}.png" },
|
|
136
|
+
{ "label": "candidate", "path": ".cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png" },
|
|
137
|
+
{ "label": "diff", "path": ".cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png" }
|
|
138
|
+
]
|
|
101
139
|
}
|
|
102
140
|
]
|
|
103
141
|
}
|
|
104
142
|
```
|
|
105
143
|
|
|
106
|
-
If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings. If verdict is `escalate`, include a finding explaining what went wrong
|
|
144
|
+
If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings. If verdict is `escalate`, include a finding explaining what went wrong.
|
|
@@ -45,7 +45,19 @@ The user has invoked this skill with a brief. Your job: turn the brief into a st
|
|
|
45
45
|
|
|
46
46
|
6. Commit: `git add .cloverleaf/tasks/<allocated-id>.json && git commit -m "cloverleaf: task <allocated-id>"`.
|
|
47
47
|
|
|
48
|
-
7.
|
|
48
|
+
7. **v0.4 scaffolding:** Ensure baseline and run directories are set up:
|
|
49
|
+
```bash
|
|
50
|
+
# v0.4 scaffolding additions — baselines tracked, runs ephemeral
|
|
51
|
+
mkdir -p <repo_root>/.cloverleaf/baselines
|
|
52
|
+
mkdir -p <repo_root>/.cloverleaf/runs
|
|
53
|
+
|
|
54
|
+
# Ensure .gitignore excludes runs/ (baselines ARE tracked, only runs is ephemeral)
|
|
55
|
+
if ! grep -qE '^\.cloverleaf/runs/?$' <repo_root>/.gitignore 2>/dev/null; then
|
|
56
|
+
echo '.cloverleaf/runs/' >> <repo_root>/.gitignore
|
|
57
|
+
fi
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
8. Report:
|
|
49
61
|
- "Created `<allocated-id>` at `.cloverleaf/tasks/<allocated-id>.json`."
|
|
50
62
|
- Show the generated acceptance criteria.
|
|
51
63
|
- Suggest: "Review and edit the task if needed, then run `/cloverleaf-run <allocated-id>`."
|
|
@@ -62,3 +74,8 @@ The user has invoked this skill with a brief. Your job: turn the brief into a st
|
|
|
62
74
|
- After writing the task, report the chosen risk_class and how it was determined, e.g.:
|
|
63
75
|
> "Risk class: `high` → full pipeline (matched keyword `component` in acceptance criterion). Override with `--risk=low` if desired."
|
|
64
76
|
- Users can manually edit `risk_class` in the task JSON before running `/cloverleaf-run`.
|
|
77
|
+
|
|
78
|
+
## v0.4 artifacts
|
|
79
|
+
|
|
80
|
+
- `.cloverleaf/baselines/` is **tracked** in git; baseline PNGs travel with code.
|
|
81
|
+
- `.cloverleaf/runs/` is **gitignored**; each task's run artifacts (diffs, candidate screenshots, QA reports) are ephemeral.
|
|
@@ -19,7 +19,12 @@ description: Run the QA agent on a task in the `qa` state (full pipeline only).
|
|
|
19
19
|
|
|
20
20
|
3. Confirm feature branch exists: `git rev-parse --verify cloverleaf/<TASK-ID>`.
|
|
21
21
|
|
|
22
|
-
4.
|
|
22
|
+
4. Ensure required directories exist:
|
|
23
|
+
```bash
|
|
24
|
+
mkdir -p <repo_root>/.cloverleaf/runs/<TASK-ID>/qa
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
5. Load QA rules JSON:
|
|
23
28
|
```bash
|
|
24
29
|
# Consumer override takes precedence over the package default.
|
|
25
30
|
if [ -f "<repo_root>/.cloverleaf/config/qa-rules.json" ]; then
|
|
@@ -30,19 +35,19 @@ description: Run the QA agent on a task in the `qa` state (full pipeline only).
|
|
|
30
35
|
```
|
|
31
36
|
Capture for the subagent as `qa_rules`.
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
6. Compute diff:
|
|
34
39
|
```bash
|
|
35
40
|
git diff main..cloverleaf/<TASK-ID>
|
|
36
41
|
```
|
|
37
42
|
|
|
38
|
-
|
|
43
|
+
7. Dispatch the QA subagent via the Task tool:
|
|
39
44
|
- `subagent_type`: `general-purpose`
|
|
40
45
|
- `model`: `sonnet`
|
|
41
|
-
- Prompt: contents of `~/.claude/plugins/cloverleaf/prompts/qa.md` with substitutions for `{{task}}`, `{{diff}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{qa_rules}}` (the JSON loaded in step
|
|
46
|
+
- Prompt: contents of `~/.claude/plugins/cloverleaf/prompts/qa.md` with substitutions for `{{task}}`, `{{diff}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{qa_rules}}` (the JSON loaded in step 5).
|
|
42
47
|
|
|
43
|
-
|
|
48
|
+
8. Parse response: expect `{"verdict": "pass"|"bounce"|"escalate", "summary", "findings", "results"}`.
|
|
44
49
|
|
|
45
|
-
|
|
50
|
+
9. Branch on verdict:
|
|
46
51
|
|
|
47
52
|
**Pass:**
|
|
48
53
|
```
|
|
@@ -27,12 +27,18 @@ description: Run the UI Reviewer agent on a task in the `ui-review` state (full
|
|
|
27
27
|
|
|
28
28
|
3. Confirm feature branch exists: `git rev-parse --verify cloverleaf/<TASK-ID>`. If missing, report and stop.
|
|
29
29
|
|
|
30
|
-
4.
|
|
30
|
+
4. Ensure required directories exist:
|
|
31
|
+
```bash
|
|
32
|
+
mkdir -p <repo_root>/.cloverleaf/baselines
|
|
33
|
+
mkdir -p <repo_root>/.cloverleaf/runs/<TASK-ID>/ui-review
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
5. Compute affected routes:
|
|
31
37
|
```bash
|
|
32
38
|
AFFECTED=$(~/.claude/plugins/cloverleaf/bin/cloverleaf-cli affected-routes <repo_root> <TASK-ID>)
|
|
33
39
|
```
|
|
34
40
|
|
|
35
|
-
|
|
41
|
+
6. **Empty-set early-exit.** If `AFFECTED` is `[]`, skip the subagent entirely:
|
|
36
42
|
```bash
|
|
37
43
|
cloverleaf-cli advance-status <repo_root> <TASK-ID> qa agent '' full_pipeline
|
|
38
44
|
cd <repo_root>
|
|
@@ -42,28 +48,29 @@ description: Run the UI Reviewer agent on a task in the `ui-review` state (full
|
|
|
42
48
|
Report: "✓ UI Review skipped (no renderable routes affected). State → qa. Next: `/cloverleaf-qa <TASK-ID>`."
|
|
43
49
|
Stop here.
|
|
44
50
|
|
|
45
|
-
|
|
51
|
+
7. Allocate a free preview port:
|
|
46
52
|
```bash
|
|
47
53
|
PREVIEW_PORT=$(node -e "const net=require('net');const s=net.createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})")
|
|
48
54
|
```
|
|
49
55
|
|
|
50
|
-
|
|
56
|
+
8. Compute diff:
|
|
51
57
|
```bash
|
|
52
58
|
git diff main..cloverleaf/<TASK-ID>
|
|
53
59
|
```
|
|
54
60
|
|
|
55
|
-
|
|
61
|
+
9. **Browser cache env var.** Before the Task-tool dispatch, ensure `PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright` is exported so the subagent inherits it. This keeps Playwright from re-downloading ~300 MB of browser binaries inside the worktree.
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
10. Dispatch the UI Reviewer subagent via the Task tool:
|
|
64
|
+
- `subagent_type`: `general-purpose`
|
|
65
|
+
- `model`: `sonnet`
|
|
66
|
+
- Prompt: contents of `~/.claude/plugins/cloverleaf/prompts/ui-reviewer.md` with substitutions:
|
|
67
|
+
- `{{task}}`, `{{diff}}`, `{{branch}}`, `{{base_branch}}`, `{{repo_root}}`, `{{preview_port}}`
|
|
68
|
+
- `{{affected_routes}}` → the value of `$AFFECTED` (verbatim — may be `"all"`, a JSON array, or `[]` but step 6 handled `[]` already)
|
|
69
|
+
- `{{ui_review_config}}` → JSON-stringified result of `cloverleaf-cli ui-review-config <repo_root>` (used by the subagent to scope viewport sizes, thresholds, and axe rule overrides)
|
|
63
70
|
|
|
64
|
-
|
|
71
|
+
11. Parse the subagent's response. Expect `{"verdict": "pass"|"bounce"|"escalate", "summary": "...", "findings": [...]}`.
|
|
65
72
|
|
|
66
|
-
|
|
73
|
+
12. Branch on verdict:
|
|
67
74
|
|
|
68
75
|
**Pass:**
|
|
69
76
|
```
|
|
@@ -89,5 +96,5 @@ description: Run the UI Reviewer agent on a task in the `ui-review` state (full
|
|
|
89
96
|
- Never push.
|
|
90
97
|
- Do not modify source code — UI Reviewer is read-only.
|
|
91
98
|
- Always teardown preview server + worktree on error.
|
|
92
|
-
- Empty-set early-exit (step
|
|
99
|
+
- Empty-set early-exit (step 6) skips the browser entirely — no Playwright invocation, no worktree.
|
|
93
100
|
- On illegal state transition, report and stop without partial commits.
|