@cloverleaf/reference-impl 0.5.1 → 0.5.3
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 +1 -1
- package/README.md +22 -6
- package/VERSION +1 -1
- package/config/ui-review.json +4 -1
- package/dist/cli.mjs +11 -1
- package/dist/prep-worktree.mjs +50 -0
- package/dist/ui-browser.mjs +74 -0
- package/dist/ui-review-config.mjs +43 -7
- package/dist/visual-diff.mjs +14 -1
- package/install.sh +17 -4
- package/lib/cli.ts +11 -1
- package/lib/prep-worktree.ts +55 -0
- package/lib/ui-browser.ts +122 -0
- package/lib/ui-review-config.ts +61 -7
- package/lib/visual-diff.ts +20 -1
- package/package.json +1 -1
- package/prompts/documenter.md +14 -1
- package/prompts/qa.md +6 -1
- package/prompts/reviewer.md +5 -3
- package/prompts/ui-reviewer.md +104 -60
- package/skills/cloverleaf-merge/SKILL.md +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cloverleaf",
|
|
3
3
|
"description": "Cloverleaf reference implementation — Claude Code skills for task scaffolding and the Delivery pipeline (implementer, documenter, reviewer, UI reviewer with multi-viewport visual diff, QA, merge).",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.3",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Renato D'Arrigo",
|
|
7
7
|
"email": "renato.darrigo@gmail.com"
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ v0.2 implements both paths of the Delivery track:
|
|
|
29
29
|
| Implementer | Real | Subagent, code + tests on feature branch |
|
|
30
30
|
| Documenter | Real (v0.2) | Subagent, doc-only commits per file-path rules |
|
|
31
31
|
| Reviewer | Real | Subagent, read-only review of diff |
|
|
32
|
-
| UI Reviewer | Real (v0.
|
|
32
|
+
| UI Reviewer | Real (v0.5) | Playwright + axe-core + pixelmatch; multi-browser outer loop (chromium/webkit/firefox); axe-core runs on `axe.browser` engine only (default chromium); maxCombinations cap with per-route warnings |
|
|
33
33
|
| QA | Real (v0.2) | Per-package test runner via `git worktree` |
|
|
34
34
|
| Plan | Stub | Deferred to v0.3 |
|
|
35
35
|
| Researcher | Stub | Deferred to v0.3 |
|
|
@@ -87,16 +87,32 @@ All overrides are read fresh on every skill invocation; no caching. Edit and the
|
|
|
87
87
|
### Known limitations
|
|
88
88
|
|
|
89
89
|
- Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
|
|
90
|
-
- UI Reviewer visual diff + multi-viewport deferred to v0.4.
|
|
91
90
|
- QA does not produce HTML reports (no `report_uri`).
|
|
92
91
|
|
|
93
92
|
### Prerequisites for UI Reviewer
|
|
94
93
|
|
|
95
|
-
|
|
94
|
+
The installer (`install.sh`) automatically runs the Playwright browser install step. If you need to install manually, run:
|
|
96
95
|
|
|
97
|
-
npx playwright install chromium
|
|
96
|
+
npx playwright install chromium webkit firefox
|
|
98
97
|
|
|
99
|
-
|
|
98
|
+
On **Linux**, webkit additionally requires system-level dependencies:
|
|
99
|
+
|
|
100
|
+
npx playwright install-deps webkit
|
|
101
|
+
|
|
102
|
+
**Disk footprint:** approximately 600–650 MB total across all three browsers in the default `PLAYWRIGHT_BROWSERS_PATH` location (`~/.cache/ms-playwright/`).
|
|
103
|
+
|
|
104
|
+
| Browser | Approx. size |
|
|
105
|
+
|-----------|-------------|
|
|
106
|
+
| chromium | ~300 MB |
|
|
107
|
+
| webkit | ~150–170 MB |
|
|
108
|
+
| firefox | ~150–180 MB |
|
|
109
|
+
|
|
110
|
+
To store browsers in a non-default location, set `PLAYWRIGHT_BROWSERS_PATH` before installing and before running the UI Reviewer skill:
|
|
111
|
+
|
|
112
|
+
export PLAYWRIGHT_BROWSERS_PATH=/mnt/data/playwright
|
|
113
|
+
npx playwright install chromium webkit firefox
|
|
114
|
+
|
|
115
|
+
Subsequent `/cloverleaf-ui-review` invocations reuse the cache — no re-download per run as long as `PLAYWRIGHT_BROWSERS_PATH` is set consistently.
|
|
100
116
|
|
|
101
117
|
## Quick start — toy repo
|
|
102
118
|
|
|
@@ -129,7 +145,7 @@ The Reviewer never switches branches. It reads files via `git show` and runs tes
|
|
|
129
145
|
|
|
130
146
|
## Package layout
|
|
131
147
|
|
|
132
|
-
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths.
|
|
148
|
+
- `lib/` — TypeScript library used by the CLI. State, events, feedback, IDs, paths. Includes `buildBaselinePath(repoRoot, browser, slug, viewport)` (`lib/visual-diff.ts`) for constructing canonical baseline paths under `.cloverleaf/baselines/{browser}/`. `lib/ui-browser.ts` exports `buildBrowserEscalationFinding` and `applyMaxCombinationsCap` (used by the UI Reviewer prompt for per-engine escalation and combination-count capping).
|
|
133
149
|
- `skills/` — Claude Code skill markdown files.
|
|
134
150
|
- `prompts/` — Implementer/Reviewer subagent system prompts.
|
|
135
151
|
- `examples/toy-repo/` — standalone demo repo.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.5.
|
|
1
|
+
0.5.3
|
package/config/ui-review.json
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
+
"browsers": ["chromium"],
|
|
2
3
|
"viewports": {
|
|
3
4
|
"mobile": { "width": 375, "height": 667 },
|
|
4
5
|
"tablet": { "width": 768, "height": 1024 },
|
|
@@ -12,7 +13,9 @@
|
|
|
12
13
|
},
|
|
13
14
|
"axe": {
|
|
14
15
|
"viewports": ["desktop"],
|
|
16
|
+
"browser": "chromium",
|
|
15
17
|
"dedupeBy": ["ruleId", "target"],
|
|
16
18
|
"ignored": []
|
|
17
|
-
}
|
|
19
|
+
},
|
|
20
|
+
"maxCombinations": 90
|
|
18
21
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* materialise-tasks <repoRoot> <planId>
|
|
27
27
|
* next-work-item-id <repoRoot> <project>
|
|
28
28
|
* discovery-config --repo-root <repoRoot>
|
|
29
|
+
* prep-worktree <mainRoot> <worktreePath>
|
|
29
30
|
*/
|
|
30
31
|
import { readFileSync } from 'node:fs';
|
|
31
32
|
import { execSync } from 'node:child_process';
|
|
@@ -44,6 +45,7 @@ import { loadRfc, saveRfc, advanceRfcStatus } from './rfc.mjs';
|
|
|
44
45
|
import { loadSpike, saveSpike, advanceSpikeStatus } from './spike.mjs';
|
|
45
46
|
import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan } from './plan.mjs';
|
|
46
47
|
import { loadDiscoveryConfig } from './discovery-config.mjs';
|
|
48
|
+
import { prepWorktree } from './prep-worktree.mjs';
|
|
47
49
|
function die(msg, code = 1) {
|
|
48
50
|
process.stderr.write(msg + '\n');
|
|
49
51
|
process.exit(code);
|
|
@@ -73,7 +75,8 @@ function usage(msg) {
|
|
|
73
75
|
' advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
74
76
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
75
77
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
76
|
-
' discovery-config --repo-root <repoRoot>\n'
|
|
78
|
+
' discovery-config --repo-root <repoRoot>\n' +
|
|
79
|
+
' prep-worktree <mainRoot> <worktreePath>\n');
|
|
77
80
|
process.exit(2);
|
|
78
81
|
}
|
|
79
82
|
const [, , command, ...rest] = process.argv;
|
|
@@ -368,6 +371,13 @@ try {
|
|
|
368
371
|
process.stdout.write(JSON.stringify(c, null, 2));
|
|
369
372
|
break;
|
|
370
373
|
}
|
|
374
|
+
case 'prep-worktree': {
|
|
375
|
+
const [mainRoot, worktreePath] = rest;
|
|
376
|
+
if (!mainRoot || !worktreePath)
|
|
377
|
+
usage('prep-worktree requires <mainRoot> <worktreePath>');
|
|
378
|
+
prepWorktree(mainRoot, worktreePath);
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
371
381
|
default:
|
|
372
382
|
usage(`Unknown command: ${command}`);
|
|
373
383
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cpSync, existsSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Prepare a freshly-created git worktree of the cloverleaf monorepo for running reference-impl
|
|
6
|
+
* tests. Addresses the v0.5 dogfood finding (CLV-16, CLV-17 Delivery runs) where Reviewer/QA
|
|
7
|
+
* subagents hit `Cannot find module '@cloverleaf/standard/validators/index.js'` because:
|
|
8
|
+
*
|
|
9
|
+
* 1. The worktree has no `node_modules` at all (git worktrees don't inherit it).
|
|
10
|
+
* 2. Running `npm install` in the worktree's `reference-impl/` follows the `file:../standard`
|
|
11
|
+
* dep, but the worktree's `standard/` has no `dist/` (nothing built) and no `node_modules/`
|
|
12
|
+
* (ajv-formats etc., needed by the conformance runner).
|
|
13
|
+
*
|
|
14
|
+
* Strategy: reuse main's already-installed deps and build standard/ fresh from the worktree
|
|
15
|
+
* sources so any branch changes to `standard/src` are picked up.
|
|
16
|
+
*
|
|
17
|
+
* - Copy `<main>/standard/node_modules` → `<wt>/standard/node_modules`
|
|
18
|
+
* - Run `npm run build` in `<wt>/standard` (produces worktree dist/)
|
|
19
|
+
* - Copy `<main>/reference-impl/node_modules` → `<wt>/reference-impl/node_modules`
|
|
20
|
+
* The `@cloverleaf/standard → ../../../standard` relative symlink is preserved verbatim so
|
|
21
|
+
* it resolves to the worktree's OWN standard/, not main's.
|
|
22
|
+
*/
|
|
23
|
+
export function prepWorktree(mainRoot, worktreePath) {
|
|
24
|
+
const mainStandardNm = join(mainRoot, 'standard', 'node_modules');
|
|
25
|
+
const mainRefImplNm = join(mainRoot, 'reference-impl', 'node_modules');
|
|
26
|
+
const wtStandardPkg = join(worktreePath, 'standard', 'package.json');
|
|
27
|
+
const wtRefImplPkg = join(worktreePath, 'reference-impl', 'package.json');
|
|
28
|
+
if (!existsSync(wtStandardPkg)) {
|
|
29
|
+
throw new Error(`worktree missing standard/package.json at ${wtStandardPkg}`);
|
|
30
|
+
}
|
|
31
|
+
if (!existsSync(wtRefImplPkg)) {
|
|
32
|
+
throw new Error(`worktree missing reference-impl/package.json at ${wtRefImplPkg}`);
|
|
33
|
+
}
|
|
34
|
+
if (!existsSync(mainStandardNm)) {
|
|
35
|
+
throw new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
|
|
36
|
+
}
|
|
37
|
+
if (!existsSync(mainRefImplNm)) {
|
|
38
|
+
throw new Error(`main missing reference-impl/node_modules at ${mainRefImplNm} — run \`npm ci\` in main's reference-impl/ first`);
|
|
39
|
+
}
|
|
40
|
+
const wtStandardNm = join(worktreePath, 'standard', 'node_modules');
|
|
41
|
+
const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
|
|
42
|
+
// verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
|
|
43
|
+
// link in reference-impl/node_modules/ resolves against the worktree after copy.
|
|
44
|
+
cpSync(mainStandardNm, wtStandardNm, { recursive: true, verbatimSymlinks: true });
|
|
45
|
+
cpSync(mainRefImplNm, wtRefImplNm, { recursive: true, verbatimSymlinks: true });
|
|
46
|
+
execSync('npm run build', {
|
|
47
|
+
cwd: join(worktreePath, 'standard'),
|
|
48
|
+
stdio: 'pipe',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Browser escalation
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
/**
|
|
5
|
+
* Build an escalation Finding for a missing Playwright browser binary.
|
|
6
|
+
*
|
|
7
|
+
* The finding names the missing engine and includes the install command per
|
|
8
|
+
* the CLV-9 RFC and CLV-10 spike:
|
|
9
|
+
* - All platforms: `npx playwright install webkit firefox`
|
|
10
|
+
* - Linux only: `npx playwright install-deps webkit`
|
|
11
|
+
*
|
|
12
|
+
* @param engine The browser engine that is missing.
|
|
13
|
+
* @param platform The platform string (defaults to `process.platform`). Pass
|
|
14
|
+
* "linux" explicitly to include the install-deps hint; all
|
|
15
|
+
* other values are treated as non-Linux.
|
|
16
|
+
*/
|
|
17
|
+
export function buildBrowserEscalationFinding(engine, platform = process.platform) {
|
|
18
|
+
const isLinux = platform === 'linux';
|
|
19
|
+
const installCmd = 'npx playwright install webkit firefox';
|
|
20
|
+
const depsHint = isLinux
|
|
21
|
+
? ` On Linux, also run: npx playwright install-deps webkit`
|
|
22
|
+
: '';
|
|
23
|
+
return {
|
|
24
|
+
severity: 'error',
|
|
25
|
+
rule: 'browser-missing',
|
|
26
|
+
message: `Playwright ${engine} not installed. Run '${installCmd}' on this machine.${depsHint}`,
|
|
27
|
+
metadata: { engine, installCommand: installCmd },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Enforce the maxCombinations cap.
|
|
32
|
+
*
|
|
33
|
+
* When `routes.length × viewportCount × browserCount > maxCombinations`,
|
|
34
|
+
* the affected routes are sorted by diff size (most-changed first) and only
|
|
35
|
+
* the first `floor(maxCombinations / (viewportCount × browserCount))` routes
|
|
36
|
+
* are processed. One `warning`-severity finding with rule `ui-review-cap` is
|
|
37
|
+
* emitted per skipped route.
|
|
38
|
+
*
|
|
39
|
+
* @param routes Affected routes with their diff sizes.
|
|
40
|
+
* @param viewportCount Number of viewports configured.
|
|
41
|
+
* @param browserCount Number of browser engines configured.
|
|
42
|
+
* @param maxCombinations Maximum allowed combinations (routes × viewports × browsers).
|
|
43
|
+
* @returns `{ routes, skippedFindings }` ready for use by the reviewer.
|
|
44
|
+
*/
|
|
45
|
+
export function applyMaxCombinationsCap(routes, viewportCount, browserCount, maxCombinations) {
|
|
46
|
+
const totalCombinations = routes.length * viewportCount * browserCount;
|
|
47
|
+
if (totalCombinations <= maxCombinations) {
|
|
48
|
+
return {
|
|
49
|
+
routes: routes.map((r) => r.route),
|
|
50
|
+
skippedFindings: [],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const perRouteSlots = viewportCount * browserCount;
|
|
54
|
+
const maxRoutes = Math.floor(maxCombinations / perRouteSlots);
|
|
55
|
+
// Sort most-changed first, then take first maxRoutes routes.
|
|
56
|
+
const sorted = [...routes].sort((a, b) => b.diffSize - a.diffSize);
|
|
57
|
+
const kept = sorted.slice(0, maxRoutes);
|
|
58
|
+
const skipped = sorted.slice(maxRoutes);
|
|
59
|
+
const skippedFindings = skipped.map((r) => ({
|
|
60
|
+
severity: 'warning',
|
|
61
|
+
rule: 'ui-review-cap',
|
|
62
|
+
message: `Route ${r.route} skipped: combination count ${totalCombinations} exceeds ` +
|
|
63
|
+
`maxCombinations (${maxCombinations}); review manually or raise the cap.`,
|
|
64
|
+
metadata: {
|
|
65
|
+
route: r.route,
|
|
66
|
+
combinationCount: totalCombinations,
|
|
67
|
+
maxCombinations,
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
return {
|
|
71
|
+
routes: kept.map((r) => r.route),
|
|
72
|
+
skippedFindings,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -4,22 +4,58 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
|
|
6
6
|
const HARDCODED_FALLBACK = {
|
|
7
|
+
browsers: ['chromium'],
|
|
7
8
|
viewports: {
|
|
8
9
|
mobile: { width: 375, height: 667 },
|
|
9
10
|
tablet: { width: 768, height: 1024 },
|
|
10
11
|
desktop: { width: 1280, height: 800 },
|
|
11
12
|
},
|
|
12
13
|
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
13
|
-
axe: {
|
|
14
|
+
axe: {
|
|
15
|
+
viewports: ['desktop'],
|
|
16
|
+
browser: 'chromium',
|
|
17
|
+
dedupeBy: ['ruleId', 'target'],
|
|
18
|
+
ignored: [],
|
|
19
|
+
},
|
|
20
|
+
maxCombinations: 90,
|
|
14
21
|
};
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
function applyDefaults(doc) {
|
|
23
|
+
// browsers — default ["chromium"]
|
|
24
|
+
if (!Array.isArray(doc.browsers)) {
|
|
25
|
+
doc.browsers = ['chromium'];
|
|
26
|
+
}
|
|
27
|
+
// viewports — fall through to hardcoded if omitted entirely
|
|
28
|
+
if (!doc.viewports) {
|
|
29
|
+
doc.viewports = HARDCODED_FALLBACK.viewports;
|
|
30
|
+
}
|
|
31
|
+
// visualDiff
|
|
32
|
+
if (!doc.visualDiff) {
|
|
33
|
+
doc.visualDiff = { ...HARDCODED_FALLBACK.visualDiff };
|
|
34
|
+
}
|
|
35
|
+
// axe sub-fields
|
|
36
|
+
if (!doc.axe) {
|
|
37
|
+
doc.axe = { ...HARDCODED_FALLBACK.axe };
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
// Back-compat: ignored omitted in older overrides
|
|
41
|
+
if (!('ignored' in doc.axe)) {
|
|
20
42
|
doc.axe.ignored = [];
|
|
21
43
|
}
|
|
22
|
-
|
|
44
|
+
// axe.browser — default "chromium"
|
|
45
|
+
if (!('browser' in doc.axe)) {
|
|
46
|
+
doc.axe.browser = 'chromium';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// maxCombinations — default 90
|
|
50
|
+
if (typeof doc.maxCombinations !== 'number') {
|
|
51
|
+
doc.maxCombinations = 90;
|
|
52
|
+
}
|
|
53
|
+
return doc;
|
|
54
|
+
}
|
|
55
|
+
function readAsConfig(path) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
58
|
+
return applyDefaults(raw);
|
|
23
59
|
}
|
|
24
60
|
catch {
|
|
25
61
|
return null;
|
package/dist/visual-diff.mjs
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import pixelmatch from 'pixelmatch';
|
|
4
4
|
import { PNG } from 'pngjs';
|
|
5
|
+
/**
|
|
6
|
+
* Construct the canonical baseline path for a visual-diff capture.
|
|
7
|
+
*
|
|
8
|
+
* Layout (as of CLV-17): `.cloverleaf/baselines/{browser}/{slug}-{viewport}.png`
|
|
9
|
+
*
|
|
10
|
+
* The flat layout `.cloverleaf/baselines/{slug}-{viewport}.png` is DEPRECATED.
|
|
11
|
+
* All new baselines MUST be placed under `baselines/{browser}/`.
|
|
12
|
+
* Existing flat chromium baselines were migrated to `baselines/chromium/` via
|
|
13
|
+
* explicit `git mv` in CLV-17.
|
|
14
|
+
*/
|
|
15
|
+
export function buildBaselinePath(repoRoot, browser, slug, viewport) {
|
|
16
|
+
return join(repoRoot, '.cloverleaf', 'baselines', browser, `${slug}-${viewport}.png`);
|
|
17
|
+
}
|
|
5
18
|
function ensureDir(path) {
|
|
6
19
|
mkdirSync(dirname(path), { recursive: true });
|
|
7
20
|
}
|
package/install.sh
CHANGED
|
@@ -42,9 +42,22 @@ echo " /cloverleaf-merge — merge gate"
|
|
|
42
42
|
echo ""
|
|
43
43
|
echo "Restart any open Claude Code sessions to pick up the new skills."
|
|
44
44
|
|
|
45
|
-
# Post-install:
|
|
46
|
-
|
|
45
|
+
# Post-install: install Playwright browsers for UI Reviewer.
|
|
46
|
+
echo ""
|
|
47
|
+
echo "Installing Playwright browsers (chromium, webkit, firefox)..."
|
|
48
|
+
echo " (approx. 600–650 MB total in \${PLAYWRIGHT_BROWSERS_PATH:-~/.cache/ms-playwright})"
|
|
49
|
+
echo ""
|
|
50
|
+
|
|
51
|
+
npx playwright install chromium webkit firefox
|
|
52
|
+
|
|
53
|
+
# On Linux, system deps for webkit are also required.
|
|
54
|
+
if [ "$(uname -s)" = "Linux" ]; then
|
|
47
55
|
echo ""
|
|
48
|
-
echo "
|
|
49
|
-
|
|
56
|
+
echo "Linux detected — installing webkit system dependencies..."
|
|
57
|
+
npx playwright install-deps webkit
|
|
50
58
|
fi
|
|
59
|
+
|
|
60
|
+
echo ""
|
|
61
|
+
echo "Playwright browsers installed."
|
|
62
|
+
echo "To use a custom cache directory, set PLAYWRIGHT_BROWSERS_PATH before running install."
|
|
63
|
+
echo " e.g. PLAYWRIGHT_BROWSERS_PATH=~/.cache/ms-playwright ./install.sh"
|
package/lib/cli.ts
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* materialise-tasks <repoRoot> <planId>
|
|
27
27
|
* next-work-item-id <repoRoot> <project>
|
|
28
28
|
* discovery-config --repo-root <repoRoot>
|
|
29
|
+
* prep-worktree <mainRoot> <worktreePath>
|
|
29
30
|
*/
|
|
30
31
|
|
|
31
32
|
import { readFileSync } from 'node:fs';
|
|
@@ -46,6 +47,7 @@ import { loadRfc, saveRfc, advanceRfcStatus, type RfcDoc } from './rfc.js';
|
|
|
46
47
|
import { loadSpike, saveSpike, advanceSpikeStatus, type SpikeDoc } from './spike.js';
|
|
47
48
|
import { loadPlan, savePlan, advancePlanStatus, materialiseTasksFromPlan, type PlanDoc } from './plan.js';
|
|
48
49
|
import { loadDiscoveryConfig } from './discovery-config.js';
|
|
50
|
+
import { prepWorktree } from './prep-worktree.js';
|
|
49
51
|
|
|
50
52
|
function die(msg: string, code = 1): never {
|
|
51
53
|
process.stderr.write(msg + '\n');
|
|
@@ -77,7 +79,8 @@ function usage(msg?: string): never {
|
|
|
77
79
|
' advance-plan <repoRoot> <id> <toStatus> <agent|human> [gate]\n' +
|
|
78
80
|
' materialise-tasks <repoRoot> <planId>\n' +
|
|
79
81
|
' next-work-item-id <repoRoot> <project>\n' +
|
|
80
|
-
' discovery-config --repo-root <repoRoot>\n'
|
|
82
|
+
' discovery-config --repo-root <repoRoot>\n' +
|
|
83
|
+
' prep-worktree <mainRoot> <worktreePath>\n'
|
|
81
84
|
);
|
|
82
85
|
process.exit(2);
|
|
83
86
|
}
|
|
@@ -375,6 +378,13 @@ try {
|
|
|
375
378
|
break;
|
|
376
379
|
}
|
|
377
380
|
|
|
381
|
+
case 'prep-worktree': {
|
|
382
|
+
const [mainRoot, worktreePath] = rest;
|
|
383
|
+
if (!mainRoot || !worktreePath) usage('prep-worktree requires <mainRoot> <worktreePath>');
|
|
384
|
+
prepWorktree(mainRoot, worktreePath);
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
|
|
378
388
|
default:
|
|
379
389
|
usage(`Unknown command: ${command}`);
|
|
380
390
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { cpSync, existsSync } from 'node:fs';
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Prepare a freshly-created git worktree of the cloverleaf monorepo for running reference-impl
|
|
7
|
+
* tests. Addresses the v0.5 dogfood finding (CLV-16, CLV-17 Delivery runs) where Reviewer/QA
|
|
8
|
+
* subagents hit `Cannot find module '@cloverleaf/standard/validators/index.js'` because:
|
|
9
|
+
*
|
|
10
|
+
* 1. The worktree has no `node_modules` at all (git worktrees don't inherit it).
|
|
11
|
+
* 2. Running `npm install` in the worktree's `reference-impl/` follows the `file:../standard`
|
|
12
|
+
* dep, but the worktree's `standard/` has no `dist/` (nothing built) and no `node_modules/`
|
|
13
|
+
* (ajv-formats etc., needed by the conformance runner).
|
|
14
|
+
*
|
|
15
|
+
* Strategy: reuse main's already-installed deps and build standard/ fresh from the worktree
|
|
16
|
+
* sources so any branch changes to `standard/src` are picked up.
|
|
17
|
+
*
|
|
18
|
+
* - Copy `<main>/standard/node_modules` → `<wt>/standard/node_modules`
|
|
19
|
+
* - Run `npm run build` in `<wt>/standard` (produces worktree dist/)
|
|
20
|
+
* - Copy `<main>/reference-impl/node_modules` → `<wt>/reference-impl/node_modules`
|
|
21
|
+
* The `@cloverleaf/standard → ../../../standard` relative symlink is preserved verbatim so
|
|
22
|
+
* it resolves to the worktree's OWN standard/, not main's.
|
|
23
|
+
*/
|
|
24
|
+
export function prepWorktree(mainRoot: string, worktreePath: string): void {
|
|
25
|
+
const mainStandardNm = join(mainRoot, 'standard', 'node_modules');
|
|
26
|
+
const mainRefImplNm = join(mainRoot, 'reference-impl', 'node_modules');
|
|
27
|
+
const wtStandardPkg = join(worktreePath, 'standard', 'package.json');
|
|
28
|
+
const wtRefImplPkg = join(worktreePath, 'reference-impl', 'package.json');
|
|
29
|
+
|
|
30
|
+
if (!existsSync(wtStandardPkg)) {
|
|
31
|
+
throw new Error(`worktree missing standard/package.json at ${wtStandardPkg}`);
|
|
32
|
+
}
|
|
33
|
+
if (!existsSync(wtRefImplPkg)) {
|
|
34
|
+
throw new Error(`worktree missing reference-impl/package.json at ${wtRefImplPkg}`);
|
|
35
|
+
}
|
|
36
|
+
if (!existsSync(mainStandardNm)) {
|
|
37
|
+
throw new Error(`main missing standard/node_modules at ${mainStandardNm} — run \`npm ci\` in main's standard/ first`);
|
|
38
|
+
}
|
|
39
|
+
if (!existsSync(mainRefImplNm)) {
|
|
40
|
+
throw new Error(`main missing reference-impl/node_modules at ${mainRefImplNm} — run \`npm ci\` in main's reference-impl/ first`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const wtStandardNm = join(worktreePath, 'standard', 'node_modules');
|
|
44
|
+
const wtRefImplNm = join(worktreePath, 'reference-impl', 'node_modules');
|
|
45
|
+
|
|
46
|
+
// verbatimSymlinks keeps relative symlink targets byte-identical, so the @cloverleaf/standard
|
|
47
|
+
// link in reference-impl/node_modules/ resolves against the worktree after copy.
|
|
48
|
+
cpSync(mainStandardNm, wtStandardNm, { recursive: true, verbatimSymlinks: true });
|
|
49
|
+
cpSync(mainRefImplNm, wtRefImplNm, { recursive: true, verbatimSymlinks: true });
|
|
50
|
+
|
|
51
|
+
execSync('npm run build', {
|
|
52
|
+
cwd: join(worktreePath, 'standard'),
|
|
53
|
+
stdio: 'pipe',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Finding } from './feedback.js';
|
|
2
|
+
import type { BrowserEngine } from './ui-review-config.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Browser escalation
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build an escalation Finding for a missing Playwright browser binary.
|
|
10
|
+
*
|
|
11
|
+
* The finding names the missing engine and includes the install command per
|
|
12
|
+
* the CLV-9 RFC and CLV-10 spike:
|
|
13
|
+
* - All platforms: `npx playwright install webkit firefox`
|
|
14
|
+
* - Linux only: `npx playwright install-deps webkit`
|
|
15
|
+
*
|
|
16
|
+
* @param engine The browser engine that is missing.
|
|
17
|
+
* @param platform The platform string (defaults to `process.platform`). Pass
|
|
18
|
+
* "linux" explicitly to include the install-deps hint; all
|
|
19
|
+
* other values are treated as non-Linux.
|
|
20
|
+
*/
|
|
21
|
+
export function buildBrowserEscalationFinding(
|
|
22
|
+
engine: BrowserEngine,
|
|
23
|
+
platform: string = process.platform,
|
|
24
|
+
): Finding {
|
|
25
|
+
const isLinux = platform === 'linux';
|
|
26
|
+
const installCmd = 'npx playwright install webkit firefox';
|
|
27
|
+
const depsHint = isLinux
|
|
28
|
+
? ` On Linux, also run: npx playwright install-deps webkit`
|
|
29
|
+
: '';
|
|
30
|
+
return {
|
|
31
|
+
severity: 'error',
|
|
32
|
+
rule: 'browser-missing',
|
|
33
|
+
message:
|
|
34
|
+
`Playwright ${engine} not installed. Run '${installCmd}' on this machine.${depsHint}`,
|
|
35
|
+
metadata: { engine, installCommand: installCmd },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// maxCombinations cap enforcement
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Represents an affected route with a diff-size weight used for sorting
|
|
45
|
+
* when maxCombinations cap is applied.
|
|
46
|
+
*/
|
|
47
|
+
export interface RouteWithDiffSize {
|
|
48
|
+
route: string;
|
|
49
|
+
/** Number of changed lines (or any monotonic proxy for diff size). */
|
|
50
|
+
diffSize: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Result of applying the maxCombinations cap.
|
|
55
|
+
*/
|
|
56
|
+
export interface CapResult {
|
|
57
|
+
/** Routes that should be processed (up to the cap). */
|
|
58
|
+
routes: string[];
|
|
59
|
+
/**
|
|
60
|
+
* One `warning`-severity Finding per skipped route, with rule
|
|
61
|
+
* `ui-review-cap` and a message containing the route name plus the
|
|
62
|
+
* combination count vs cap.
|
|
63
|
+
*/
|
|
64
|
+
skippedFindings: Finding[];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enforce the maxCombinations cap.
|
|
69
|
+
*
|
|
70
|
+
* When `routes.length × viewportCount × browserCount > maxCombinations`,
|
|
71
|
+
* the affected routes are sorted by diff size (most-changed first) and only
|
|
72
|
+
* the first `floor(maxCombinations / (viewportCount × browserCount))` routes
|
|
73
|
+
* are processed. One `warning`-severity finding with rule `ui-review-cap` is
|
|
74
|
+
* emitted per skipped route.
|
|
75
|
+
*
|
|
76
|
+
* @param routes Affected routes with their diff sizes.
|
|
77
|
+
* @param viewportCount Number of viewports configured.
|
|
78
|
+
* @param browserCount Number of browser engines configured.
|
|
79
|
+
* @param maxCombinations Maximum allowed combinations (routes × viewports × browsers).
|
|
80
|
+
* @returns `{ routes, skippedFindings }` ready for use by the reviewer.
|
|
81
|
+
*/
|
|
82
|
+
export function applyMaxCombinationsCap(
|
|
83
|
+
routes: RouteWithDiffSize[],
|
|
84
|
+
viewportCount: number,
|
|
85
|
+
browserCount: number,
|
|
86
|
+
maxCombinations: number,
|
|
87
|
+
): CapResult {
|
|
88
|
+
const totalCombinations = routes.length * viewportCount * browserCount;
|
|
89
|
+
|
|
90
|
+
if (totalCombinations <= maxCombinations) {
|
|
91
|
+
return {
|
|
92
|
+
routes: routes.map((r) => r.route),
|
|
93
|
+
skippedFindings: [],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const perRouteSlots = viewportCount * browserCount;
|
|
98
|
+
const maxRoutes = Math.floor(maxCombinations / perRouteSlots);
|
|
99
|
+
|
|
100
|
+
// Sort most-changed first, then take first maxRoutes routes.
|
|
101
|
+
const sorted = [...routes].sort((a, b) => b.diffSize - a.diffSize);
|
|
102
|
+
const kept = sorted.slice(0, maxRoutes);
|
|
103
|
+
const skipped = sorted.slice(maxRoutes);
|
|
104
|
+
|
|
105
|
+
const skippedFindings: Finding[] = skipped.map((r) => ({
|
|
106
|
+
severity: 'warning',
|
|
107
|
+
rule: 'ui-review-cap',
|
|
108
|
+
message:
|
|
109
|
+
`Route ${r.route} skipped: combination count ${totalCombinations} exceeds ` +
|
|
110
|
+
`maxCombinations (${maxCombinations}); review manually or raise the cap.`,
|
|
111
|
+
metadata: {
|
|
112
|
+
route: r.route,
|
|
113
|
+
combinationCount: totalCombinations,
|
|
114
|
+
maxCombinations,
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
routes: kept.map((r) => r.route),
|
|
120
|
+
skippedFindings,
|
|
121
|
+
};
|
|
122
|
+
}
|
package/lib/ui-review-config.ts
CHANGED
|
@@ -10,7 +10,12 @@ export interface Viewport {
|
|
|
10
10
|
height: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Valid Playwright browser engine strings. */
|
|
14
|
+
export type BrowserEngine = 'chromium' | 'webkit' | 'firefox';
|
|
15
|
+
|
|
13
16
|
export interface UiReviewConfig {
|
|
17
|
+
/** Browser engines to run visual-diff + axe across. Default: ["chromium"]. */
|
|
18
|
+
browsers: BrowserEngine[];
|
|
14
19
|
viewports: Record<string, Viewport>;
|
|
15
20
|
visualDiff: {
|
|
16
21
|
enabled: boolean;
|
|
@@ -20,29 +25,78 @@ export interface UiReviewConfig {
|
|
|
20
25
|
};
|
|
21
26
|
axe: {
|
|
22
27
|
viewports: string[];
|
|
28
|
+
/** Browser engine to use for axe accessibility runs. Default: "chromium". */
|
|
29
|
+
browser: BrowserEngine;
|
|
23
30
|
dedupeBy: ('ruleId' | 'target')[];
|
|
24
31
|
ignored: Array<{ ruleId: string; target: string }>;
|
|
25
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Maximum number of (browser × viewport) combinations to run.
|
|
35
|
+
* The skill will skip combinations beyond this limit to avoid runaway runtimes.
|
|
36
|
+
* Default: 90.
|
|
37
|
+
*/
|
|
38
|
+
maxCombinations: number;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
const HARDCODED_FALLBACK: UiReviewConfig = {
|
|
42
|
+
browsers: ['chromium'],
|
|
29
43
|
viewports: {
|
|
30
44
|
mobile: { width: 375, height: 667 },
|
|
31
45
|
tablet: { width: 768, height: 1024 },
|
|
32
46
|
desktop: { width: 1280, height: 800 },
|
|
33
47
|
},
|
|
34
48
|
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
35
|
-
axe: {
|
|
49
|
+
axe: {
|
|
50
|
+
viewports: ['desktop'],
|
|
51
|
+
browser: 'chromium',
|
|
52
|
+
dedupeBy: ['ruleId', 'target'],
|
|
53
|
+
ignored: [],
|
|
54
|
+
},
|
|
55
|
+
maxCombinations: 90,
|
|
36
56
|
};
|
|
37
57
|
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
58
|
+
function applyDefaults(doc: Partial<UiReviewConfig>): UiReviewConfig {
|
|
59
|
+
// browsers — default ["chromium"]
|
|
60
|
+
if (!Array.isArray(doc.browsers)) {
|
|
61
|
+
(doc as UiReviewConfig).browsers = ['chromium'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// viewports — fall through to hardcoded if omitted entirely
|
|
65
|
+
if (!doc.viewports) {
|
|
66
|
+
(doc as UiReviewConfig).viewports = HARDCODED_FALLBACK.viewports;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// visualDiff
|
|
70
|
+
if (!doc.visualDiff) {
|
|
71
|
+
(doc as UiReviewConfig).visualDiff = { ...HARDCODED_FALLBACK.visualDiff };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// axe sub-fields
|
|
75
|
+
if (!doc.axe) {
|
|
76
|
+
(doc as UiReviewConfig).axe = { ...HARDCODED_FALLBACK.axe };
|
|
77
|
+
} else {
|
|
78
|
+
// Back-compat: ignored omitted in older overrides
|
|
79
|
+
if (!('ignored' in doc.axe)) {
|
|
43
80
|
(doc.axe as UiReviewConfig['axe']).ignored = [];
|
|
44
81
|
}
|
|
45
|
-
|
|
82
|
+
// axe.browser — default "chromium"
|
|
83
|
+
if (!('browser' in doc.axe)) {
|
|
84
|
+
(doc.axe as UiReviewConfig['axe']).browser = 'chromium';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// maxCombinations — default 90
|
|
89
|
+
if (typeof doc.maxCombinations !== 'number') {
|
|
90
|
+
(doc as UiReviewConfig).maxCombinations = 90;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return doc as UiReviewConfig;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readAsConfig(path: string): UiReviewConfig | null {
|
|
97
|
+
try {
|
|
98
|
+
const raw = JSON.parse(readFileSync(path, 'utf-8')) as Partial<UiReviewConfig>;
|
|
99
|
+
return applyDefaults(raw);
|
|
46
100
|
} catch {
|
|
47
101
|
return null;
|
|
48
102
|
}
|
package/lib/visual-diff.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname } from 'node:path';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import pixelmatch from 'pixelmatch';
|
|
4
4
|
import { PNG } from 'pngjs';
|
|
5
5
|
|
|
6
6
|
export type VisualDiffStatus = 'new-baseline' | 'match' | 'diff' | 'dimension-mismatch';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Construct the canonical baseline path for a visual-diff capture.
|
|
10
|
+
*
|
|
11
|
+
* Layout (as of CLV-17): `.cloverleaf/baselines/{browser}/{slug}-{viewport}.png`
|
|
12
|
+
*
|
|
13
|
+
* The flat layout `.cloverleaf/baselines/{slug}-{viewport}.png` is DEPRECATED.
|
|
14
|
+
* All new baselines MUST be placed under `baselines/{browser}/`.
|
|
15
|
+
* Existing flat chromium baselines were migrated to `baselines/chromium/` via
|
|
16
|
+
* explicit `git mv` in CLV-17.
|
|
17
|
+
*/
|
|
18
|
+
export function buildBaselinePath(
|
|
19
|
+
repoRoot: string,
|
|
20
|
+
browser: string,
|
|
21
|
+
slug: string,
|
|
22
|
+
viewport: string,
|
|
23
|
+
): string {
|
|
24
|
+
return join(repoRoot, '.cloverleaf', 'baselines', browser, `${slug}-${viewport}.png`);
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
export interface VisualDiffResult {
|
|
9
28
|
status: VisualDiffStatus;
|
|
10
29
|
diffPixels: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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",
|
package/prompts/documenter.md
CHANGED
|
@@ -52,10 +52,23 @@ If `## [Unreleased]` does not exist, create it at the top of the CHANGELOG (righ
|
|
|
52
52
|
|
|
53
53
|
## Commit discipline
|
|
54
54
|
|
|
55
|
-
-
|
|
55
|
+
- **Before committing, run `git status --porcelain` in the worktree and stage every modified doc file.** Do NOT hardcode a single path into `git add`; the subagent has historically forgotten README.md and committed only CHANGELOG.md when it edited both. The reliable pattern:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cd <temp>
|
|
59
|
+
git status --porcelain
|
|
60
|
+
# For each modified doc file listed, stage it explicitly:
|
|
61
|
+
git add <package>/CHANGELOG.md <package>/README.md <package>/docs/*.md # include all that were edited
|
|
62
|
+
git commit -m "docs(<scope>): <short>"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Equivalently, if you are certain only doc files are modified (you never touched source code), `git add -A` is acceptable — it's the hardcoded-single-path pattern that must be avoided.
|
|
66
|
+
|
|
67
|
+
- One commit per Documenter run, covering every doc file edited in that run. (If you need multiple scopes — e.g., both `standard/CHANGELOG.md` and `reference-impl/CHANGELOG.md` — make one commit per scope, but each commit still stages every edited file within that scope.)
|
|
56
68
|
- Commit message: `docs(<scope>): <short>` where `<scope>` is the package name (`standard`, `reference-impl`, `site`, or `repo` for root-level).
|
|
57
69
|
- All commits land on `{{branch}}` (the feature branch).
|
|
58
70
|
- After all commits land, run `git worktree remove --force <temp>` to clean up.
|
|
71
|
+
- **Self-check before returning**: `git status --porcelain` in the worktree must be empty. If it's not, you have uncommitted doc edits — stage and commit them, or revert them, before reporting back.
|
|
59
72
|
|
|
60
73
|
## Output
|
|
61
74
|
|
package/prompts/qa.md
CHANGED
|
@@ -17,10 +17,15 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
|
|
|
17
17
|
|
|
18
18
|
## Runtime procedure
|
|
19
19
|
|
|
20
|
-
1. Set up isolated worktree
|
|
20
|
+
1. Set up isolated worktree and prepare its node_modules + standard/dist. The `prep-worktree`
|
|
21
|
+
helper copies main's `standard/node_modules` and `reference-impl/node_modules` into the
|
|
22
|
+
worktree and runs the standard build script so the @cloverleaf/standard symlink resolves
|
|
23
|
+
correctly inside the worktree. (Without this, `tsc` fails with `Cannot find module
|
|
24
|
+
'@cloverleaf/standard/validators/index.js'` because git worktrees don't inherit node_modules.)
|
|
21
25
|
```bash
|
|
22
26
|
TMPDIR=$(mktemp -d)
|
|
23
27
|
git worktree add "$TMPDIR" {{branch}}
|
|
28
|
+
cloverleaf-cli prep-worktree {{repo_root}} "$TMPDIR"
|
|
24
29
|
```
|
|
25
30
|
|
|
26
31
|
2. Inspect the changed files (from the diff). For each QA rule whose `match` patterns match ≥1 changed file, queue its command.
|
package/prompts/reviewer.md
CHANGED
|
@@ -41,12 +41,14 @@ A `pass` verdict MAY have an empty `findings` array or omit it. A `bounce` verdi
|
|
|
41
41
|
- You are a fresh pair of eyes. Do not rubber-stamp. If you have substantive doubts, bounce.
|
|
42
42
|
- Check that tests actually cover the AC; a passing test suite with no AC coverage is a bounce.
|
|
43
43
|
- Do NOT modify any files. You are read-only.
|
|
44
|
-
- Do NOT use `git checkout` or `git switch`. Read files via `git show <branch>:<path>`. If you need a live checkout to run tests, use a worktree:
|
|
44
|
+
- Do NOT use `git checkout` or `git switch`. Read files via `git show <branch>:<path>`. If you need a live checkout to run tests, use a worktree and prime it with `cloverleaf-cli prep-worktree` (copies main's node_modules + builds standard/dist inside the worktree — without this, `tsc` fails with `Cannot find module '@cloverleaf/standard/validators/index.js'`):
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
+
MAIN=$(pwd)
|
|
47
48
|
git worktree add /tmp/cl-review-<task-id> cloverleaf/<task-id>
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
cloverleaf-cli prep-worktree "$MAIN" /tmp/cl-review-<task-id>
|
|
50
|
+
cd /tmp/cl-review-<task-id>/reference-impl
|
|
51
|
+
npm test
|
|
50
52
|
cd -
|
|
51
53
|
git worktree remove /tmp/cl-review-<task-id>
|
|
52
54
|
```
|
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 at multiple viewports for accessibility violations (axe-core) and visual regressions (pixelmatch) using
|
|
3
|
+
You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes at multiple viewports and browser engines for accessibility violations (axe-core) and visual regressions (pixelmatch) using headless Playwright browsers. 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,7 +11,7 @@ You are the Cloverleaf UI Reviewer. Your job: review a task's UI changes at mult
|
|
|
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
|
+
- **UI review config**: {{ui_review_config}} — the loaded `UiReviewConfig` object (browsers, viewports, visualDiff, axe, maxCombinations) as JSON. The `viewports` array contains named entries such as `mobile`, `tablet`, and `desktop` with their respective `{ width, height }` dimensions.
|
|
15
15
|
|
|
16
16
|
## Paths
|
|
17
17
|
|
|
@@ -24,22 +24,46 @@ You operate in two filesystem locations — keep them straight:
|
|
|
24
24
|
|
|
25
25
|
The rationale: baselines on `{{repo_root}}/.cloverleaf/baselines/` get picked up by subsequent `git add` + `git commit` steps in the UI Reviewer, which run on the feature branch. The merge skill (v0.4.1+) then merges those commits to main via `git merge --no-ff`. Writing to the worktree's `.cloverleaf/` would strand the files and `git worktree remove --force` would discard them on teardown.
|
|
26
26
|
|
|
27
|
-
## Scope (v0.
|
|
27
|
+
## Scope (v0.5)
|
|
28
28
|
|
|
29
|
-
- **
|
|
29
|
+
- **Browsers**: the reviewer runs separate Playwright sessions for each engine listed in `{{ui_review_config}}.browsers` (e.g., `["chromium", "webkit", "firefox"]`). Browser is the **outermost** loop, wrapping the viewport × route loops.
|
|
30
|
+
- **Accessibility (axe-core):** run only for the engine specified by `{{ui_review_config}}.axe.browser` (default: `"chromium"`). webkit and firefox browser passes produce **no axe output and no axe findings** — this is intentional, to avoid engine-specific false positives from getComputedStyle, aria-required-children, and scrollable-region-focusable divergence across Blink, WebKit, and Gecko (see CLV-12 spike).
|
|
30
31
|
Apply the allowlist in `{{ui_review_config}}.axe.ignored` to drop pre-existing violations that the consumer has accepted (e.g., a11y debt being tracked separately).
|
|
31
32
|
Dedupe findings across viewports by the `{{ui_review_config}}.axe.dedupeBy` composite key (default `["ruleId", "target"]`).
|
|
32
33
|
Emit one finding per (ruleId, target) pair, with a `metadata.viewports` array aggregating the viewports where the violation was detected.
|
|
33
|
-
- **Visual diff (pixelmatch):** when `{{ui_review_config}}.visualDiff.enabled` is true, screenshot each route at each viewport in `{{ui_review_config}}.viewports
|
|
34
|
+
- **Visual diff (pixelmatch):** when `{{ui_review_config}}.visualDiff.enabled` is true, screenshot each route at each viewport in `{{ui_review_config}}.viewports` for **each browser**, compare to `.cloverleaf/baselines/{browser}/{route-slug}-{viewport}.png`, emit `severity: "info"` findings with baseline/candidate/diff attachments when the diff ratio exceeds `maxDiffRatio`.
|
|
34
35
|
- Visual diffs are **informational**, never gating. A diff does not fail the review — it surfaces to the human final-gate reviewer.
|
|
35
36
|
- Route empty-set / "all" handling preserves v0.3 behavior:
|
|
36
37
|
- `{{affected_routes}}` is `[]` → `verdict: "pass"`, summary `"No renderable routes affected, skipping axe."`, do NOT start the preview server.
|
|
37
|
-
- `{{affected_routes}}` is `"all"` → crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback).
|
|
38
|
+
- `{{affected_routes}}` is `"all"` → crawl up to 20 pages reachable from `/` via same-origin link discovery (v0.2 fallback behavior).
|
|
38
39
|
- otherwise → visit exactly the URLs listed.
|
|
39
40
|
|
|
41
|
+
## maxCombinations cap
|
|
42
|
+
|
|
43
|
+
Before starting any browser session, compute total combinations = `routes × viewports × browsers`.
|
|
44
|
+
|
|
45
|
+
If the product exceeds `{{ui_review_config}}.maxCombinations` (default 90):
|
|
46
|
+
1. Sort affected routes by diff size (most-changed first — use the character count of each route's section in `{{diff}}` as a proxy for diff size).
|
|
47
|
+
2. Keep only the first `floor(maxCombinations / (viewportCount × browserCount))` routes.
|
|
48
|
+
3. For each skipped route emit one `severity: "warning"` finding with `rule: "ui-review-cap"` and message:
|
|
49
|
+
`"Route {route} skipped: combination count {total} exceeds maxCombinations ({cap}); review manually or raise the cap."`
|
|
50
|
+
Include `metadata: { route, combinationCount: total, maxCombinations: cap }`.
|
|
51
|
+
|
|
52
|
+
The cap enforcement helper is available in `lib/ui-browser.ts` as `applyMaxCombinationsCap`.
|
|
53
|
+
|
|
40
54
|
## Playwright cache
|
|
41
55
|
|
|
42
|
-
The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked.
|
|
56
|
+
The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playwright` before you are invoked. Before launching each browser session, verify that the required engine binary exists in `PLAYWRIGHT_BROWSERS_PATH`. If a browser binary is absent, return `verdict: "escalate"` with a synthetic finding per missing engine:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
"Playwright {engine} not installed. Run 'npx playwright install webkit firefox' on this machine."
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
On Linux, append: `" On Linux, also run: npx playwright install-deps webkit"`
|
|
63
|
+
|
|
64
|
+
The escalation helper is available in `lib/ui-browser.ts` as `buildBrowserEscalationFinding`.
|
|
65
|
+
|
|
66
|
+
Do not attempt to launch a missing engine — fail fast with `verdict: "escalate"` listing all missing engines before any browser session is started.
|
|
43
67
|
|
|
44
68
|
## Runtime procedure
|
|
45
69
|
|
|
@@ -49,6 +73,7 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
|
|
|
49
73
|
```bash
|
|
50
74
|
TMPDIR=$(mktemp -d)
|
|
51
75
|
git worktree add "$TMPDIR" {{branch}}
|
|
76
|
+
npx cloverleaf-cli prep-worktree {{repo_root}} "$TMPDIR"
|
|
52
77
|
```
|
|
53
78
|
|
|
54
79
|
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:
|
|
@@ -66,53 +91,72 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
|
|
|
66
91
|
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.
|
|
67
92
|
3. If both fail, treat base as empty string.
|
|
68
93
|
|
|
69
|
-
6. **
|
|
70
|
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
94
|
+
6. **Apply maxCombinations cap** (when `affected_routes` is a list, not `"all"`):
|
|
95
|
+
- Compute `routes × viewports × browsers`. Use diff line counts as proxy for route diff size.
|
|
96
|
+
- Call `applyMaxCombinationsCap` from `lib/ui-browser.ts`.
|
|
97
|
+
- The `skippedFindings` are collected now and included in the final output.
|
|
98
|
+
- Use only the returned `routes` list for the browser passes below.
|
|
99
|
+
|
|
100
|
+
7. **Verify browser binaries** — before starting any browser session:
|
|
101
|
+
- Check each engine in `{{ui_review_config}}.browsers` against `PLAYWRIGHT_BROWSERS_PATH`.
|
|
102
|
+
- Collect all missing engines.
|
|
103
|
+
- If any engine is missing, call `buildBrowserEscalationFinding(engine, process.platform)` for each, teardown the worktree (step 11), and return `verdict: "escalate"` with those findings.
|
|
104
|
+
|
|
105
|
+
8. **Per-browser outer loop** — for each `browser` in `{{ui_review_config}}.browsers`:
|
|
106
|
+
|
|
107
|
+
a. Launch a Playwright browser context using the `browser` engine.
|
|
108
|
+
|
|
109
|
+
b. **Visual-diff pass (when `visualDiff.enabled` is true):**
|
|
110
|
+
For each route in the (capped) route list × each viewport in `{{ui_review_config}}.viewports`:
|
|
111
|
+
- Set Playwright viewport to `{ width, height }` from the config.
|
|
112
|
+
- Apply mask CSS — inject a style that sets `visibility: hidden` on any selector in `visualDiff.mask`.
|
|
113
|
+
- Navigate to `http://localhost:{{preview_port}}<base><route>`. If 404, retry without the base.
|
|
114
|
+
- `page.screenshot({ fullPage: false })` → candidate PNG buffer.
|
|
115
|
+
- Compute slug for the route (lowercase, strip leading/trailing slashes, replace slashes with hyphens; `/` → `index`).
|
|
116
|
+
- Note: use `{{repo_root}}` (the absolute main-repo path), NOT `$TMPDIR` or the worktree. See the "Paths" section.
|
|
117
|
+
- Call `compareVisual` (from `lib/visual-diff.ts`) with:
|
|
118
|
+
- `baselinePath = {{repo_root}}/.cloverleaf/baselines/{browser}/{slug}-{viewport}.png`
|
|
119
|
+
- `candidateBuf = <candidate PNG>`
|
|
120
|
+
- `diffPath = {{repo_root}}/.cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png`
|
|
121
|
+
- `candidateOutPath = {{repo_root}}/.cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png`
|
|
122
|
+
- `threshold = visualDiff.threshold`
|
|
123
|
+
- `maxDiffRatio = visualDiff.maxDiffRatio`
|
|
124
|
+
- Map result to a finding:
|
|
125
|
+
- `new-baseline` → `severity: "info"`, `rule: "visual-diff"`, `message: "new baseline established for {route} @ {viewport} [{browser}]"`, `metadata: { route, viewport, browser, status: "new-baseline" }`. No attachments.
|
|
126
|
+
- `dimension-mismatch` → `severity: "info"`, `rule: "visual-diff"`, `message: "baseline dimensions changed for {route} @ {viewport} [{browser}]; regenerated"`, `metadata: { route, viewport, browser, status: "dimension-mismatch" }`.
|
|
127
|
+
- `diff` → `severity: "info"`, `rule: "visual-diff"`, `message: "visual diff: {route} @ {viewport} [{browser}] — {diffRatio*100}% pixels differ"`, `metadata: { route, viewport, browser, diffRatio, status: "diff" }`, `attachments: [baseline, candidate, diff]`.
|
|
128
|
+
- `match` → no finding emitted.
|
|
129
|
+
|
|
130
|
+
c. **Axe pass (only when `browser === {{ui_review_config}}.axe.browser`):**
|
|
131
|
+
Skip this section entirely if the current browser is NOT the configured `axe.browser`. webkit and firefox runs produce no axe output and no axe findings.
|
|
132
|
+
|
|
133
|
+
For each viewport in `{{ui_review_config}}.axe.viewports`:
|
|
134
|
+
- Set Playwright viewport to `{ width, height }`.
|
|
135
|
+
- For each route in the (capped) route list:
|
|
136
|
+
- Navigate.
|
|
137
|
+
- Inject and run axe-core:
|
|
138
|
+
```javascript
|
|
139
|
+
import axe from 'axe-core';
|
|
140
|
+
const results = await axe.run(document);
|
|
141
|
+
```
|
|
142
|
+
- Collect each violation as a raw tuple: `{ viewport, ruleId, target, impact, message, helpUrl }` (from `axe.run` output).
|
|
143
|
+
|
|
144
|
+
d. Close the browser context before launching the next engine.
|
|
145
|
+
|
|
146
|
+
9. Dedupe raw axe findings via `dedupeAxeFindings(raws, {{ui_review_config}}.axe.dedupeBy, {{ui_review_config}}.axe.ignored)` (from `lib/axe-dedupe.ts`). The `ignored` parameter drops any finding whose `(ruleId, target)` exactly matches an allowlist entry BEFORE dedupe/grouping. Emit the returned `Finding[]`.
|
|
147
|
+
|
|
148
|
+
10. Severity mapping (preserved from v0.3 via `dedupeAxeFindings`):
|
|
149
|
+
- axe `impact: "critical"` → `severity: "blocker"`
|
|
150
|
+
- axe `impact: "serious"` → `severity: "error"`
|
|
151
|
+
- axe `impact: "moderate"` → `severity: "warning"`
|
|
152
|
+
- axe `impact: "minor"` → `severity: "info"`
|
|
153
|
+
|
|
154
|
+
11. Compute verdict (visual-diff and ui-review-cap findings are **never** considered for gating):
|
|
155
|
+
- `pass` — zero non-visual-diff, non-cap findings with severity `blocker` or `error`
|
|
156
|
+
- `bounce` — ≥1 non-visual-diff, non-cap finding with severity `blocker` or `error`
|
|
157
|
+
- `escalate` — preview server failed to start, OR axe threw ≥3 consecutive times, OR any required browser binary was absent.
|
|
158
|
+
|
|
159
|
+
12. Teardown:
|
|
116
160
|
```bash
|
|
117
161
|
kill $SERVER_PID 2>/dev/null || true
|
|
118
162
|
cd {{repo_root}}
|
|
@@ -132,7 +176,7 @@ Respond with exactly one JSON object and nothing else. Finding shape must match
|
|
|
132
176
|
- required: `severity`, `message`
|
|
133
177
|
- optional: `rule`, `suggestion`, `location`, `attachments`, `metadata`
|
|
134
178
|
|
|
135
|
-
For a11y findings there is usually no meaningful file/line, so OMIT `location` entirely.
|
|
179
|
+
For a11y findings there is usually no meaningful file/line, so OMIT `location` entirely. For `location`, use an object shape when present — do not emit `location` as a URL string.
|
|
136
180
|
|
|
137
181
|
```json
|
|
138
182
|
{
|
|
@@ -141,11 +185,11 @@ For a11y findings there is usually no meaningful file/line, so OMIT `location` e
|
|
|
141
185
|
"findings": [
|
|
142
186
|
{
|
|
143
187
|
"severity": "blocker" | "error" | "warning" | "info",
|
|
144
|
-
"rule": "a11y.<rule-id>" | "visual-diff",
|
|
145
|
-
"message": "<description; include the page URL for a11y, route+viewport+
|
|
146
|
-
"metadata": { /* per §
|
|
188
|
+
"rule": "a11y.<rule-id>" | "visual-diff" | "ui-review-cap" | "browser-missing",
|
|
189
|
+
"message": "<description; include the page URL for a11y, route+viewport+browser for visual-diff>",
|
|
190
|
+
"metadata": { /* per §8/§9 above */ },
|
|
147
191
|
"attachments": [ /* for visual-diff with status="diff" */
|
|
148
|
-
{ "label": "baseline", "path": ".cloverleaf/baselines/{slug}-{viewport}.png" },
|
|
192
|
+
{ "label": "baseline", "path": ".cloverleaf/baselines/{browser}/{slug}-{viewport}.png" },
|
|
149
193
|
{ "label": "candidate", "path": ".cloverleaf/runs/{taskId}/ui-review/candidate-{slug}-{viewport}.png" },
|
|
150
194
|
{ "label": "diff", "path": ".cloverleaf/runs/{taskId}/ui-review/diff-{slug}-{viewport}.png" }
|
|
151
195
|
]
|
|
@@ -154,4 +198,4 @@ For a11y findings there is usually no meaningful file/line, so OMIT `location` e
|
|
|
154
198
|
}
|
|
155
199
|
```
|
|
156
200
|
|
|
157
|
-
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.
|
|
201
|
+
If verdict is `pass`, `findings` may be empty or include only `warning`/`info`-level findings (including `ui-review-cap` warnings and visual-diff info). If verdict is `escalate`, include a finding explaining what went wrong.
|
|
@@ -71,9 +71,9 @@ description: Human gate for merging a Cloverleaf task. Branches on state — fro
|
|
|
71
71
|
cloverleaf-cli advance-status <repo_root> ${TASK_ID} escalated agent
|
|
72
72
|
```
|
|
73
73
|
Exit with a human-readable error explaining the conflict.
|
|
74
|
-
- Advance task status on main (commits `.cloverleaf/tasks/${TASK_ID}.json` + event):
|
|
74
|
+
- Advance task status on main (commits `.cloverleaf/tasks/${TASK_ID}.json` + event). The `final-gate → merged` transition is `allowed_actors: [human]` per the task state machine; the skill passes the gate + path as positional args:
|
|
75
75
|
```bash
|
|
76
|
-
cloverleaf-cli advance-status <repo_root> ${TASK_ID} merged
|
|
76
|
+
cloverleaf-cli advance-status <repo_root> ${TASK_ID} merged human final_approval_gate full_pipeline
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
### 4. Common: report
|