@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
package/lib/affected-routes.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface AffectedRoutesConfig {
|
|
|
9
9
|
pageRoots: string[];
|
|
10
10
|
globalPatterns: string[];
|
|
11
11
|
routeScope: string[];
|
|
12
|
+
contentRoutes: Record<string, string>;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
function globToRegex(pattern: string): RegExp {
|
|
@@ -33,7 +34,7 @@ function routeForPage(file: string, pageRoot: string): string | null {
|
|
|
33
34
|
return `/${withoutExt}/`;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
function loadDefaultConfig(): AffectedRoutesConfig {
|
|
37
38
|
if (!existsSync(DEFAULT_CONFIG)) {
|
|
38
39
|
throw new Error(`affected-routes config not found at ${DEFAULT_CONFIG}`);
|
|
39
40
|
}
|
|
@@ -42,9 +43,32 @@ export function loadDefaultConfig(): AffectedRoutesConfig {
|
|
|
42
43
|
pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
|
|
43
44
|
globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
|
|
44
45
|
routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
|
|
46
|
+
contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
|
|
47
|
+
? doc.contentRoutes
|
|
48
|
+
: {},
|
|
45
49
|
};
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
export function loadAffectedRoutesConfig(repoRoot: string): AffectedRoutesConfig {
|
|
53
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'affected-routes.json');
|
|
54
|
+
if (existsSync(consumerPath)) {
|
|
55
|
+
try {
|
|
56
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as Partial<AffectedRoutesConfig>;
|
|
57
|
+
return {
|
|
58
|
+
pageRoots: Array.isArray(doc.pageRoots) ? doc.pageRoots : [],
|
|
59
|
+
globalPatterns: Array.isArray(doc.globalPatterns) ? doc.globalPatterns : [],
|
|
60
|
+
routeScope: Array.isArray(doc.routeScope) ? doc.routeScope : [],
|
|
61
|
+
contentRoutes: (doc.contentRoutes && typeof doc.contentRoutes === 'object' && !Array.isArray(doc.contentRoutes))
|
|
62
|
+
? doc.contentRoutes
|
|
63
|
+
: {},
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
// fall through
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return loadDefaultConfig();
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
export function computeAffectedRoutes(
|
|
49
73
|
changedFiles: string[],
|
|
50
74
|
config: AffectedRoutesConfig
|
|
@@ -68,6 +92,15 @@ export function computeAffectedRoutes(
|
|
|
68
92
|
routes.add(mapped);
|
|
69
93
|
continue;
|
|
70
94
|
}
|
|
95
|
+
// contentRoutes: map content-collection files to specific routes
|
|
96
|
+
for (const [pattern, route] of Object.entries(config.contentRoutes)) {
|
|
97
|
+
if (globToRegex(pattern).test(file)) {
|
|
98
|
+
routes.add(route);
|
|
99
|
+
mapped = route;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (mapped) continue;
|
|
71
104
|
if (matchesAny(file, config.routeScope)) {
|
|
72
105
|
inScopeButUnmatched = true;
|
|
73
106
|
}
|
|
@@ -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';
|
|
@@ -21,8 +22,11 @@ import { advanceStatus } from './state.js';
|
|
|
21
22
|
import { emitGateDecision } from './events.js';
|
|
22
23
|
import { writeFeedback, latestFeedback } from './feedback.js';
|
|
23
24
|
import { nextTaskId, inferProject } from './ids.js';
|
|
24
|
-
import { matchesUiPaths
|
|
25
|
-
import {
|
|
25
|
+
import { matchesUiPaths } from './ui-paths.js';
|
|
26
|
+
import { loadUiPathsConfig } from './ui-paths.js';
|
|
27
|
+
import { computeAffectedRoutes } from './affected-routes.js';
|
|
28
|
+
import { loadAffectedRoutesConfig } from './affected-routes.js';
|
|
29
|
+
import { loadUiReviewConfig } from './ui-review-config.js';
|
|
26
30
|
import type { FeedbackEnvelope } from './feedback.js';
|
|
27
31
|
|
|
28
32
|
function die(msg: string, code = 1): never {
|
|
@@ -41,7 +45,8 @@ function usage(msg?: string): never {
|
|
|
41
45
|
' advance-status <repoRoot> <taskId> <toStatus> <actor> [gate] [path]\n' +
|
|
42
46
|
' write-feedback <repoRoot> <taskId> <envelopeJsonPath>\n' +
|
|
43
47
|
' latest-feedback <repoRoot> <taskId>\n' +
|
|
44
|
-
' 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'
|
|
45
50
|
);
|
|
46
51
|
process.exit(2);
|
|
47
52
|
}
|
|
@@ -186,7 +191,7 @@ try {
|
|
|
186
191
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
187
192
|
process.exit(2);
|
|
188
193
|
}
|
|
189
|
-
const patterns =
|
|
194
|
+
const { patterns } = loadUiPathsConfig(repoRoot);
|
|
190
195
|
const result = matchesUiPaths(changed, patterns);
|
|
191
196
|
process.stdout.write(`${result}\n`);
|
|
192
197
|
process.exit(0);
|
|
@@ -213,12 +218,32 @@ try {
|
|
|
213
218
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
214
219
|
process.exit(2);
|
|
215
220
|
}
|
|
216
|
-
const config =
|
|
221
|
+
const config = loadAffectedRoutesConfig(repoRoot);
|
|
217
222
|
const result = computeAffectedRoutes(changed, config);
|
|
218
223
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
219
224
|
process.exit(0);
|
|
220
225
|
}
|
|
221
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
|
+
|
|
222
247
|
default:
|
|
223
248
|
usage(`Unknown command: ${command}`);
|
|
224
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 {
|
package/lib/qa-report.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export interface QaRunResult {
|
|
2
|
+
ruleId: string;
|
|
3
|
+
command: string;
|
|
4
|
+
cwd: string;
|
|
5
|
+
durationMs: number;
|
|
6
|
+
passed: boolean;
|
|
7
|
+
stdoutTail: string;
|
|
8
|
+
stderrTail: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escape(s: string): string {
|
|
12
|
+
return s
|
|
13
|
+
.replace(/&/g, '&')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>')
|
|
16
|
+
.replace(/"/g, '"')
|
|
17
|
+
.replace(/'/g, ''');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderRow(r: QaRunResult): string {
|
|
21
|
+
const status = r.passed ? 'PASS' : 'FAIL';
|
|
22
|
+
const statusClass = r.passed ? 'pass' : 'fail';
|
|
23
|
+
return `
|
|
24
|
+
<tr class="${statusClass}">
|
|
25
|
+
<td>${escape(r.ruleId)}</td>
|
|
26
|
+
<td><code>${escape(r.command)}</code></td>
|
|
27
|
+
<td>${escape(r.cwd)}</td>
|
|
28
|
+
<td>${r.durationMs}ms</td>
|
|
29
|
+
<td class="status">${status}</td>
|
|
30
|
+
</tr>
|
|
31
|
+
<tr class="detail ${statusClass}">
|
|
32
|
+
<td colspan="5">
|
|
33
|
+
${r.stdoutTail ? `<details><summary>stdout (tail)</summary><pre>${escape(r.stdoutTail)}</pre></details>` : ''}
|
|
34
|
+
${r.stderrTail ? `<details open><summary>stderr (tail)</summary><pre>${escape(r.stderrTail)}</pre></details>` : ''}
|
|
35
|
+
</td>
|
|
36
|
+
</tr>
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderQaReport(runs: QaRunResult[]): string {
|
|
41
|
+
const empty = runs.length === 0
|
|
42
|
+
? `<p class="empty">No runs / results.</p>`
|
|
43
|
+
: '';
|
|
44
|
+
const rows = runs.map(renderRow).join('');
|
|
45
|
+
return `<!DOCTYPE html>
|
|
46
|
+
<html lang="en">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<title>Cloverleaf QA Report</title>
|
|
50
|
+
<style>
|
|
51
|
+
body { font: 14px/1.4 system-ui, sans-serif; margin: 2rem; color: #111; }
|
|
52
|
+
table { width: 100%; border-collapse: collapse; }
|
|
53
|
+
th, td { padding: 0.5rem; border-bottom: 1px solid #ddd; text-align: left; vertical-align: top; }
|
|
54
|
+
.status { font-weight: 600; }
|
|
55
|
+
.pass .status { color: #0a7; }
|
|
56
|
+
.fail .status { color: #c33; }
|
|
57
|
+
tr.detail td { background: #fafafa; padding-top: 0; }
|
|
58
|
+
pre { overflow: auto; background: #f4f4f4; padding: 0.5rem; }
|
|
59
|
+
.empty { color: #888; }
|
|
60
|
+
</style>
|
|
61
|
+
</head>
|
|
62
|
+
<body>
|
|
63
|
+
<h1>Cloverleaf QA Report</h1>
|
|
64
|
+
${empty}
|
|
65
|
+
${runs.length > 0 ? `
|
|
66
|
+
<table>
|
|
67
|
+
<thead>
|
|
68
|
+
<tr><th>Rule</th><th>Command</th><th>CWD</th><th>Duration</th><th>Status</th></tr>
|
|
69
|
+
</thead>
|
|
70
|
+
<tbody>
|
|
71
|
+
${rows}
|
|
72
|
+
</tbody>
|
|
73
|
+
</table>
|
|
74
|
+
` : ''}
|
|
75
|
+
</body>
|
|
76
|
+
</html>`;
|
|
77
|
+
}
|
package/lib/qa-rules.ts
CHANGED
|
@@ -12,12 +12,27 @@ export interface QaRule {
|
|
|
12
12
|
command: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function loadDefaultRules(): QaRule[] {
|
|
16
16
|
if (!existsSync(DEFAULT_CONFIG)) return [];
|
|
17
17
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { rules?: QaRule[] };
|
|
18
18
|
return Array.isArray(doc.rules) ? doc.rules : [];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function loadQaRulesConfig(repoRoot: string): QaRule[] {
|
|
22
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'qa-rules.json');
|
|
23
|
+
if (existsSync(consumerPath)) {
|
|
24
|
+
try {
|
|
25
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as { rules?: QaRule[] };
|
|
26
|
+
if (Array.isArray(doc.rules)) {
|
|
27
|
+
return doc.rules;
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// fall through
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return loadDefaultRules();
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
export function selectTestCommands(changedFiles: string[], rules: QaRule[]): QaRule[] {
|
|
22
37
|
return rules.filter((rule) => matchesUiPaths(changedFiles, rule.match));
|
|
23
38
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn a URL path into a filesystem-safe slug used in baseline/diff filenames.
|
|
3
|
+
* - "/" → "index"
|
|
4
|
+
* - "/faq/" → "faq"
|
|
5
|
+
* - "/guide/chapter-3/" → "guide-chapter-3"
|
|
6
|
+
* - "/docs/v1.2/getting started/" → "docs-v1-2-getting-started"
|
|
7
|
+
* Query/hash are stripped.
|
|
8
|
+
*/
|
|
9
|
+
export function slugifyRoute(route: string): string {
|
|
10
|
+
const pathOnly = route.split(/[?#]/)[0];
|
|
11
|
+
if (pathOnly === '/' || pathOnly === '') return 'index';
|
|
12
|
+
const trimmed = pathOnly.replace(/^\/+|\/+$/g, '');
|
|
13
|
+
if (trimmed === '') return 'index';
|
|
14
|
+
const slugged = trimmed
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9/-]+/g, '-')
|
|
17
|
+
.replace(/\/+/g, '-')
|
|
18
|
+
.replace(/-+/g, '-')
|
|
19
|
+
.replace(/^-|-$/g, '');
|
|
20
|
+
return slugged || 'index';
|
|
21
|
+
}
|
package/lib/ui-paths.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const DEFAULT_CONFIG = join(here, '..', 'config', 'ui-paths.json');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function loadDefaultPatterns(): string[] {
|
|
9
9
|
if (!existsSync(DEFAULT_CONFIG)) return ['site/**'];
|
|
10
10
|
const doc = JSON.parse(readFileSync(DEFAULT_CONFIG, 'utf-8')) as { patterns?: string[] };
|
|
11
11
|
return Array.isArray(doc.patterns) ? doc.patterns : ['site/**'];
|
|
@@ -20,6 +20,21 @@ function globToRegex(pattern: string): RegExp {
|
|
|
20
20
|
return new RegExp(`^${regex}$`);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function loadUiPathsConfig(repoRoot: string): { patterns: string[] } {
|
|
24
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-paths.json');
|
|
25
|
+
if (existsSync(consumerPath)) {
|
|
26
|
+
try {
|
|
27
|
+
const doc = JSON.parse(readFileSync(consumerPath, 'utf-8')) as { patterns?: string[] };
|
|
28
|
+
if (Array.isArray(doc.patterns)) {
|
|
29
|
+
return { patterns: doc.patterns };
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// fall through to package default
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { patterns: loadDefaultPatterns() };
|
|
36
|
+
}
|
|
37
|
+
|
|
23
38
|
export function matchesUiPaths(changedFiles: string[], patterns: string[]): boolean {
|
|
24
39
|
if (changedFiles.length === 0) return false;
|
|
25
40
|
const regexes = patterns.map(globToRegex);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const PACKAGE_DEFAULT = join(here, '..', 'config', 'ui-review.json');
|
|
7
|
+
|
|
8
|
+
export interface Viewport {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface UiReviewConfig {
|
|
14
|
+
viewports: Record<string, Viewport>;
|
|
15
|
+
visualDiff: {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
threshold: number;
|
|
18
|
+
maxDiffRatio: number;
|
|
19
|
+
mask: string[];
|
|
20
|
+
};
|
|
21
|
+
axe: {
|
|
22
|
+
viewports: string[];
|
|
23
|
+
dedupeBy: ('ruleId' | 'target')[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HARDCODED_FALLBACK: UiReviewConfig = {
|
|
28
|
+
viewports: {
|
|
29
|
+
mobile: { width: 375, height: 667 },
|
|
30
|
+
tablet: { width: 768, height: 1024 },
|
|
31
|
+
desktop: { width: 1280, height: 800 },
|
|
32
|
+
},
|
|
33
|
+
visualDiff: { enabled: true, threshold: 0.1, maxDiffRatio: 0.01, mask: [] },
|
|
34
|
+
axe: { viewports: ['desktop'], dedupeBy: ['ruleId', 'target'] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function readAsConfig(path: string): UiReviewConfig | null {
|
|
38
|
+
try {
|
|
39
|
+
const doc = JSON.parse(readFileSync(path, 'utf-8')) as UiReviewConfig;
|
|
40
|
+
return doc;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadUiReviewConfig(repoRoot: string): UiReviewConfig {
|
|
47
|
+
const consumerPath = join(repoRoot, '.cloverleaf', 'config', 'ui-review.json');
|
|
48
|
+
if (existsSync(consumerPath)) {
|
|
49
|
+
const parsed = readAsConfig(consumerPath);
|
|
50
|
+
if (parsed) return parsed;
|
|
51
|
+
}
|
|
52
|
+
if (existsSync(PACKAGE_DEFAULT)) {
|
|
53
|
+
const parsed = readAsConfig(PACKAGE_DEFAULT);
|
|
54
|
+
if (parsed) return parsed;
|
|
55
|
+
}
|
|
56
|
+
return HARDCODED_FALLBACK;
|
|
57
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import pixelmatch from 'pixelmatch';
|
|
4
|
+
import { PNG } from 'pngjs';
|
|
5
|
+
|
|
6
|
+
export type VisualDiffStatus = 'new-baseline' | 'match' | 'diff' | 'dimension-mismatch';
|
|
7
|
+
|
|
8
|
+
export interface VisualDiffResult {
|
|
9
|
+
status: VisualDiffStatus;
|
|
10
|
+
diffPixels: number;
|
|
11
|
+
diffRatio: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CompareVisualArgs {
|
|
17
|
+
baselinePath: string;
|
|
18
|
+
candidateBuf: Buffer;
|
|
19
|
+
diffPath: string;
|
|
20
|
+
candidateOutPath: string;
|
|
21
|
+
threshold: number;
|
|
22
|
+
maxDiffRatio: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function ensureDir(path: string): void {
|
|
26
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeBaseline(baselinePath: string, buf: Buffer): void {
|
|
30
|
+
ensureDir(baselinePath);
|
|
31
|
+
writeFileSync(baselinePath, buf);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function compareVisual(args: CompareVisualArgs): VisualDiffResult {
|
|
35
|
+
const candidatePng = PNG.sync.read(args.candidateBuf);
|
|
36
|
+
|
|
37
|
+
if (!existsSync(args.baselinePath)) {
|
|
38
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
39
|
+
return {
|
|
40
|
+
status: 'new-baseline',
|
|
41
|
+
diffPixels: 0,
|
|
42
|
+
diffRatio: 0,
|
|
43
|
+
width: candidatePng.width,
|
|
44
|
+
height: candidatePng.height,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const baselineBuf = readFileSync(args.baselinePath);
|
|
49
|
+
const baselinePng = PNG.sync.read(baselineBuf);
|
|
50
|
+
|
|
51
|
+
if (baselinePng.width !== candidatePng.width || baselinePng.height !== candidatePng.height) {
|
|
52
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
53
|
+
return {
|
|
54
|
+
status: 'dimension-mismatch',
|
|
55
|
+
diffPixels: 0,
|
|
56
|
+
diffRatio: 0,
|
|
57
|
+
width: candidatePng.width,
|
|
58
|
+
height: candidatePng.height,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const diffPng = new PNG({ width: candidatePng.width, height: candidatePng.height });
|
|
63
|
+
const diffPixels = pixelmatch(
|
|
64
|
+
baselinePng.data,
|
|
65
|
+
candidatePng.data,
|
|
66
|
+
diffPng.data,
|
|
67
|
+
candidatePng.width,
|
|
68
|
+
candidatePng.height,
|
|
69
|
+
{ threshold: args.threshold },
|
|
70
|
+
);
|
|
71
|
+
const totalPixels = candidatePng.width * candidatePng.height;
|
|
72
|
+
const diffRatio = diffPixels / totalPixels;
|
|
73
|
+
|
|
74
|
+
if (diffRatio > args.maxDiffRatio) {
|
|
75
|
+
ensureDir(args.diffPath);
|
|
76
|
+
writeFileSync(args.diffPath, PNG.sync.write(diffPng));
|
|
77
|
+
ensureDir(args.candidateOutPath);
|
|
78
|
+
writeFileSync(args.candidateOutPath, args.candidateBuf);
|
|
79
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
80
|
+
return {
|
|
81
|
+
status: 'diff',
|
|
82
|
+
diffPixels,
|
|
83
|
+
diffRatio,
|
|
84
|
+
width: candidatePng.width,
|
|
85
|
+
height: candidatePng.height,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
writeBaseline(args.baselinePath, args.candidateBuf);
|
|
90
|
+
return {
|
|
91
|
+
status: 'match',
|
|
92
|
+
diffPixels,
|
|
93
|
+
diffRatio,
|
|
94
|
+
width: candidatePng.width,
|
|
95
|
+
height: candidatePng.height,
|
|
96
|
+
};
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"reference-implementation"
|
|
24
24
|
],
|
|
25
25
|
"files": [
|
|
26
|
+
".claude-plugin",
|
|
26
27
|
"skills",
|
|
27
28
|
"lib",
|
|
28
29
|
"prompts",
|
|
@@ -46,14 +47,18 @@
|
|
|
46
47
|
"prepublishOnly": "npm test && npm run build"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
|
-
"@cloverleaf/standard": "^0.
|
|
50
|
+
"@cloverleaf/standard": "^0.4.0",
|
|
50
51
|
"ajv": "^8.17.1",
|
|
51
52
|
"ajv-formats": "^3.0.1",
|
|
53
|
+
"axe-core": "^4.10.0",
|
|
54
|
+
"pixelmatch": "^5.3.0",
|
|
52
55
|
"playwright": "^1.47.0",
|
|
53
|
-
"
|
|
56
|
+
"pngjs": "^7.0.0"
|
|
54
57
|
},
|
|
55
58
|
"devDependencies": {
|
|
56
59
|
"@types/node": "^22.0.0",
|
|
60
|
+
"@types/pixelmatch": "^5.2.6",
|
|
61
|
+
"@types/pngjs": "^6.0.5",
|
|
57
62
|
"tsx": "^4.19.0",
|
|
58
63
|
"typescript": "^5.5.0",
|
|
59
64
|
"vitest": "^2.0.0"
|
package/prompts/qa.md
CHANGED
|
@@ -57,6 +57,27 @@ The Standard's QA contract requires a `preview_uri`. You were passed the sentine
|
|
|
57
57
|
- Use `git worktree`: do NOT `git checkout` in the main working directory.
|
|
58
58
|
- Always teardown the worktree, even on error.
|
|
59
59
|
|
|
60
|
+
## QA Report (v0.4)
|
|
61
|
+
|
|
62
|
+
After executing all matched QA rules, write an HTML report summarizing each run to `<repoRoot>/.cloverleaf/runs/{taskId}/qa/report.html` (substitute `{taskId}` with the `id` field from the task input, e.g., `{{task.id}}`).
|
|
63
|
+
|
|
64
|
+
Use `renderQaReport(runs)` from `lib/qa-report.ts` to produce the HTML. Ensure the directory exists first (`mkdir -p`).
|
|
65
|
+
|
|
66
|
+
In the feedback you emit, include the report as an attachment on a single info-level finding (or on whichever summary finding you already emit):
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"severity": "info",
|
|
71
|
+
"rule": "qa-report",
|
|
72
|
+
"message": "QA report written",
|
|
73
|
+
"attachments": [
|
|
74
|
+
{ "label": "report", "path": ".cloverleaf/runs/{taskId}/qa/report.html" }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
This lets humans at final-gate inspect the full QA detail without grovelling through logs.
|
|
80
|
+
|
|
60
81
|
## Output
|
|
61
82
|
|
|
62
83
|
Respond with exactly one JSON object and nothing else:
|