@cloverleaf/reference-impl 0.3.0 → 0.3.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/README.md +32 -1
- package/VERSION +1 -1
- package/config/affected-routes.json +2 -1
- package/dist/affected-routes.mjs +33 -0
- package/dist/cli.mjs +6 -4
- package/dist/qa-rules.mjs +15 -0
- package/dist/ui-paths.mjs +15 -0
- package/lib/affected-routes.ts +33 -0
- package/lib/cli.ts +6 -4
- package/lib/qa-rules.ts +15 -0
- package/lib/ui-paths.ts +15 -0
- package/package.json +1 -1
- package/prompts/ui-reviewer.md +4 -1
- package/skills/cloverleaf-qa.md +6 -1
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.3.
|
|
1
|
+
0.3.1
|
package/dist/affected-routes.mjs
CHANGED
|
@@ -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
|
}
|
package/dist/cli.mjs
CHANGED
|
@@ -20,8 +20,10 @@ import { advanceStatus } from './state.mjs';
|
|
|
20
20
|
import { emitGateDecision } from './events.mjs';
|
|
21
21
|
import { writeFeedback, latestFeedback } from './feedback.mjs';
|
|
22
22
|
import { nextTaskId, inferProject } from './ids.mjs';
|
|
23
|
-
import { matchesUiPaths
|
|
24
|
-
import {
|
|
23
|
+
import { matchesUiPaths } from './ui-paths.mjs';
|
|
24
|
+
import { loadUiPathsConfig } from './ui-paths.mjs';
|
|
25
|
+
import { computeAffectedRoutes } from './affected-routes.mjs';
|
|
26
|
+
import { loadAffectedRoutesConfig } from './affected-routes.mjs';
|
|
25
27
|
function die(msg, code = 1) {
|
|
26
28
|
process.stderr.write(msg + '\n');
|
|
27
29
|
process.exit(code);
|
|
@@ -177,7 +179,7 @@ try {
|
|
|
177
179
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
178
180
|
process.exit(2);
|
|
179
181
|
}
|
|
180
|
-
const patterns =
|
|
182
|
+
const { patterns } = loadUiPathsConfig(repoRoot);
|
|
181
183
|
const result = matchesUiPaths(changed, patterns);
|
|
182
184
|
process.stdout.write(`${result}\n`);
|
|
183
185
|
process.exit(0);
|
|
@@ -204,7 +206,7 @@ try {
|
|
|
204
206
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
205
207
|
process.exit(2);
|
|
206
208
|
}
|
|
207
|
-
const config =
|
|
209
|
+
const config = loadAffectedRoutesConfig(repoRoot);
|
|
208
210
|
const result = computeAffectedRoutes(changed, config);
|
|
209
211
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
210
212
|
process.exit(0);
|
package/dist/qa-rules.mjs
CHANGED
|
@@ -10,6 +10,21 @@ export function loadDefaultRules() {
|
|
|
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
|
}
|
package/dist/ui-paths.mjs
CHANGED
|
@@ -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;
|
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 {
|
|
@@ -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
|
}
|
package/lib/cli.ts
CHANGED
|
@@ -21,8 +21,10 @@ import { advanceStatus } from './state.js';
|
|
|
21
21
|
import { emitGateDecision } from './events.js';
|
|
22
22
|
import { writeFeedback, latestFeedback } from './feedback.js';
|
|
23
23
|
import { nextTaskId, inferProject } from './ids.js';
|
|
24
|
-
import { matchesUiPaths
|
|
25
|
-
import {
|
|
24
|
+
import { matchesUiPaths } from './ui-paths.js';
|
|
25
|
+
import { loadUiPathsConfig } from './ui-paths.js';
|
|
26
|
+
import { computeAffectedRoutes } from './affected-routes.js';
|
|
27
|
+
import { loadAffectedRoutesConfig } from './affected-routes.js';
|
|
26
28
|
import type { FeedbackEnvelope } from './feedback.js';
|
|
27
29
|
|
|
28
30
|
function die(msg: string, code = 1): never {
|
|
@@ -186,7 +188,7 @@ try {
|
|
|
186
188
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
187
189
|
process.exit(2);
|
|
188
190
|
}
|
|
189
|
-
const patterns =
|
|
191
|
+
const { patterns } = loadUiPathsConfig(repoRoot);
|
|
190
192
|
const result = matchesUiPaths(changed, patterns);
|
|
191
193
|
process.stdout.write(`${result}\n`);
|
|
192
194
|
process.exit(0);
|
|
@@ -213,7 +215,7 @@ try {
|
|
|
213
215
|
console.error(`branch ${branch} not found: ${stderrStr || err.message || 'unknown'}`);
|
|
214
216
|
process.exit(2);
|
|
215
217
|
}
|
|
216
|
-
const config =
|
|
218
|
+
const config = loadAffectedRoutesConfig(repoRoot);
|
|
217
219
|
const result = computeAffectedRoutes(changed, config);
|
|
218
220
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
219
221
|
process.exit(0);
|
package/lib/qa-rules.ts
CHANGED
|
@@ -18,6 +18,21 @@ export function loadDefaultRules(): 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
|
}
|
package/lib/ui-paths.ts
CHANGED
|
@@ -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);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloverleaf/reference-impl",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Reference implementation of the Cloverleaf methodology as Claude Code skills. Implements the Tight Loop (Implementer + Reviewer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/prompts/ui-reviewer.md
CHANGED
|
@@ -46,7 +46,10 @@ The `PLAYWRIGHT_BROWSERS_PATH` environment variable is set to `~/.cache/ms-playw
|
|
|
46
46
|
|
|
47
47
|
4. Wait up to 30s for `http://localhost:{{preview_port}}/` to respond 200. If the server fails to start in 30s, kill it and return verdict `escalate`.
|
|
48
48
|
|
|
49
|
-
5. Determine the site base path:
|
|
49
|
+
5. Determine the site base path:
|
|
50
|
+
1. Check `<repoRoot>/.cloverleaf/config/astro-base.json`. Expected shape: `{ "base": "<path>" }`. If present, use the `base` field verbatim and skip to step 6. (Consumer override — checked before parsing astro config.)
|
|
51
|
+
2. Otherwise, attempt to locate and parse an astro config file (common locations: `site/astro.config.mjs`, `astro.config.mjs` at repo root, `apps/web/astro.config.mjs`). This is best-effort; the v0.3 behavior is preserved. Consumers with non-conventional layouts should supply `astro-base.json` rather than relying on parse.
|
|
52
|
+
3. If both fail, treat base as empty string.
|
|
50
53
|
|
|
51
54
|
6. For each route in `{{affected_routes}}` (or the crawl set, if `"all"`):
|
|
52
55
|
- Construct URL `http://localhost:{{preview_port}}<base><route>`.
|
package/skills/cloverleaf-qa.md
CHANGED
|
@@ -21,7 +21,12 @@ description: Run the QA agent on a task in the `qa` state (full pipeline only).
|
|
|
21
21
|
|
|
22
22
|
4. Load QA rules JSON:
|
|
23
23
|
```bash
|
|
24
|
-
|
|
24
|
+
# Consumer override takes precedence over the package default.
|
|
25
|
+
if [ -f "<repo_root>/.cloverleaf/config/qa-rules.json" ]; then
|
|
26
|
+
cat "<repo_root>/.cloverleaf/config/qa-rules.json"
|
|
27
|
+
else
|
|
28
|
+
cat ~/.claude/plugins/cloverleaf/config/qa-rules.json
|
|
29
|
+
fi
|
|
25
30
|
```
|
|
26
31
|
Capture for the subagent as `qa_rules`.
|
|
27
32
|
|