@cloverleaf/reference-impl 0.3.0 → 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/README.md +32 -1
- package/VERSION +1 -1
- package/config/affected-routes.json +7 -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 +34 -1
- package/dist/axe-dedupe.mjs +38 -0
- package/dist/cli.mjs +31 -5
- package/dist/qa-report.mjs +65 -0
- package/dist/qa-rules.mjs +16 -1
- package/dist/route-slug.mjs +23 -0
- package/dist/ui-paths.mjs +16 -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 +34 -1
- package/lib/axe-dedupe.ts +53 -0
- package/lib/cli.ts +30 -5
- package/lib/feedback.ts +7 -0
- package/lib/qa-report.ts +77 -0
- package/lib/qa-rules.ts +16 -1
- package/lib/route-slug.ts +21 -0
- package/lib/ui-paths.ts +16 -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 +80 -39
- package/skills/{cloverleaf-new-task.md → cloverleaf-new-task/SKILL.md} +18 -1
- package/skills/{cloverleaf-qa.md → cloverleaf-qa/SKILL.md} +17 -7
- 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/README.md
CHANGED
|
@@ -52,12 +52,43 @@ Two JSON config files in `config/` (overridable per consumer project):
|
|
|
52
52
|
- `config/ui-paths.json` — glob patterns that trigger UI Reviewer (default: `site/**`)
|
|
53
53
|
- `config/qa-rules.json` — per-package test commands
|
|
54
54
|
|
|
55
|
+
### Customizing for your repo
|
|
56
|
+
|
|
57
|
+
The package ships with cloverleaf-flavored defaults in `config/`. Your repo overrides any of them by placing a file of the same name at `<repoRoot>/.cloverleaf/config/<name>.json`. Consumer override is a **full replacement** — your file becomes the complete source of truth for that config.
|
|
58
|
+
|
|
59
|
+
Available overrides:
|
|
60
|
+
|
|
61
|
+
| Override file | Purpose |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `.cloverleaf/config/ui-paths.json` | Controls `detect-ui-paths` — which diffs trigger the `ui-review` state |
|
|
64
|
+
| `.cloverleaf/config/qa-rules.json` | Per-package test commands for the QA agent |
|
|
65
|
+
| `.cloverleaf/config/affected-routes.json` | Rules for mapping diffs to site routes (UI Reviewer scope); includes `contentRoutes` for content-collection mapping |
|
|
66
|
+
| `.cloverleaf/config/astro-base.json` | Explicit Astro `base` path — avoids best-effort parsing of `astro.config.*` |
|
|
67
|
+
|
|
68
|
+
Example `affected-routes.json` override for a Next.js project:
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"pageRoots": ["apps/web/app/"],
|
|
73
|
+
"globalPatterns": ["apps/web/components/**", "apps/web/styles/**"],
|
|
74
|
+
"routeScope": ["apps/web/**"],
|
|
75
|
+
"contentRoutes": {}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Example `astro-base.json`:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{ "base": "/my-docs" }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
All overrides are read fresh on every skill invocation; no caching. Edit and the next `/cloverleaf-run` picks it up.
|
|
86
|
+
|
|
55
87
|
### Known limitations
|
|
56
88
|
|
|
57
89
|
- Concurrent `/cloverleaf-run` on the same repo may race on preview ports.
|
|
58
90
|
- UI Reviewer visual diff + multi-viewport deferred to v0.4.
|
|
59
91
|
- QA does not produce HTML reports (no `report_uri`).
|
|
60
|
-
- Astro `base` path is parsed best-effort; if misdetected, UI Reviewer may return escalate.
|
|
61
92
|
|
|
62
93
|
### Prerequisites for UI Reviewer
|
|
63
94
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.0
|
|
@@ -1,12 +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/**"],
|
|
10
|
+
"contentRoutes": {}
|
|
12
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
|
}
|
|
@@ -34,8 +34,31 @@ export function loadDefaultConfig() {
|
|
|
34
34
|
pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
|
|
35
35
|
globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
|
|
36
36
|
routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
|
|
37
|
+
contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
|
|
38
|
+
? doc.contentRoutes
|
|
39
|
+
: {},
|
|
37
40
|
};
|
|
38
41
|
}
|
|
42
|
+
export function loadAffectedRoutesConfig(repoRoot) {
|
|
43
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'affected-routes.json');
|
|
44
|
+
if (existsSync(consumerPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
|
|
47
|
+
return {
|
|
48
|
+
pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
|
|
49
|
+
globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
|
|
50
|
+
routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
|
|
51
|
+
contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
|
|
52
|
+
? doc.contentRoutes
|
|
53
|
+
: {},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// fall through
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return loadDefaultConfig();
|
|
61
|
+
}
|
|
39
62
|
export function computeAffectedRoutes(changedFiles, config) {
|
|
40
63
|
const routes = new Set();
|
|
41
64
|
let inScopeButUnmatched = false;
|
|
@@ -55,6 +78,16 @@ export function computeAffectedRoutes(changedFiles, config) {
|
|
|
55
78
|
routes.add(mapped);
|
|
56
79
|
continue;
|
|
57
80
|
}
|
|
81
|
+
// contentRoutes: map content-collection files to specific routes
|
|
82
|
+
for (const [pattern, route] of Object.entries(config.contentRoutes)) {
|
|
83
|
+
if (globToRegex(pattern).test(file)) {
|
|
84
|
+
routes.add(route);
|
|
85
|
+
mapped = route;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (mapped)
|
|
90
|
+
continue;
|
|
58
91
|
if (matchesAny(file, config.routeScope)) {
|
|
59
92
|
inScopeButUnmatched = true;
|
|
60
93
|
}
|
|
@@ -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';
|
|
@@ -20,8 +21,11 @@ import { advanceStatus } from './state.mjs';
|
|
|
20
21
|
import { emitGateDecision } from './events.mjs';
|
|
21
22
|
import { writeFeedback, latestFeedback } from './feedback.mjs';
|
|
22
23
|
import { nextTaskId, inferProject } from './ids.mjs';
|
|
23
|
-
import { matchesUiPaths
|
|
24
|
-
import {
|
|
24
|
+
import { matchesUiPaths } from './ui-paths.mjs';
|
|
25
|
+
import { loadUiPathsConfig } from './ui-paths.mjs';
|
|
26
|
+
import { computeAffectedRoutes } from './affected-routes.mjs';
|
|
27
|
+
import { loadAffectedRoutesConfig } from './affected-routes.mjs';
|
|
28
|
+
import { loadUiReviewConfig } from './ui-review-config.mjs';
|
|
25
29
|
function die(msg, code = 1) {
|
|
26
30
|
process.stderr.write(msg + '\n');
|
|
27
31
|
process.exit(code);
|
|
@@ -37,7 +41,8 @@ function usage(msg) {
|
|
|
37
41
|
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
|
|
38
42
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
39
43
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
40
|
-
' 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');
|
|
41
46
|
process.exit(2);
|
|
42
47
|
}
|
|
43
48
|
const [, , command, ...rest] = process.argv;
|
|
@@ -177,7 +182,7 @@ try {
|
|
|
177
182
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
178
183
|
process.exit(2);
|
|
179
184
|
}
|
|
180
|
-
const patterns =
|
|
185
|
+
const { patterns } = loadUiPathsConfig(repoRoot);
|
|
181
186
|
const result = matchesUiPaths(changed, patterns);
|
|
182
187
|
process.stdout.write(`${result}\n`);
|
|
183
188
|
process.exit(0);
|
|
@@ -204,11 +209,32 @@ try {
|
|
|
204
209
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
205
210
|
process.exit(2);
|
|
206
211
|
}
|
|
207
|
-
const config =
|
|
212
|
+
const config = loadAffectedRoutesConfig(repoRoot);
|
|
208
213
|
const result = computeAffectedRoutes(changed, config);
|
|
209
214
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
210
215
|
process.exit(0);
|
|
211
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
|
+
}
|
|
212
238
|
default:
|
|
213
239
|
usage(`Unknown command: ${command}`);
|
|
214
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,12 +4,27 @@ 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'));
|
|
11
11
|
return Array.isArray(doc.rules) ? doc.rules : [];
|
|
12
12
|
}
|
|
13
|
+
export function loadQaRulesConfig(repoRoot) {
|
|
14
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
|
|
15
|
+
if (existsSync(consumerPath)) {
|
|
16
|
+
try {
|
|
17
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
|
|
18
|
+
if (Array.isArray(doc.rules)) {
|
|
19
|
+
return doc.rules;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// fall through
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return loadDefaultRules();
|
|
27
|
+
}
|
|
13
28
|
export function selectTestCommands(changedFiles, rules) {
|
|
14
29
|
return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
|
|
15
30
|
}
|
|
@@ -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'));
|
|
@@ -17,6 +17,21 @@ function globToRegex(pattern) {
|
|
|
17
17
|
.replace(/\u0000/g, '.*');
|
|
18
18
|
return new RegExp(`^${regex}$`);
|
|
19
19
|
}
|
|
20
|
+
export function loadUiPathsConfig(repoRoot) {
|
|
21
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-paths.json');
|
|
22
|
+
if (existsSync(consumerPath)) {
|
|
23
|
+
try {
|
|
24
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8'));
|
|
25
|
+
if (Array.isArray(doc.patterns)) {
|
|
26
|
+
return { patterns: doc.patterns };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// fall through to package default
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { patterns: loadDefaultPatterns() };
|
|
34
|
+
}
|
|
20
35
|
export function matchesUiPaths(changedFiles, patterns) {
|
|
21
36
|
if (changedFiles.length === 0)
|
|
22
37
|
return false;
|
|
@@ -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
|