@lifeaitools/rdc-skills 0.8.7
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/settings.json +15 -0
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +11 -0
- package/.github/workflows/publish.yml +25 -0
- package/.github/workflows/self-test.yml +53 -0
- package/CHANGELOG.md +246 -0
- package/LICENSE +21 -0
- package/MANIFEST.md +190 -0
- package/README.md +188 -0
- package/README.sandbox.md +3 -0
- package/assets/watcher/viewer.html +164 -0
- package/commands/build.md +183 -0
- package/commands/collab.md +180 -0
- package/commands/deploy.md +138 -0
- package/commands/fixit.md +112 -0
- package/commands/handoff.md +173 -0
- package/commands/help.md +88 -0
- package/commands/overnight.md +220 -0
- package/commands/plan.md +158 -0
- package/commands/preplan.md +131 -0
- package/commands/prototype.md +145 -0
- package/commands/release.md +159 -0
- package/commands/report.md +99 -0
- package/commands/review.md +120 -0
- package/commands/self-test.md +107 -0
- package/commands/status.md +86 -0
- package/commands/watch.md +92 -0
- package/commands/workitems.md +132 -0
- package/guides/.gitkeep +0 -0
- package/guides/agent-bootstrap.md +191 -0
- package/guides/agents/backend.md +104 -0
- package/guides/agents/content.md +94 -0
- package/guides/agents/cs2.md +56 -0
- package/guides/agents/data.md +87 -0
- package/guides/agents/design.md +77 -0
- package/guides/agents/frontend.md +92 -0
- package/guides/agents/infrastructure.md +81 -0
- package/guides/agents/setup.md +279 -0
- package/guides/agents/verify.md +132 -0
- package/guides/agents/viz.md +106 -0
- package/guides/backend.md +146 -0
- package/guides/content.md +147 -0
- package/guides/cs2.md +190 -0
- package/guides/data.md +123 -0
- package/guides/design.md +116 -0
- package/guides/frontend.md +151 -0
- package/guides/infrastructure.md +179 -0
- package/guides/output-contract.md +98 -0
- package/hooks/no-stop-open-epics.js +125 -0
- package/package.json +31 -0
- package/rules/work-items-rpc.md +399 -0
- package/scripts/install-rdc-skills.js +559 -0
- package/scripts/install.ps1 +165 -0
- package/scripts/install.sh +132 -0
- package/scripts/lib/assertions.mjs +264 -0
- package/scripts/lib/manifest-schema.mjs +607 -0
- package/scripts/lib/runner.mjs +429 -0
- package/scripts/lib/sandbox.mjs +435 -0
- package/scripts/self-test.mjs +1108 -0
- package/scripts/uninstall.ps1 +77 -0
- package/scripts/uninstall.sh +69 -0
- package/scripts/update.ps1 +43 -0
- package/scripts/update.sh +43 -0
- package/scripts/watch-init.mjs +100 -0
- package/skills/.gitkeep +0 -0
- package/skills/build/SKILL.md +238 -0
- package/skills/collab/SKILL.md +218 -0
- package/skills/deploy/SKILL.md +144 -0
- package/skills/fixit/SKILL.md +112 -0
- package/skills/handoff/SKILL.md +175 -0
- package/skills/help/SKILL.md +101 -0
- package/skills/overnight/SKILL.md +220 -0
- package/skills/plan/SKILL.md +96 -0
- package/skills/preplan/SKILL.md +87 -0
- package/skills/prototype/SKILL.md +151 -0
- package/skills/release/SKILL.md +221 -0
- package/skills/report/SKILL.md +101 -0
- package/skills/review/SKILL.md +120 -0
- package/skills/self-test/SKILL.md +127 -0
- package/skills/status/SKILL.md +86 -0
- package/skills/tests/README.md +29 -0
- package/skills/tests/rdc-build.test.json +22 -0
- package/skills/tests/rdc-deploy.test.json +15 -0
- package/skills/tests/rdc-fixit.test.json +21 -0
- package/skills/tests/rdc-handoff.test.json +14 -0
- package/skills/tests/rdc-overnight.test.json +21 -0
- package/skills/tests/rdc-plan.test.json +14 -0
- package/skills/tests/rdc-preplan.test.json +15 -0
- package/skills/tests/rdc-prototype.test.json +14 -0
- package/skills/tests/rdc-release.test.json +15 -0
- package/skills/tests/rdc-report.test.json +14 -0
- package/skills/tests/rdc-review.test.json +14 -0
- package/skills/tests/rdc-status.test.json +16 -0
- package/skills/tests/rdc-workitems.test.json +15 -0
- package/skills/watch/SKILL.md +92 -0
- package/skills/workitems/SKILL.md +147 -0
- package/tests/validate-skills.js +183 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// manifest-schema.mjs — Tier 2 test manifest schema + validator.
|
|
2
|
+
//
|
|
3
|
+
// Source of truth: .rdc/plans/skill-self-test-tier-2.md decision D3.
|
|
4
|
+
//
|
|
5
|
+
// A manifest describes one behavioral test for an rdc:* skill. The Tier 2
|
|
6
|
+
// runner loads a manifest, validates it here, then uses it to drive a headless
|
|
7
|
+
// Claude invocation inside a sandbox and assert on real artifacts.
|
|
8
|
+
//
|
|
9
|
+
// Exports:
|
|
10
|
+
// MANIFEST_VERSION — currently 1
|
|
11
|
+
// validateManifest(obj) → { ok, manifest, errors, warnings }
|
|
12
|
+
// loadManifest(filepath) → same shape, with io/parse errors
|
|
13
|
+
// loadAllManifests(dir?) → [{ file, ok, manifest?, errors?, warnings? }]
|
|
14
|
+
//
|
|
15
|
+
// Error shape: { path, code, msg }. `ok` is false iff errors.length > 0.
|
|
16
|
+
// Warnings (e.g. unknown-field) do not fail the manifest.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
19
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
export const MANIFEST_VERSION = 1;
|
|
23
|
+
|
|
24
|
+
const VALID_STATUSES = new Set([
|
|
25
|
+
"todo",
|
|
26
|
+
"in_progress",
|
|
27
|
+
"blocked",
|
|
28
|
+
"review",
|
|
29
|
+
"done",
|
|
30
|
+
"archived",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const TOP_LEVEL_FIELDS = new Set([
|
|
34
|
+
"manifest_version",
|
|
35
|
+
"skill",
|
|
36
|
+
"description",
|
|
37
|
+
"fixture",
|
|
38
|
+
"assertions",
|
|
39
|
+
"teardown",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const FIXTURE_FIELDS = new Set(["prompt", "precondition_files", "env", "slow"]);
|
|
43
|
+
|
|
44
|
+
const ASSERTION_FIELDS = new Set([
|
|
45
|
+
"exit_code",
|
|
46
|
+
"work_items_created",
|
|
47
|
+
"files_modified",
|
|
48
|
+
"commits_made",
|
|
49
|
+
"stderr_empty",
|
|
50
|
+
"stdout_contains",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const WIC_FIELDS = new Set(["min", "max", "status", "labels_include"]);
|
|
54
|
+
const COMMITS_FIELDS = new Set(["min", "message_matches"]);
|
|
55
|
+
const TEARDOWN_FIELDS = new Set(["reset_branch"]);
|
|
56
|
+
|
|
57
|
+
const SKILL_RE = /^rdc:[a-z][a-z0-9-]*$/;
|
|
58
|
+
|
|
59
|
+
function isPlainObject(v) {
|
|
60
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isInt(v) {
|
|
64
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRelativePath(p) {
|
|
68
|
+
if (typeof p !== "string" || p.length === 0) return false;
|
|
69
|
+
if (p.startsWith("/") || p.startsWith("\\")) return false;
|
|
70
|
+
if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
|
|
71
|
+
// Split on either slash and reject any '..' segment
|
|
72
|
+
const parts = p.split(/[\\/]/);
|
|
73
|
+
if (parts.some((seg) => seg === "..")) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function err(errors, path, code, msg) {
|
|
78
|
+
errors.push({ path, code, msg });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function warn(warnings, path, code, msg) {
|
|
82
|
+
warnings.push({ path, code, msg });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateFixture(fx, errors, warnings) {
|
|
86
|
+
if (!isPlainObject(fx)) {
|
|
87
|
+
err(errors, "fixture", "type", "fixture must be an object");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// prompt
|
|
91
|
+
if (typeof fx.prompt !== "string" || fx.prompt.trim().length === 0) {
|
|
92
|
+
err(errors, "fixture.prompt", "missing", "fixture.prompt must be a non-empty string");
|
|
93
|
+
} else if (!fx.prompt.startsWith("rdc:")) {
|
|
94
|
+
err(
|
|
95
|
+
errors,
|
|
96
|
+
"fixture.prompt",
|
|
97
|
+
"prompt-not-rdc",
|
|
98
|
+
`fixture.prompt must start with "rdc:" (got "${fx.prompt.slice(0, 20)}...")`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// precondition_files (optional, default [])
|
|
103
|
+
if (fx.precondition_files !== undefined) {
|
|
104
|
+
if (!Array.isArray(fx.precondition_files)) {
|
|
105
|
+
err(
|
|
106
|
+
errors,
|
|
107
|
+
"fixture.precondition_files",
|
|
108
|
+
"type",
|
|
109
|
+
"precondition_files must be an array",
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
fx.precondition_files.forEach((pf, i) => {
|
|
113
|
+
const p = `fixture.precondition_files[${i}]`;
|
|
114
|
+
if (!isPlainObject(pf)) {
|
|
115
|
+
err(errors, p, "type", "precondition_file must be an object");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (typeof pf.path !== "string" || pf.path.length === 0) {
|
|
119
|
+
err(errors, `${p}.path`, "missing", "path must be a non-empty string");
|
|
120
|
+
} else if (!isRelativePath(pf.path)) {
|
|
121
|
+
err(
|
|
122
|
+
errors,
|
|
123
|
+
`${p}.path`,
|
|
124
|
+
"path-not-relative",
|
|
125
|
+
`path "${pf.path}" must be relative (no leading /, no .., no drive letter)`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
if (typeof pf.content !== "string") {
|
|
129
|
+
err(errors, `${p}.content`, "missing", "content must be a string");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// env
|
|
136
|
+
if (!isPlainObject(fx.env)) {
|
|
137
|
+
err(errors, "fixture.env", "missing", "fixture.env must be an object");
|
|
138
|
+
} else {
|
|
139
|
+
if (fx.env.RDC_TEST !== "1") {
|
|
140
|
+
err(
|
|
141
|
+
errors,
|
|
142
|
+
"fixture.env.RDC_TEST",
|
|
143
|
+
"rdc-test-not-set",
|
|
144
|
+
`fixture.env.RDC_TEST must be the literal string "1" (sandbox contract)`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
for (const [k, v] of Object.entries(fx.env)) {
|
|
148
|
+
if (typeof v !== "string") {
|
|
149
|
+
err(
|
|
150
|
+
errors,
|
|
151
|
+
`fixture.env.${k}`,
|
|
152
|
+
"type",
|
|
153
|
+
`env var "${k}" must be a string (got ${typeof v})`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// unknown fields in fixture
|
|
160
|
+
for (const k of Object.keys(fx)) {
|
|
161
|
+
if (!FIXTURE_FIELDS.has(k)) {
|
|
162
|
+
warn(warnings, `fixture.${k}`, "unknown-field", `unknown fixture field "${k}"`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function validateWorkItems(wic, errors, warnings) {
|
|
168
|
+
if (!isPlainObject(wic)) {
|
|
169
|
+
err(errors, "assertions.work_items_created", "type", "must be an object");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let min, max;
|
|
173
|
+
if (wic.min !== undefined) {
|
|
174
|
+
if (!isInt(wic.min) || wic.min < 0) {
|
|
175
|
+
err(
|
|
176
|
+
errors,
|
|
177
|
+
"assertions.work_items_created.min",
|
|
178
|
+
"type",
|
|
179
|
+
"min must be an integer >= 0",
|
|
180
|
+
);
|
|
181
|
+
} else {
|
|
182
|
+
min = wic.min;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (wic.max !== undefined) {
|
|
186
|
+
if (!isInt(wic.max) || wic.max < 0) {
|
|
187
|
+
err(
|
|
188
|
+
errors,
|
|
189
|
+
"assertions.work_items_created.max",
|
|
190
|
+
"type",
|
|
191
|
+
"max must be an integer >= 0",
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
max = wic.max;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
198
|
+
err(
|
|
199
|
+
errors,
|
|
200
|
+
"assertions.work_items_created",
|
|
201
|
+
"range-invalid",
|
|
202
|
+
`min (${min}) must be <= max (${max})`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (wic.status !== undefined) {
|
|
206
|
+
if (typeof wic.status !== "string" || !VALID_STATUSES.has(wic.status)) {
|
|
207
|
+
err(
|
|
208
|
+
errors,
|
|
209
|
+
"assertions.work_items_created.status",
|
|
210
|
+
"status-invalid",
|
|
211
|
+
`status must be one of: ${[...VALID_STATUSES].join(", ")}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (wic.labels_include !== undefined) {
|
|
216
|
+
if (!Array.isArray(wic.labels_include)) {
|
|
217
|
+
err(
|
|
218
|
+
errors,
|
|
219
|
+
"assertions.work_items_created.labels_include",
|
|
220
|
+
"type",
|
|
221
|
+
"labels_include must be an array",
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
wic.labels_include.forEach((lbl, i) => {
|
|
225
|
+
if (typeof lbl !== "string") {
|
|
226
|
+
err(
|
|
227
|
+
errors,
|
|
228
|
+
`assertions.work_items_created.labels_include[${i}]`,
|
|
229
|
+
"type",
|
|
230
|
+
"label must be a string",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const k of Object.keys(wic)) {
|
|
237
|
+
if (!WIC_FIELDS.has(k)) {
|
|
238
|
+
warn(
|
|
239
|
+
warnings,
|
|
240
|
+
`assertions.work_items_created.${k}`,
|
|
241
|
+
"unknown-field",
|
|
242
|
+
`unknown field "${k}"`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function validateCommits(cm, errors, warnings) {
|
|
249
|
+
if (!isPlainObject(cm)) {
|
|
250
|
+
err(errors, "assertions.commits_made", "type", "must be an object");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (cm.min !== undefined && (!isInt(cm.min) || cm.min < 0)) {
|
|
254
|
+
err(errors, "assertions.commits_made.min", "type", "min must be an integer >= 0");
|
|
255
|
+
}
|
|
256
|
+
if (cm.message_matches !== undefined) {
|
|
257
|
+
if (typeof cm.message_matches !== "string") {
|
|
258
|
+
err(
|
|
259
|
+
errors,
|
|
260
|
+
"assertions.commits_made.message_matches",
|
|
261
|
+
"type",
|
|
262
|
+
"message_matches must be a string (regex source)",
|
|
263
|
+
);
|
|
264
|
+
} else {
|
|
265
|
+
try {
|
|
266
|
+
new RegExp(cm.message_matches);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
err(
|
|
269
|
+
errors,
|
|
270
|
+
"assertions.commits_made.message_matches",
|
|
271
|
+
"invalid-regex",
|
|
272
|
+
`invalid regex: ${e.message}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const k of Object.keys(cm)) {
|
|
278
|
+
if (!COMMITS_FIELDS.has(k)) {
|
|
279
|
+
warn(warnings, `assertions.commits_made.${k}`, "unknown-field", `unknown field "${k}"`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function validateAssertions(a, errors, warnings) {
|
|
285
|
+
if (!isPlainObject(a)) {
|
|
286
|
+
err(errors, "assertions", "type", "assertions must be an object");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (Object.keys(a).length === 0) {
|
|
290
|
+
warn(
|
|
291
|
+
warnings,
|
|
292
|
+
"assertions",
|
|
293
|
+
"empty-assertions",
|
|
294
|
+
"assertions object is empty — test will always pass",
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
if (a.exit_code !== undefined) {
|
|
298
|
+
if (!isInt(a.exit_code) || a.exit_code < 0) {
|
|
299
|
+
err(errors, "assertions.exit_code", "type", "exit_code must be an integer >= 0");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (a.work_items_created !== undefined) {
|
|
303
|
+
validateWorkItems(a.work_items_created, errors, warnings);
|
|
304
|
+
}
|
|
305
|
+
if (a.files_modified !== undefined) {
|
|
306
|
+
if (!Array.isArray(a.files_modified)) {
|
|
307
|
+
err(errors, "assertions.files_modified", "type", "files_modified must be an array");
|
|
308
|
+
} else {
|
|
309
|
+
a.files_modified.forEach((p, i) => {
|
|
310
|
+
if (typeof p !== "string") {
|
|
311
|
+
err(errors, `assertions.files_modified[${i}]`, "type", "path must be a string");
|
|
312
|
+
} else if (!isRelativePath(p)) {
|
|
313
|
+
err(
|
|
314
|
+
errors,
|
|
315
|
+
`assertions.files_modified[${i}]`,
|
|
316
|
+
"path-not-relative",
|
|
317
|
+
`path "${p}" must be relative`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (a.commits_made !== undefined) {
|
|
324
|
+
validateCommits(a.commits_made, errors, warnings);
|
|
325
|
+
}
|
|
326
|
+
if (a.stderr_empty !== undefined && typeof a.stderr_empty !== "boolean") {
|
|
327
|
+
err(errors, "assertions.stderr_empty", "type", "stderr_empty must be a boolean");
|
|
328
|
+
}
|
|
329
|
+
if (a.stdout_contains !== undefined) {
|
|
330
|
+
if (!Array.isArray(a.stdout_contains)) {
|
|
331
|
+
err(errors, "assertions.stdout_contains", "type", "stdout_contains must be an array");
|
|
332
|
+
} else {
|
|
333
|
+
a.stdout_contains.forEach((s, i) => {
|
|
334
|
+
if (typeof s !== "string") {
|
|
335
|
+
err(errors, `assertions.stdout_contains[${i}]`, "type", "entry must be a string");
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
for (const k of Object.keys(a)) {
|
|
341
|
+
if (!ASSERTION_FIELDS.has(k)) {
|
|
342
|
+
warn(warnings, `assertions.${k}`, "unknown-field", `unknown assertion "${k}"`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function validateTeardown(t, errors, warnings) {
|
|
348
|
+
if (!isPlainObject(t)) {
|
|
349
|
+
err(errors, "teardown", "type", "teardown must be an object");
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (t.reset_branch !== undefined && typeof t.reset_branch !== "boolean") {
|
|
353
|
+
err(errors, "teardown.reset_branch", "type", "reset_branch must be a boolean");
|
|
354
|
+
}
|
|
355
|
+
for (const k of Object.keys(t)) {
|
|
356
|
+
if (!TEARDOWN_FIELDS.has(k)) {
|
|
357
|
+
warn(warnings, `teardown.${k}`, "unknown-field", `unknown teardown field "${k}"`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export function validateManifest(raw) {
|
|
363
|
+
const errors = [];
|
|
364
|
+
const warnings = [];
|
|
365
|
+
|
|
366
|
+
if (!isPlainObject(raw)) {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
manifest: null,
|
|
370
|
+
errors: [{ path: "", code: "type", msg: "manifest must be a JSON object" }],
|
|
371
|
+
warnings: [],
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// manifest_version
|
|
376
|
+
if (raw.manifest_version === undefined) {
|
|
377
|
+
err(errors, "manifest_version", "missing", "manifest_version is required");
|
|
378
|
+
} else if (!isInt(raw.manifest_version) || raw.manifest_version !== MANIFEST_VERSION) {
|
|
379
|
+
err(
|
|
380
|
+
errors,
|
|
381
|
+
"manifest_version",
|
|
382
|
+
"version-unsupported",
|
|
383
|
+
`manifest_version must be ${MANIFEST_VERSION} (got ${JSON.stringify(raw.manifest_version)})`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// skill
|
|
388
|
+
if (raw.skill === undefined) {
|
|
389
|
+
err(errors, "skill", "missing", "skill is required");
|
|
390
|
+
} else if (typeof raw.skill !== "string" || !SKILL_RE.test(raw.skill)) {
|
|
391
|
+
err(
|
|
392
|
+
errors,
|
|
393
|
+
"skill",
|
|
394
|
+
"skill-invalid",
|
|
395
|
+
`skill must match /^rdc:[a-z][a-z0-9-]*$/ (got ${JSON.stringify(raw.skill)})`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// description
|
|
400
|
+
if (raw.description === undefined) {
|
|
401
|
+
err(errors, "description", "missing", "description is required");
|
|
402
|
+
} else if (typeof raw.description !== "string" || raw.description.trim().length === 0) {
|
|
403
|
+
err(errors, "description", "type", "description must be a non-empty string");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// fixture
|
|
407
|
+
if (raw.fixture === undefined) {
|
|
408
|
+
err(errors, "fixture", "missing", "fixture is required");
|
|
409
|
+
} else {
|
|
410
|
+
validateFixture(raw.fixture, errors, warnings);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// assertions
|
|
414
|
+
if (raw.assertions === undefined) {
|
|
415
|
+
err(errors, "assertions", "missing", "assertions is required");
|
|
416
|
+
} else {
|
|
417
|
+
validateAssertions(raw.assertions, errors, warnings);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// teardown (optional)
|
|
421
|
+
if (raw.teardown !== undefined) {
|
|
422
|
+
validateTeardown(raw.teardown, errors, warnings);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// unknown top-level fields → warning
|
|
426
|
+
for (const k of Object.keys(raw)) {
|
|
427
|
+
if (!TOP_LEVEL_FIELDS.has(k)) {
|
|
428
|
+
warn(warnings, k, "unknown-field", `unknown top-level field "${k}"`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const ok = errors.length === 0;
|
|
433
|
+
return { ok, manifest: ok ? raw : null, errors, warnings };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function loadManifest(filepath) {
|
|
437
|
+
let text;
|
|
438
|
+
try {
|
|
439
|
+
text = readFileSync(filepath, "utf8");
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
manifest: null,
|
|
444
|
+
errors: [{ path: "", code: "io", msg: `cannot read ${filepath}: ${e.message}` }],
|
|
445
|
+
warnings: [],
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
let parsed;
|
|
449
|
+
try {
|
|
450
|
+
parsed = JSON.parse(text);
|
|
451
|
+
} catch (e) {
|
|
452
|
+
return {
|
|
453
|
+
ok: false,
|
|
454
|
+
manifest: null,
|
|
455
|
+
errors: [{ path: "", code: "parse", msg: `invalid JSON in ${filepath}: ${e.message}` }],
|
|
456
|
+
warnings: [],
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
return validateManifest(parsed);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function loadAllManifests(dir) {
|
|
463
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
464
|
+
const REPO_ROOT = resolve(__dirname, "..", "..");
|
|
465
|
+
const targetDir = dir || join(REPO_ROOT, "skills", "tests");
|
|
466
|
+
if (!existsSync(targetDir)) return [];
|
|
467
|
+
let files;
|
|
468
|
+
try {
|
|
469
|
+
files = readdirSync(targetDir).filter((f) => f.endsWith(".test.json"));
|
|
470
|
+
} catch {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
return files.map((f) => {
|
|
474
|
+
const full = join(targetDir, f);
|
|
475
|
+
const res = loadManifest(full);
|
|
476
|
+
return { file: `skills/tests/${basename(full)}`, ...res };
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── Self-test ──────────────────────────────────────────────────────────────
|
|
481
|
+
const __isMain = (() => {
|
|
482
|
+
try {
|
|
483
|
+
return fileURLToPath(import.meta.url) === resolve(process.argv[1] || "");
|
|
484
|
+
} catch {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
})();
|
|
488
|
+
if (__isMain) {
|
|
489
|
+
const validMinimal = {
|
|
490
|
+
manifest_version: 1,
|
|
491
|
+
skill: "rdc:fixit",
|
|
492
|
+
description: "minimal valid",
|
|
493
|
+
fixture: {
|
|
494
|
+
prompt: "rdc:fixit fix typo",
|
|
495
|
+
env: { RDC_TEST: "1" },
|
|
496
|
+
},
|
|
497
|
+
assertions: { exit_code: 0 },
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const cases = [
|
|
501
|
+
{
|
|
502
|
+
n: 1,
|
|
503
|
+
desc: "valid minimal manifest",
|
|
504
|
+
input: validMinimal,
|
|
505
|
+
expect: (r) => r.ok && r.errors.length === 0 && r.warnings.length === 0,
|
|
506
|
+
expectStr: "ok=true, 0 errors, 0 warnings",
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
n: 2,
|
|
510
|
+
desc: "missing skill",
|
|
511
|
+
input: { ...validMinimal, skill: undefined },
|
|
512
|
+
expect: (r) =>
|
|
513
|
+
!r.ok && r.errors.some((e) => e.path === "skill" && e.code === "missing"),
|
|
514
|
+
expectStr: "error code=missing on skill",
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
n: 3,
|
|
518
|
+
desc: "manifest_version: 2",
|
|
519
|
+
input: { ...validMinimal, manifest_version: 2 },
|
|
520
|
+
expect: (r) =>
|
|
521
|
+
!r.ok && r.errors.some((e) => e.code === "version-unsupported"),
|
|
522
|
+
expectStr: "error code=version-unsupported",
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
n: 4,
|
|
526
|
+
desc: 'fixture.env.RDC_TEST: "0"',
|
|
527
|
+
input: {
|
|
528
|
+
...validMinimal,
|
|
529
|
+
fixture: { ...validMinimal.fixture, env: { RDC_TEST: "0" } },
|
|
530
|
+
},
|
|
531
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "rdc-test-not-set"),
|
|
532
|
+
expectStr: "error code=rdc-test-not-set",
|
|
533
|
+
},
|
|
534
|
+
{
|
|
535
|
+
n: 5,
|
|
536
|
+
desc: "invalid regex in commits_made.message_matches",
|
|
537
|
+
input: {
|
|
538
|
+
...validMinimal,
|
|
539
|
+
assertions: {
|
|
540
|
+
exit_code: 0,
|
|
541
|
+
commits_made: { min: 1, message_matches: "[unclosed" },
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "invalid-regex"),
|
|
545
|
+
expectStr: "error code=invalid-regex",
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
n: 6,
|
|
549
|
+
desc: "unknown top-level field `extra: 1`",
|
|
550
|
+
input: { ...validMinimal, extra: 1 },
|
|
551
|
+
expect: (r) =>
|
|
552
|
+
r.ok &&
|
|
553
|
+
r.warnings.length === 1 &&
|
|
554
|
+
r.warnings[0].code === "unknown-field" &&
|
|
555
|
+
r.warnings[0].path === "extra",
|
|
556
|
+
expectStr: "ok=true, 1 warning code=unknown-field",
|
|
557
|
+
},
|
|
558
|
+
{
|
|
559
|
+
n: 7,
|
|
560
|
+
desc: "work_items_created.min > max",
|
|
561
|
+
input: {
|
|
562
|
+
...validMinimal,
|
|
563
|
+
assertions: {
|
|
564
|
+
exit_code: 0,
|
|
565
|
+
work_items_created: { min: 5, max: 2 },
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "range-invalid"),
|
|
569
|
+
expectStr: "error code=range-invalid",
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
n: 8,
|
|
573
|
+
desc: 'files_modified: ["/abs/path"]',
|
|
574
|
+
input: {
|
|
575
|
+
...validMinimal,
|
|
576
|
+
assertions: { exit_code: 0, files_modified: ["/abs/path"] },
|
|
577
|
+
},
|
|
578
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "path-not-relative"),
|
|
579
|
+
expectStr: "error code=path-not-relative",
|
|
580
|
+
},
|
|
581
|
+
];
|
|
582
|
+
|
|
583
|
+
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
|
|
584
|
+
console.log("\nmanifest-schema self-test\n");
|
|
585
|
+
console.log(pad("#", 3) + pad("description", 44) + pad("expected", 36) + pad("actual", 36) + "result");
|
|
586
|
+
console.log("─".repeat(130));
|
|
587
|
+
|
|
588
|
+
let passed = 0;
|
|
589
|
+
for (const c of cases) {
|
|
590
|
+
const r = validateManifest(c.input);
|
|
591
|
+
const ok = c.expect(r);
|
|
592
|
+
const actual = `ok=${r.ok}, ${r.errors.length}e/${r.warnings.length}w${
|
|
593
|
+
r.errors[0] ? ` [${r.errors[0].code}]` : ""
|
|
594
|
+
}${r.warnings[0] && r.errors.length === 0 ? ` [${r.warnings[0].code}]` : ""}`;
|
|
595
|
+
console.log(
|
|
596
|
+
pad(c.n, 3) +
|
|
597
|
+
pad(c.desc, 44) +
|
|
598
|
+
pad(c.expectStr, 36) +
|
|
599
|
+
pad(actual, 36) +
|
|
600
|
+
(ok ? "PASS" : "FAIL"),
|
|
601
|
+
);
|
|
602
|
+
if (ok) passed++;
|
|
603
|
+
}
|
|
604
|
+
console.log("─".repeat(130));
|
|
605
|
+
console.log(`\n${passed}/${cases.length} passed\n`);
|
|
606
|
+
process.exit(passed === cases.length ? 0 : 1);
|
|
607
|
+
}
|