@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
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloverleaf",
|
|
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.4.0",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Renato D'Arrigo",
|
|
7
|
+
"email": "renato.darrigo@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/cloverleaf-org/cloverleaf",
|
|
10
|
+
"repository": "https://github.com/cloverleaf-org/cloverleaf",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"cloverleaf",
|
|
14
|
+
"methodology",
|
|
15
|
+
"ai-first",
|
|
16
|
+
"reference-implementation"
|
|
17
|
+
]
|
|
18
|
+
}
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.0
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"pageRoots": ["
|
|
2
|
+
"pageRoots": ["src/pages/"],
|
|
3
3
|
"globalPatterns": [
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"site/astro.config.*",
|
|
9
|
-
"site/src/content/**"
|
|
4
|
+
"src/layouts/**",
|
|
5
|
+
"src/components/**",
|
|
6
|
+
"src/styles/**",
|
|
7
|
+
"public/**"
|
|
10
8
|
],
|
|
11
|
-
"routeScope": ["
|
|
9
|
+
"routeScope": ["src/**", "public/**"],
|
|
12
10
|
"contentRoutes": {}
|
|
13
11
|
}
|
package/config/qa-rules.json
CHANGED
|
@@ -1,19 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"rules": [
|
|
3
3
|
{
|
|
4
|
-
"cwd": "
|
|
5
|
-
"match": ["
|
|
6
|
-
"command": "npm
|
|
7
|
-
},
|
|
8
|
-
{
|
|
9
|
-
"cwd": "reference-impl",
|
|
10
|
-
"match": ["reference-impl/**"],
|
|
11
|
-
"command": "npm ci && npm test"
|
|
12
|
-
},
|
|
13
|
-
{
|
|
14
|
-
"cwd": "site",
|
|
15
|
-
"match": ["site/**"],
|
|
16
|
-
"command": "npm ci && npm run build"
|
|
4
|
+
"cwd": ".",
|
|
5
|
+
"match": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"],
|
|
6
|
+
"command": "npm test"
|
|
17
7
|
}
|
|
18
8
|
]
|
|
19
9
|
}
|
package/config/ui-paths.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"viewports": {
|
|
3
|
+
"mobile": { "width": 375, "height": 667 },
|
|
4
|
+
"tablet": { "width": 768, "height": 1024 },
|
|
5
|
+
"desktop": { "width": 1280, "height": 800 }
|
|
6
|
+
},
|
|
7
|
+
"visualDiff": {
|
|
8
|
+
"enabled": true,
|
|
9
|
+
"threshold": 0.1,
|
|
10
|
+
"maxDiffRatio": 0.01,
|
|
11
|
+
"mask": []
|
|
12
|
+
},
|
|
13
|
+
"axe": {
|
|
14
|
+
"viewports": ["desktop"],
|
|
15
|
+
"dedupeBy": ["ruleId", "target"]
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/affected-routes.mjs
CHANGED
|
@@ -25,7 +25,7 @@ function routeForPage(file, pageRoot) {
|
|
|
25
25
|
return '/';
|
|
26
26
|
return `/${withoutExt}/`;
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
function loadDefaultConfig() {
|
|
29
29
|
if (!existsSync(DEFAULT_CONFIG)) {
|
|
30
30
|
throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
|
|
31
31
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const SEVERITY_MAP = {
|
|
2
|
+
critical: 'blocker',
|
|
3
|
+
serious: 'error',
|
|
4
|
+
moderate: 'warning',
|
|
5
|
+
minor: 'info',
|
|
6
|
+
};
|
|
7
|
+
function dedupeKeyOf(raw, keys) {
|
|
8
|
+
return keys.map((k) => raw[k]).join('||');
|
|
9
|
+
}
|
|
10
|
+
export function dedupeAxeFindings(raws, keys) {
|
|
11
|
+
const groups = new Map();
|
|
12
|
+
for (const raw of raws) {
|
|
13
|
+
const key = dedupeKeyOf(raw, keys);
|
|
14
|
+
const existing = groups.get(key);
|
|
15
|
+
if (existing) {
|
|
16
|
+
if (!existing.viewports.includes(raw.viewport))
|
|
17
|
+
existing.viewports.push(raw.viewport);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
groups.set(key, { first: raw, viewports: [raw.viewport] });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const { first, viewports } of groups.values()) {
|
|
25
|
+
out.push({
|
|
26
|
+
severity: SEVERITY_MAP[first.impact],
|
|
27
|
+
message: first.message,
|
|
28
|
+
rule: first.ruleId,
|
|
29
|
+
metadata: {
|
|
30
|
+
target: first.target,
|
|
31
|
+
impact: first.impact,
|
|
32
|
+
viewports,
|
|
33
|
+
...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* write-feedback <repoRoot> <taskId> <envelopeJsonPath>
|
|
13
13
|
* latest-feedback <repoRoot> <taskId>
|
|
14
14
|
* emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
|
|
15
|
+
* ui-review-config --repo-root <repoRoot>
|
|
15
16
|
*/
|
|
16
17
|
import { readFileSync } from 'node:fs';
|
|
17
18
|
import { execSync } from 'node:child_process';
|
|
@@ -24,6 +25,7 @@ import { matchesUiPaths } from './ui-paths.mjs';
|
|
|
24
25
|
import { loadUiPathsConfig } from './ui-paths.mjs';
|
|
25
26
|
import { computeAffectedRoutes } from './affected-routes.mjs';
|
|
26
27
|
import { loadAffectedRoutesConfig } from './affected-routes.mjs';
|
|
28
|
+
import { loadUiReviewConfig } from './ui-review-config.mjs';
|
|
27
29
|
function die(msg, code = 1) {
|
|
28
30
|
process.stderr.write(msg + '\n');
|
|
29
31
|
process.exit(code);
|
|
@@ -39,7 +41,8 @@ function usage(msg) {
|
|
|
39
41
|
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
|
|
40
42
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
41
43
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
42
|
-
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n'
|
|
44
|
+
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
45
|
+
' ui-review-config --repo-root <repoRoot>\n');
|
|
43
46
|
process.exit(2);
|
|
44
47
|
}
|
|
45
48
|
const [, , command, ...rest] = process.argv;
|
|
@@ -211,6 +214,27 @@ try {
|
|
|
211
214
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
212
215
|
process.exit(0);
|
|
213
216
|
}
|
|
217
|
+
case 'ui-review-config': {
|
|
218
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
219
|
+
const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
|
|
220
|
+
let repoRoot;
|
|
221
|
+
if (repoRootFlag === '--repo-root') {
|
|
222
|
+
repoRoot = rest[rest.indexOf('--repo-root') + 1];
|
|
223
|
+
}
|
|
224
|
+
else if (repoRootFlag) {
|
|
225
|
+
repoRoot = repoRootFlag.replace('--repo-root=', '');
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
|
|
229
|
+
}
|
|
230
|
+
if (!repoRoot) {
|
|
231
|
+
console.error('usage: ui-review-config --repo-root <repoRoot>');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const config = loadUiReviewConfig(repoRoot);
|
|
235
|
+
process.stdout.write(JSON.stringify(config, null, 2));
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
214
238
|
default:
|
|
215
239
|
usage(`Unknown command: ${command}`);
|
|
216
240
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
function escape(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
function renderRow(r) {
|
|
10
|
+
const status = r.passed ? 'PASS' : 'FAIL';
|
|
11
|
+
const statusClass = r.passed ? 'pass' : 'fail';
|
|
12
|
+
return `
|
|
13
|
+
<tr class="${statusClass}">
|
|
14
|
+
<td>${escape(r.ruleId)}</td>
|
|
15
|
+
<td><code>${escape(r.command)}</code></td>
|
|
16
|
+
<td>${escape(r.cwd)}</td>
|
|
17
|
+
<td>${r.durationMs}ms</td>
|
|
18
|
+
<td class="status">${status}</td>
|
|
19
|
+
</tr>
|
|
20
|
+
<tr class="detail ${statusClass}">
|
|
21
|
+
<td colspan="5">
|
|
22
|
+
${r.stdoutTail ? `<details><summary>stdout (tail)</summary><pre>${escape(r.stdoutTail)}</pre></details>` : ''}
|
|
23
|
+
${r.stderrTail ? `<details open><summary>stderr (tail)</summary><pre>${escape(r.stderrTail)}</pre></details>` : ''}
|
|
24
|
+
</td>
|
|
25
|
+
</tr>
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
export function renderQaReport(runs) {
|
|
29
|
+
const empty = runs.length === 0
|
|
30
|
+
? `<p class="empty">No runs / results.</p>`
|
|
31
|
+
: '';
|
|
32
|
+
const rows = runs.map(renderRow).join('');
|
|
33
|
+
return `<!DOCTYPE html>
|
|
34
|
+
<html lang="en">
|
|
35
|
+
<head>
|
|
36
|
+
<meta charset="utf-8">
|
|
37
|
+
<title>Cloverleaf QA Report</title>
|
|
38
|
+
<style>
|
|
39
|
+
body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #111; }
|
|
40
|
+
table { width: 100%; border-collapse: collapse; }
|
|
41
|
+
th, td { padding: 0.5rem; border-bottom: 1px solid #ddd; text-align: left; vertical-align: top; }
|
|
42
|
+
.status { font-weight: 600; }
|
|
43
|
+
.pass .status { color: #0a7; }
|
|
44
|
+
.fail .status { color: #c33; }
|
|
45
|
+
tr.detail td { background: #fafafa; padding-top: 0; }
|
|
46
|
+
pre { overflow: auto; background: #f4f4f4; padding: 0.5rem; }
|
|
47
|
+
.empty { color: #888; }
|
|
48
|
+
</style>
|
|
49
|
+
</head>
|
|
50
|
+
<body>
|
|
51
|
+
<h1>Cloverleaf QA Report</h1>
|
|
52
|
+
${empty}
|
|
53
|
+
${runs.length > 0 ? `
|
|
54
|
+
<table>
|
|
55
|
+
<thead>
|
|
56
|
+
<tr><th>Rule</th><th>Command</th><th>CWD</th><th>Duration</th><th>Status</th></tr>
|
|
57
|
+
</thead>
|
|
58
|
+
<tbody>
|
|
59
|
+
${rows}
|
|
60
|
+
</tbody>
|
|
61
|
+
</table>
|
|
62
|
+
` : ''}
|
|
63
|
+
</body>
|
|
64
|
+
</html>`;
|
|
65
|
+
}
|
package/dist/qa-rules.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { dirname, join } from 'node:path';
|
|
|
4
4
|
import { matchesUiPaths } from './ui-paths.mjs';
|
|
5
5
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const DEFAULT_CONFIG = join(here, '..', 'config', 'qa-rules.json');
|
|
7
|
-
|
|
7
|
+
function loadDefaultRules() {
|
|
8
8
|
if (!existsSync(DEFAULT_CONFIG))
|
|
9
9
|
return [];
|
|
10
10
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
|
|
@@ -0,0 +1,23 @@
|
|
|
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) {
|
|
10
|
+
const pathOnly = route.split(/[?#]/)[0];
|
|
11
|
+
if (pathOnly === '/' || pathOnly === '')
|
|
12
|
+
return 'index';
|
|
13
|
+
const trimmed = pathOnly.replace(/^\/+|\/+$/g, '');
|
|
14
|
+
if (trimmed === '')
|
|
15
|
+
return 'index';
|
|
16
|
+
const slugged = trimmed
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace(/[^a-z0-9/-]+/g, '-')
|
|
19
|
+
.replace(/\/+/g, '-')
|
|
20
|
+
.replace(/-+/g, '-')
|
|
21
|
+
.replace(/^-|-$/g, '');
|
|
22
|
+
return slugged || 'index';
|
|
23
|
+
}
|
package/dist/ui-paths.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
5
|
const DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
|
|
6
|
-
|
|
6
|
+
function loadDefaultPatterns() {
|
|
7
7
|
if (!existsSync(DEFAULT_CONFIG))
|
|
8
8
|
return ['site/**'];
|
|
9
9
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8'));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
5
|
+
const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
|
|
6
|
+
const HARDCODED_FALLBACK = {
|
|
7
|
+
viewports: {
|
|
8
|
+
mobile: { width: 375, height: 667 },
|
|
9
|
+
tablet: { width: 768, height: 1024 },
|
|
10
|
+
desktop: { width: 1280, height: 800 },
|
|
11
|
+
},
|
|
12
|
+
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
13
|
+
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
|
|
14
|
+
};
|
|
15
|
+
function readAsConfig(path) {
|
|
16
|
+
try {
|
|
17
|
+
const doc = JSON.parse(readFileSync(path, 'utf-8'));
|
|
18
|
+
return doc;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function loadUiReviewConfig(repoRoot) {
|
|
25
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
|
|
26
|
+
if (existsSync(consumerPath)) {
|
|
27
|
+
const parsed = readAsConfig(consumerPath);
|
|
28
|
+
if (parsed)
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
if (existsSync(PACKAGE_DEFAULT)) {
|
|
32
|
+
const parsed = readAsConfig(PACKAGE_DEFAULT);
|
|
33
|
+
if (parsed)
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
return HARDCODED_FALLBACK;
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
function ensureDir(path) {
|
|
6
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
function writeBaseline(baselinePath, buf) {
|
|
9
|
+
ensureDir(baselinePath);
|
|
10
|
+
writeFileSync(baselinePath, buf);
|
|
11
|
+
}
|
|
12
|
+
export function compareVisual(args) {
|
|
13
|
+
const candidatePng = PNG.sync.read(args.candidateBuf);
|
|
14
|
+
if (!existsSync(args.baselinePath)) {
|
|
15
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
16
|
+
return {
|
|
17
|
+
status: 'new-baseline',
|
|
18
|
+
diffPixels: 0,
|
|
19
|
+
diffRatio: 0,
|
|
20
|
+
width: candidatePng.width,
|
|
21
|
+
height: candidatePng.height,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const baselineBuf = readFileSync(args.baselinePath);
|
|
25
|
+
const baselinePng = PNG.sync.read(baselineBuf);
|
|
26
|
+
if (baselinePng.width !== candidatePng.width || baselinePng.height !== candidatePng.height) {
|
|
27
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
28
|
+
return {
|
|
29
|
+
status: 'dimension-mismatch',
|
|
30
|
+
diffPixels: 0,
|
|
31
|
+
diffRatio: 0,
|
|
32
|
+
width: candidatePng.width,
|
|
33
|
+
height: candidatePng.height,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const diffPng = new PNG({ width: candidatePng.width, height: candidatePng.height });
|
|
37
|
+
const diffPixels = pixelmatch(baselinePng.data, candidatePng.data, diffPng.data, candidatePng.width, candidatePng.height, { threshold: args.threshold });
|
|
38
|
+
const totalPixels = candidatePng.width * candidatePng.height;
|
|
39
|
+
const diffRatio = diffPixels / totalPixels;
|
|
40
|
+
if (diffRatio > args.maxDiffRatio) {
|
|
41
|
+
ensureDir(args.diffPath);
|
|
42
|
+
writeFileSync(args.diffPath, PNG.sync.write(diffPng));
|
|
43
|
+
ensureDir(args.candidateOutPath);
|
|
44
|
+
writeFileSync(args.candidateOutPath, args.candidateBuf);
|
|
45
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
46
|
+
return {
|
|
47
|
+
status: 'diff',
|
|
48
|
+
diffPixels,
|
|
49
|
+
diffRatio,
|
|
50
|
+
width: candidatePng.width,
|
|
51
|
+
height: candidatePng.height,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
55
|
+
return {
|
|
56
|
+
status: 'match',
|
|
57
|
+
diffPixels,
|
|
58
|
+
diffRatio,
|
|
59
|
+
width: candidatePng.width,
|
|
60
|
+
height: candidatePng.height,
|
|
61
|
+
};
|
|
62
|
+
}
|
package/install.sh
CHANGED
|
@@ -2,59 +2,45 @@
|
|
|
2
2
|
set -euo pipefail
|
|
3
3
|
|
|
4
4
|
# Cloverleaf Reference Impl installer.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
#
|
|
6
|
+
# As of v0.4.0, cloverleaf installs as a proper Claude Code plugin via the
|
|
7
|
+
# `claude plugin` CLI. Point Claude Code at the cloverleaf repo root (where
|
|
8
|
+
# .claude-plugin/marketplace.json lives), then install the plugin from the
|
|
9
|
+
# resulting marketplace.
|
|
7
10
|
|
|
8
11
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
9
|
-
|
|
12
|
+
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
echo "Usage: ./install.sh [--project]"
|
|
16
|
-
echo " --project: install locally in .claude/plugins/cloverleaf/"
|
|
17
|
-
echo " (default): install at ~/.claude/plugins/cloverleaf/"
|
|
18
|
-
exit 0 ;;
|
|
19
|
-
*) echo "Unknown arg: $1"; exit 2 ;;
|
|
20
|
-
esac
|
|
21
|
-
done
|
|
22
|
-
|
|
23
|
-
if [[ "$MODE" == "user" ]]; then
|
|
24
|
-
INSTALL_ROOT="${HOME}/.claude/plugins/cloverleaf"
|
|
25
|
-
else
|
|
26
|
-
INSTALL_ROOT="$(pwd)/.claude/plugins/cloverleaf"
|
|
14
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
15
|
+
echo "error: 'claude' CLI not found on PATH."
|
|
16
|
+
echo "Install Claude Code first: https://docs.claude.com/claude-code"
|
|
17
|
+
exit 1
|
|
27
18
|
fi
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# Symlink skills
|
|
35
|
-
for f in "${SCRIPT_DIR}/skills/"*.md; do
|
|
36
|
-
name="$(basename "$f")"
|
|
37
|
-
ln -sf "$f" "${INSTALL_ROOT}/skills/${name}"
|
|
38
|
-
done
|
|
20
|
+
if [ ! -f "${REPO_ROOT}/.claude-plugin/marketplace.json" ]; then
|
|
21
|
+
echo "error: ${REPO_ROOT}/.claude-plugin/marketplace.json not found."
|
|
22
|
+
echo "This script expects to be run from inside a cloverleaf checkout."
|
|
23
|
+
exit 1
|
|
24
|
+
fi
|
|
39
25
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
name="$(basename "$f")"
|
|
43
|
-
ln -sf "$f" "${INSTALL_ROOT}/prompts/${name}"
|
|
44
|
-
done
|
|
26
|
+
echo "Registering cloverleaf marketplace from ${REPO_ROOT}..."
|
|
27
|
+
claude plugin marketplace add "${REPO_ROOT}"
|
|
45
28
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#!/usr/bin/env bash
|
|
49
|
-
exec npx --yes tsx "${SCRIPT_DIR}/lib/cli.ts" "\$@"
|
|
50
|
-
EOF
|
|
51
|
-
chmod +x "${INSTALL_ROOT}/bin/cloverleaf-cli"
|
|
29
|
+
echo "Installing cloverleaf plugin..."
|
|
30
|
+
claude plugin install cloverleaf@cloverleaf-local
|
|
52
31
|
|
|
53
|
-
echo "Cloverleaf reference impl installed at: ${INSTALL_ROOT}"
|
|
54
|
-
echo "Skills available: $(ls "${INSTALL_ROOT}/skills" | wc -l | tr -d ' ')"
|
|
55
32
|
echo ""
|
|
56
|
-
echo "
|
|
57
|
-
echo "
|
|
33
|
+
echo "Cloverleaf installed. Slash commands:"
|
|
34
|
+
echo " /cloverleaf-new-task — scaffold a Task from a brief"
|
|
35
|
+
echo " /cloverleaf-run — full pipeline orchestrator"
|
|
36
|
+
echo " /cloverleaf-implement — run Implementer"
|
|
37
|
+
echo " /cloverleaf-document — run Documenter"
|
|
38
|
+
echo " /cloverleaf-review — run Reviewer"
|
|
39
|
+
echo " /cloverleaf-ui-review — run UI Reviewer (visual diff + multi-viewport + axe)"
|
|
40
|
+
echo " /cloverleaf-qa — run QA"
|
|
41
|
+
echo " /cloverleaf-merge — merge gate"
|
|
42
|
+
echo ""
|
|
43
|
+
echo "Restart any open Claude Code sessions to pick up the new skills."
|
|
58
44
|
|
|
59
45
|
# Post-install: warn about Playwright chromium if not cached
|
|
60
46
|
if [ ! -d "${HOME}/.cache/ms-playwright" ] || [ -z "$(ls -A "${HOME}/.cache/ms-playwright" 2>/dev/null)" ]; then
|
package/lib/affected-routes.ts
CHANGED
|
@@ -34,7 +34,7 @@ function routeForPage(file: string, pageRoot: string): string | null {
|
|
|
34
34
|
return `/${withoutExt}/`;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
function loadDefaultConfig(): AffectedRoutesConfig {
|
|
38
38
|
if (!existsSync(DEFAULT_CONFIG)) {
|
|
39
39
|
throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
|
|
40
40
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Finding, FindingSeverity } from './feedback.js';
|
|
2
|
+
|
|
3
|
+
export type AxeImpact = 'critical' | 'serious' | 'moderate' | 'minor';
|
|
4
|
+
|
|
5
|
+
export interface RawAxeFinding {
|
|
6
|
+
viewport: string;
|
|
7
|
+
ruleId: string;
|
|
8
|
+
target: string;
|
|
9
|
+
impact: AxeImpact;
|
|
10
|
+
message: string;
|
|
11
|
+
helpUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type DedupeKey = 'ruleId' | 'target';
|
|
15
|
+
|
|
16
|
+
const SEVERITY_MAP: Record<AxeImpact, FindingSeverity> = {
|
|
17
|
+
critical: 'blocker',
|
|
18
|
+
serious: 'error',
|
|
19
|
+
moderate: 'warning',
|
|
20
|
+
minor: 'info',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function dedupeKeyOf(raw: RawAxeFinding, keys: DedupeKey[]): string {
|
|
24
|
+
return keys.map((k) => raw[k]).join('||');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function dedupeAxeFindings(raws: RawAxeFinding[], keys: DedupeKey[]): Finding[] {
|
|
28
|
+
const groups = new Map<string, { first: RawAxeFinding; viewports: string[] }>();
|
|
29
|
+
for (const raw of raws) {
|
|
30
|
+
const key = dedupeKeyOf(raw, keys);
|
|
31
|
+
const existing = groups.get(key);
|
|
32
|
+
if (existing) {
|
|
33
|
+
if (!existing.viewports.includes(raw.viewport)) existing.viewports.push(raw.viewport);
|
|
34
|
+
} else {
|
|
35
|
+
groups.set(key, { first: raw, viewports: [raw.viewport] });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const out: Finding[] = [];
|
|
39
|
+
for (const { first, viewports } of groups.values()) {
|
|
40
|
+
out.push({
|
|
41
|
+
severity: SEVERITY_MAP[first.impact],
|
|
42
|
+
message: first.message,
|
|
43
|
+
rule: first.ruleId,
|
|
44
|
+
metadata: {
|
|
45
|
+
target: first.target,
|
|
46
|
+
impact: first.impact,
|
|
47
|
+
viewports,
|
|
48
|
+
...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
package/lib/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* write-feedback <repoRoot> <taskId> <envelopeJsonPath>
|
|
13
13
|
* latest-feedback <repoRoot> <taskId>
|
|
14
14
|
* emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]
|
|
15
|
+
* ui-review-config --repo-root <repoRoot>
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import { readFileSync } from 'node:fs';
|
|
@@ -25,6 +26,7 @@ import { matchesUiPaths } from './ui-paths.js';
|
|
|
25
26
|
import { loadUiPathsConfig } from './ui-paths.js';
|
|
26
27
|
import { computeAffectedRoutes } from './affected-routes.js';
|
|
27
28
|
import { loadAffectedRoutesConfig } from './affected-routes.js';
|
|
29
|
+
import { loadUiReviewConfig } from './ui-review-config.js';
|
|
28
30
|
import type { FeedbackEnvelope } from './feedback.js';
|
|
29
31
|
|
|
30
32
|
function die(msg: string, code = 1): never {
|
|
@@ -43,7 +45,8 @@ function usage(msg?: string): never {
|
|
|
43
45
|
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
|
|
44
46
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
45
47
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
46
|
-
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n'
|
|
48
|
+
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
49
|
+
' ui-review-config --repo-root <repoRoot>\n'
|
|
47
50
|
);
|
|
48
51
|
process.exit(2);
|
|
49
52
|
}
|
|
@@ -221,6 +224,26 @@ try {
|
|
|
221
224
|
process.exit(0);
|
|
222
225
|
}
|
|
223
226
|
|
|
227
|
+
case 'ui-review-config': {
|
|
228
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
229
|
+
const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
|
|
230
|
+
let repoRoot: string | undefined;
|
|
231
|
+
if (repoRootFlag === '--repo-root') {
|
|
232
|
+
repoRoot = rest[rest.indexOf('--repo-root') + 1];
|
|
233
|
+
} else if (repoRootFlag) {
|
|
234
|
+
repoRoot = repoRootFlag.replace('--repo-root=', '');
|
|
235
|
+
} else {
|
|
236
|
+
repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
|
|
237
|
+
}
|
|
238
|
+
if (!repoRoot) {
|
|
239
|
+
console.error('usage: ui-review-config --repo-root <repoRoot>');
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
const config = loadUiReviewConfig(repoRoot);
|
|
243
|
+
process.stdout.write(JSON.stringify(config, null, 2));
|
|
244
|
+
process.exit(0);
|
|
245
|
+
}
|
|
246
|
+
|
|
224
247
|
default:
|
|
225
248
|
usage(`Unknown command: ${command}`);
|
|
226
249
|
}
|
package/lib/feedback.ts
CHANGED
|
@@ -13,12 +13,19 @@ export interface FindingLocation {
|
|
|
13
13
|
work_item_id?: { project: string; id: string };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export interface Attachment {
|
|
17
|
+
label: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
export interface Finding {
|
|
17
22
|
severity: FindingSeverity;
|
|
18
23
|
message: string;
|
|
19
24
|
location?: FindingLocation;
|
|
20
25
|
suggestion?: string;
|
|
21
26
|
rule?: string;
|
|
27
|
+
attachments?: Attachment[];
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
export interface FeedbackEnvelope {
|