@cloverleaf/reference-impl 0.3.1 → 0.4.1
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 +18 -0
- package/dist/affected-routes.mjs +1 -1
- package/dist/axe-dedupe.mjs +42 -0
- package/dist/cli.mjs +32 -1
- package/dist/feedback.mjs +1 -1
- package/dist/plugin-path.mjs +19 -0
- 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 +41 -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 +64 -0
- package/lib/cli.ts +32 -1
- package/lib/feedback.ts +8 -1
- package/lib/plugin-path.ts +21 -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 +62 -0
- package/lib/visual-diff.ts +97 -0
- package/package.json +8 -3
- package/prompts/qa.md +21 -0
- package/prompts/ui-reviewer.md +90 -39
- package/skills/{cloverleaf-document.md → cloverleaf-document/SKILL.md} +2 -2
- package/skills/{cloverleaf-implement.md → cloverleaf-implement/SKILL.md} +3 -3
- package/skills/{cloverleaf-merge.md → cloverleaf-merge/SKILL.md} +26 -5
- package/skills/{cloverleaf-new-task.md → cloverleaf-new-task/SKILL.md} +20 -3
- package/skills/{cloverleaf-qa.md → cloverleaf-qa/SKILL.md} +26 -11
- package/skills/{cloverleaf-review.md → cloverleaf-review/SKILL.md} +18 -8
- package/skills/{cloverleaf-ui-review.md → cloverleaf-ui-review/SKILL.md} +37 -20
- /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.1",
|
|
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.1
|
|
@@ -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,18 @@
|
|
|
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
|
+
"ignored": []
|
|
17
|
+
}
|
|
18
|
+
}
|
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,42 @@
|
|
|
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, ignored = []) {
|
|
11
|
+
// Filter out ignored (ruleId, target) tuples BEFORE grouping.
|
|
12
|
+
const filtered = raws.filter((raw) => {
|
|
13
|
+
return !ignored.some((i) => i.ruleId === raw.ruleId && i.target === raw.target);
|
|
14
|
+
});
|
|
15
|
+
const groups = new Map();
|
|
16
|
+
for (const raw of filtered) {
|
|
17
|
+
const key = dedupeKeyOf(raw, keys);
|
|
18
|
+
const existing = groups.get(key);
|
|
19
|
+
if (existing) {
|
|
20
|
+
if (!existing.viewports.includes(raw.viewport))
|
|
21
|
+
existing.viewports.push(raw.viewport);
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
groups.set(key, { first: raw, viewports: [raw.viewport] });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const { first, viewports } of groups.values()) {
|
|
29
|
+
out.push({
|
|
30
|
+
severity: SEVERITY_MAP[first.impact],
|
|
31
|
+
message: first.message,
|
|
32
|
+
rule: first.ruleId,
|
|
33
|
+
metadata: {
|
|
34
|
+
target: first.target,
|
|
35
|
+
impact: first.impact,
|
|
36
|
+
viewports,
|
|
37
|
+
...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
package/dist/cli.mjs
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
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>
|
|
16
|
+
* plugin-root
|
|
15
17
|
*/
|
|
16
18
|
import { readFileSync } from 'node:fs';
|
|
17
19
|
import { execSync } from 'node:child_process';
|
|
@@ -24,6 +26,8 @@ import { matchesUiPaths } from './ui-paths.mjs';
|
|
|
24
26
|
import { loadUiPathsConfig } from './ui-paths.mjs';
|
|
25
27
|
import { computeAffectedRoutes } from './affected-routes.mjs';
|
|
26
28
|
import { loadAffectedRoutesConfig } from './affected-routes.mjs';
|
|
29
|
+
import { loadUiReviewConfig } from './ui-review-config.mjs';
|
|
30
|
+
import { getPluginRoot } from './plugin-path.mjs';
|
|
27
31
|
function die(msg, code = 1) {
|
|
28
32
|
process.stderr.write(msg + '\n');
|
|
29
33
|
process.exit(code);
|
|
@@ -39,7 +43,9 @@ function usage(msg) {
|
|
|
39
43
|
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
|
|
40
44
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
41
45
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
42
|
-
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n'
|
|
46
|
+
' emit-gate-decision <repoRoot> <workItemId> <gate> <decision> <actor> [--comment=<str>]\n' +
|
|
47
|
+
' ui-review-config --repo-root <repoRoot>\n' +
|
|
48
|
+
' plugin-root\n');
|
|
43
49
|
process.exit(2);
|
|
44
50
|
}
|
|
45
51
|
const [, , command, ...rest] = process.argv;
|
|
@@ -211,6 +217,31 @@ try {
|
|
|
211
217
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
212
218
|
process.exit(0);
|
|
213
219
|
}
|
|
220
|
+
case 'ui-review-config': {
|
|
221
|
+
const flags = rest.filter((a) => a.startsWith('--'));
|
|
222
|
+
const repoRootFlag = flags.find((f) => f.startsWith('--repo-root=') || f === '--repo-root');
|
|
223
|
+
let repoRoot;
|
|
224
|
+
if (repoRootFlag === '--repo-root') {
|
|
225
|
+
repoRoot = rest[rest.indexOf('--repo-root') + 1];
|
|
226
|
+
}
|
|
227
|
+
else if (repoRootFlag) {
|
|
228
|
+
repoRoot = repoRootFlag.replace('--repo-root=', '');
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
repoRoot = rest.filter((a) => !a.startsWith('--'))[0];
|
|
232
|
+
}
|
|
233
|
+
if (!repoRoot) {
|
|
234
|
+
console.error('usage: ui-review-config --repo-root <repoRoot>');
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
const config = loadUiReviewConfig(repoRoot);
|
|
238
|
+
process.stdout.write(JSON.stringify(config, null, 2));
|
|
239
|
+
process.exit(0);
|
|
240
|
+
}
|
|
241
|
+
case 'plugin-root': {
|
|
242
|
+
process.stdout.write(getPluginRoot());
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
214
245
|
default:
|
|
215
246
|
usage(`Unknown command: ${command}`);
|
|
216
247
|
}
|
package/dist/feedback.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export function allFeedback(repoRoot, taskId) {
|
|
|
29
29
|
const dir = feedbackDir(repoRoot);
|
|
30
30
|
if (!existsSync(dir))
|
|
31
31
|
return [];
|
|
32
|
-
const re = new RegExp(`^${escapeRegex(taskId)}-
|
|
32
|
+
const re = new RegExp(`^${escapeRegex(taskId)}-[ruq](\\d+)\\.json$`);
|
|
33
33
|
const entries = readdirSync(dir)
|
|
34
34
|
.map((f) => ({ f, m: f.match(re) }))
|
|
35
35
|
.filter((x) => !!x.m)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
4
|
+
/**
|
|
5
|
+
* Absolute path to the plugin root.
|
|
6
|
+
*
|
|
7
|
+
* At runtime, this module lives at <plugin-root>/lib/plugin-path.js (or .ts in dev),
|
|
8
|
+
* so the plugin root is the parent directory.
|
|
9
|
+
*
|
|
10
|
+
* Works under:
|
|
11
|
+
* - dev mode (repo source: <repo>/reference-impl/)
|
|
12
|
+
* - npm install (node_modules/@cloverleaf/reference-impl/)
|
|
13
|
+
* - claude plugin install cache (~/.claude/plugins/cache/cloverleaf-local/cloverleaf/0.4.1/)
|
|
14
|
+
* - legacy symlink into ~/.claude/plugins/cloverleaf/
|
|
15
|
+
* - claude --plugin-dir <path>
|
|
16
|
+
*/
|
|
17
|
+
export function getPluginRoot() {
|
|
18
|
+
return resolve(here, '..');
|
|
19
|
+
}
|
|
@@ -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,41 @@
|
|
|
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'], ignored: [] },
|
|
14
|
+
};
|
|
15
|
+
function readAsConfig(path) {
|
|
16
|
+
try {
|
|
17
|
+
const doc = JSON.parse(readFileSync(path, 'utf-8'));
|
|
18
|
+
// Back-compat: if ignored is missing from an older override, default it.
|
|
19
|
+
if (doc.axe && !('ignored' in doc.axe)) {
|
|
20
|
+
doc.axe.ignored = [];
|
|
21
|
+
}
|
|
22
|
+
return doc;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function loadUiReviewConfig(repoRoot) {
|
|
29
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
|
|
30
|
+
if (existsSync(consumerPath)) {
|
|
31
|
+
const parsed = readAsConfig(consumerPath);
|
|
32
|
+
if (parsed)
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
if (existsSync(PACKAGE_DEFAULT)) {
|
|
36
|
+
const parsed = readAsConfig(PACKAGE_DEFAULT);
|
|
37
|
+
if (parsed)
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
return HARDCODED_FALLBACK;
|
|
41
|
+
}
|
|
@@ -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,64 @@
|
|
|
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(
|
|
28
|
+
raws: RawAxeFinding[],
|
|
29
|
+
keys: DedupeKey[],
|
|
30
|
+
ignored: Array<{ ruleId: string; target: string }> = []
|
|
31
|
+
): Finding[] {
|
|
32
|
+
// Filter out ignored (ruleId, target) tuples BEFORE grouping.
|
|
33
|
+
const filtered = raws.filter((raw) => {
|
|
34
|
+
return !ignored.some(
|
|
35
|
+
(i) => i.ruleId === raw.ruleId && i.target === raw.target
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const groups = new Map<string, { first: RawAxeFinding; viewports: string[] }>();
|
|
40
|
+
for (const raw of filtered) {
|
|
41
|
+
const key = dedupeKeyOf(raw, keys);
|
|
42
|
+
const existing = groups.get(key);
|
|
43
|
+
if (existing) {
|
|
44
|
+
if (!existing.viewports.includes(raw.viewport)) existing.viewports.push(raw.viewport);
|
|
45
|
+
} else {
|
|
46
|
+
groups.set(key, { first: raw, viewports: [raw.viewport] });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const out: Finding[] = [];
|
|
50
|
+
for (const { first, viewports } of groups.values()) {
|
|
51
|
+
out.push({
|
|
52
|
+
severity: SEVERITY_MAP[first.impact],
|
|
53
|
+
message: first.message,
|
|
54
|
+
rule: first.ruleId,
|
|
55
|
+
metadata: {
|
|
56
|
+
target: first.target,
|
|
57
|
+
impact: first.impact,
|
|
58
|
+
viewports,
|
|
59
|
+
...(first.helpUrl ? { helpUrl: first.helpUrl } : {}),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|