@lifeaitools/rdc-skills 0.24.41 → 0.25.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/settings.json +15 -15
- package/.claude-plugin/marketplace.json +21 -21
- package/.claude-plugin/plugin.json +1518 -1371
- package/.github/workflows/publish.yml +34 -34
- package/.github/workflows/self-test.yml +58 -58
- package/CHANGELOG.md +310 -310
- package/LICENSE +21 -21
- package/MANIFEST.md +221 -221
- package/README.md +375 -376
- package/README.sandbox.md +3 -3
- package/assets/watcher/viewer.html +164 -164
- package/bin/rdc-skills-mcp.mjs +316 -316
- package/commands/build.md +183 -183
- package/commands/collab.md +180 -180
- package/commands/deploy.md +152 -152
- package/commands/design.md +31 -31
- package/commands/edit.md +28 -28
- package/commands/fixit.md +150 -124
- package/commands/handoff.md +173 -173
- package/commands/help.md +95 -95
- package/commands/overnight.md +220 -220
- package/commands/plan.md +158 -158
- package/commands/preplan.md +131 -131
- package/commands/prototype.md +145 -145
- package/commands/release.md +49 -49
- package/commands/report.md +99 -99
- package/commands/review.md +120 -120
- package/commands/self-test.md +113 -113
- package/commands/status.md +86 -86
- package/commands/watch.md +98 -98
- package/commands/workitems.md +137 -137
- package/git-sha.json +1 -1
- package/guides/agent-bootstrap.md +295 -295
- package/guides/agents/backend.md +104 -104
- package/guides/agents/content.md +94 -94
- package/guides/agents/cs2.md +56 -56
- package/guides/agents/data.md +87 -87
- package/guides/agents/design.md +77 -77
- package/guides/agents/frontend.md +92 -92
- package/guides/agents/infrastructure.md +81 -81
- package/guides/agents/setup.md +281 -281
- package/guides/agents/verify.md +151 -151
- package/guides/agents/viz.md +106 -106
- package/guides/backend.md +146 -146
- package/guides/content.md +147 -147
- package/guides/cs2.md +190 -190
- package/guides/data.md +123 -123
- package/guides/design.md +116 -116
- package/guides/engineering-behavior.md +43 -43
- package/guides/escalation-protocol.md +125 -125
- package/guides/frontend.md +151 -151
- package/guides/history-md-spec.md +297 -297
- package/guides/infrastructure.md +179 -179
- package/guides/lessons-learned-spec.md +145 -151
- package/guides/output-contract.md +108 -108
- package/guides/publish-md-spec.md +289 -289
- package/guides/rdc-skills-startup.md +30 -30
- package/guides/verify.md +11 -11
- package/hooks/check-cwd.js +31 -31
- package/hooks/check-rdc-environment.js +164 -164
- package/hooks/check-services.js +6 -6
- package/hooks/check-stale-work-items.js +19 -19
- package/hooks/foreground-process-gate.js +128 -128
- package/hooks/gate-watchdog-selfcheck.js +257 -257
- package/hooks/hook-logger.js +25 -25
- package/hooks/lib/run-evidence-gate.mjs +241 -241
- package/hooks/no-stop-open-epics.js +127 -127
- package/hooks/post-tool-batch-gate.js +203 -203
- package/hooks/post-work-check.js +21 -21
- package/hooks/postcompact-log.js +13 -13
- package/hooks/precompact-log.js +13 -13
- package/hooks/rate-limit-retry.js +46 -46
- package/hooks/rdc-invocation-marker.js +157 -157
- package/hooks/rdc-output-contract-gate.js +94 -94
- package/hooks/require-work-item-on-commit.js +294 -294
- package/hooks/restart-brief.js +19 -19
- package/hooks/run-hidden-hook.ps1 +47 -47
- package/hooks/task-completed-gate.js +274 -274
- package/hooks/work-item-exit-gate.js +944 -944
- package/lib/catalog.mjs +236 -236
- package/lib/cloud-rewrite.mjs +155 -155
- package/package.json +57 -57
- package/rules/work-items-rpc.md +520 -520
- package/scaffold/templates/HISTORY.md.template +39 -39
- package/scaffold/templates/PUBLISH.md.template +21 -21
- package/scaffold/templates/brochure-studio-default.html +70 -70
- package/scripts/acceptance.mjs +502 -502
- package/scripts/fixtures/guides/bad-guide.md +15 -15
- package/scripts/fixtures/guides-clean/good-guide.md +16 -16
- package/scripts/install-rdc-skills.js +1289 -1289
- package/scripts/install.ps1 +202 -202
- package/scripts/install.sh +132 -132
- package/scripts/lib/assertions.mjs +287 -287
- package/scripts/lib/manifest-schema.mjs +754 -754
- package/scripts/lib/runner.mjs +465 -465
- package/scripts/lib/sandbox.mjs +435 -435
- package/scripts/prepack.mjs +32 -32
- package/scripts/rdc-brochure.mjs +482 -482
- package/scripts/rdc-design-cli.mjs +134 -134
- package/scripts/rebuild-mcp.mjs +107 -107
- package/scripts/self-test.mjs +1460 -1460
- package/scripts/stamp-git-sha.mjs +29 -29
- package/scripts/test-guide-validator.mjs +196 -196
- package/scripts/test-rdc-hooks.mjs +145 -145
- package/scripts/uninstall.ps1 +77 -77
- package/scripts/uninstall.sh +69 -69
- package/scripts/update.ps1 +43 -43
- package/scripts/update.sh +43 -43
- package/scripts/validate-place-histories.js +461 -461
- package/scripts/validate-publish-manifests.js +502 -424
- package/scripts/watch-init.mjs +100 -100
- package/skills/brochure/SKILL.md +107 -107
- package/skills/build/SKILL.md +563 -563
- package/skills/channel-formatter/SKILL.md +538 -533
- package/skills/co-develop/SKILL.md +196 -196
- package/skills/collab/SKILL.md +239 -239
- package/skills/convert/SKILL.md +167 -140
- package/skills/deploy/SKILL.md +541 -541
- package/skills/design/SKILL.md +211 -211
- package/skills/design/reference/ownership.md +16 -16
- package/skills/design/reference/rampa.md +92 -92
- package/skills/design/reference/studio-model.md +153 -153
- package/skills/edit/SKILL.md +98 -98
- package/skills/fixit/SKILL.md +203 -165
- package/skills/fs-mcp/SKILL.md +148 -148
- package/skills/handoff/SKILL.md +236 -236
- package/skills/help/SKILL.md +143 -143
- package/skills/housekeeping/SKILL.md +160 -219
- package/skills/lifeai-brochure-author/SKILL.md +340 -340
- package/skills/onramp/SKILL.md +248 -0
- package/skills/overnight/SKILL.md +251 -251
- package/skills/plan/SKILL.md +345 -345
- package/skills/preplan/SKILL.md +90 -90
- package/skills/prototype/SKILL.md +150 -150
- package/skills/rdc-brochurify/SKILL.md +245 -245
- package/skills/rdc-extract-verifier-rules/SKILL.md +191 -191
- package/skills/release/SKILL.md +140 -140
- package/skills/report/SKILL.md +100 -100
- package/skills/review/SKILL.md +152 -152
- package/skills/rpms-filemap/SKILL.cloud.md +111 -111
- package/skills/rpms-filemap/SKILL.md +111 -111
- package/skills/self-test/SKILL.md +132 -132
- package/skills/status/SKILL.md +99 -99
- package/skills/terminal-config/SKILL.md +62 -62
- package/skills/tests/MATRIX.md +54 -54
- package/skills/tests/README.md +47 -47
- package/skills/tests/onramp.test.json +87 -0
- package/skills/tests/rdc-brochure.test.json +34 -34
- package/skills/tests/rdc-build.test.json +36 -36
- package/skills/tests/rdc-channel-formatter.test.json +45 -45
- package/skills/tests/rdc-co-develop.test.json +29 -29
- package/skills/tests/rdc-collab.test.json +29 -29
- package/skills/tests/rdc-convert.test.json +35 -35
- package/skills/tests/rdc-deploy.test.json +30 -30
- package/skills/tests/rdc-design.test.json +27 -27
- package/skills/tests/rdc-edit.test.json +29 -29
- package/skills/tests/rdc-fixit.test.json +36 -36
- package/skills/tests/rdc-fs-mcp.test.json +36 -36
- package/skills/tests/rdc-handoff.test.json +28 -28
- package/skills/tests/rdc-help.test.json +29 -29
- package/skills/tests/rdc-housekeeping.test.json +28 -32
- package/skills/tests/rdc-lifeai-brochure-author.test.json +35 -35
- package/skills/tests/rdc-overnight.test.json +37 -37
- package/skills/tests/rdc-plan.test.json +27 -27
- package/skills/tests/rdc-preplan.test.json +31 -31
- package/skills/tests/rdc-prototype.test.json +28 -28
- package/skills/tests/rdc-rdc-brochurify.test.json +23 -23
- package/skills/tests/rdc-rdc-extract-verifier-rules.test.json +34 -34
- package/skills/tests/rdc-release.test.json +29 -29
- package/skills/tests/rdc-report.test.json +28 -28
- package/skills/tests/rdc-review.test.json +29 -29
- package/skills/tests/rdc-rpms-filemap.test.json +28 -28
- package/skills/tests/rdc-self-test.test.json +24 -24
- package/skills/tests/rdc-status.test.json +29 -29
- package/skills/tests/rdc-terminal-config.test.json +29 -29
- package/skills/tests/rdc-watch.test.json +24 -24
- package/skills/tests/rdc-workitems.test.json +27 -27
- package/skills/watch/SKILL.md +97 -97
- package/skills/workitems/SKILL.md +151 -151
- package/tests/acceptance.test.mjs +59 -59
- package/tests/channel-formatter.contract.test.mjs +251 -251
- package/tests/curl-surface.test.mjs +289 -289
- package/tests/harness-gates.test.mjs +325 -325
- package/tests/help-surface.test.mjs +61 -61
- package/tests/install-rdc-skills.test.mjs +49 -49
- package/tests/manifest-contract-fields.test.mjs +78 -78
- package/tests/mcp.test.mjs +271 -271
- package/tests/rdc-brochure.test.mjs +125 -125
- package/tests/require-work-item-on-commit.test.mjs +162 -162
- package/tests/run-evidence-gate.test.mjs +82 -82
- package/tests/skill-test-matrix.test.mjs +66 -66
- package/tests/validate-skills.js +27 -27
- package/tests/work-item-exit-gate-l2.test.mjs +368 -368
- package/tests/work-item-exit-gate-l3.test.mjs +197 -197
- package/RELEASE.md +0 -42
- package/tests/housekeeping-lessons-triage.test.mjs +0 -49
- package/tests/lessons-pipeline-contract.test.mjs +0 -26
- package/tests/release-contract.test.mjs +0 -16
|
@@ -1,754 +1,754 @@
|
|
|
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 installed 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
|
-
"acceptance",
|
|
40
|
-
"teardown",
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
const FIXTURE_FIELDS = new Set(["prompt", "precondition_files", "env", "slow"]);
|
|
44
|
-
|
|
45
|
-
const ASSERTION_FIELDS = new Set([
|
|
46
|
-
"exit_code",
|
|
47
|
-
"work_items_created",
|
|
48
|
-
"files_modified",
|
|
49
|
-
"commits_made",
|
|
50
|
-
"stderr_empty",
|
|
51
|
-
"stdout_contains",
|
|
52
|
-
"stdout_not_contains",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
const WIC_FIELDS = new Set(["min", "max", "status", "labels_include"]);
|
|
56
|
-
const COMMITS_FIELDS = new Set(["min", "message_matches"]);
|
|
57
|
-
const TEARDOWN_FIELDS = new Set(["reset_branch"]);
|
|
58
|
-
const ACCEPTANCE_FIELDS = new Set([
|
|
59
|
-
"output_contains",
|
|
60
|
-
"output_not_contains",
|
|
61
|
-
"tool_calls_include_any",
|
|
62
|
-
"tool_calls_include_all",
|
|
63
|
-
"tool_calls_argument_matches",
|
|
64
|
-
]);
|
|
65
|
-
const TOOL_CALL_MATCHER_FIELDS = new Set(["tools", "pattern"]);
|
|
66
|
-
|
|
67
|
-
const SKILL_RE = /^(rdc:)?[a-z][a-z0-9-]*$/;
|
|
68
|
-
|
|
69
|
-
function isPlainObject(v) {
|
|
70
|
-
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function isInt(v) {
|
|
74
|
-
return typeof v === "number" && Number.isInteger(v);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function isRelativePath(p) {
|
|
78
|
-
if (typeof p !== "string" || p.length === 0) return false;
|
|
79
|
-
if (p.startsWith("/") || p.startsWith("\\")) return false;
|
|
80
|
-
if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
|
|
81
|
-
// Split on either slash and reject any '..' segment
|
|
82
|
-
const parts = p.split(/[\\/]/);
|
|
83
|
-
if (parts.some((seg) => seg === "..")) return false;
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function err(errors, path, code, msg) {
|
|
88
|
-
errors.push({ path, code, msg });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function warn(warnings, path, code, msg) {
|
|
92
|
-
warnings.push({ path, code, msg });
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function validateFixture(fx, errors, warnings) {
|
|
96
|
-
if (!isPlainObject(fx)) {
|
|
97
|
-
err(errors, "fixture", "type", "fixture must be an object");
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
// prompt
|
|
101
|
-
if (typeof fx.prompt !== "string" || fx.prompt.trim().length === 0) {
|
|
102
|
-
err(errors, "fixture.prompt", "missing", "fixture.prompt must be a non-empty string");
|
|
103
|
-
} else if (!SKILL_RE.test(fx.prompt.split(/\s+/, 1)[0] || "")) {
|
|
104
|
-
err(
|
|
105
|
-
errors,
|
|
106
|
-
"fixture.prompt",
|
|
107
|
-
"prompt-not-skill",
|
|
108
|
-
`fixture.prompt must start with a valid skill name (got "${fx.prompt.slice(0, 20)}...")`,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// precondition_files (optional, default [])
|
|
113
|
-
if (fx.precondition_files !== undefined) {
|
|
114
|
-
if (!Array.isArray(fx.precondition_files)) {
|
|
115
|
-
err(
|
|
116
|
-
errors,
|
|
117
|
-
"fixture.precondition_files",
|
|
118
|
-
"type",
|
|
119
|
-
"precondition_files must be an array",
|
|
120
|
-
);
|
|
121
|
-
} else {
|
|
122
|
-
fx.precondition_files.forEach((pf, i) => {
|
|
123
|
-
const p = `fixture.precondition_files[${i}]`;
|
|
124
|
-
if (!isPlainObject(pf)) {
|
|
125
|
-
err(errors, p, "type", "precondition_file must be an object");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (typeof pf.path !== "string" || pf.path.length === 0) {
|
|
129
|
-
err(errors, `${p}.path`, "missing", "path must be a non-empty string");
|
|
130
|
-
} else if (!isRelativePath(pf.path)) {
|
|
131
|
-
err(
|
|
132
|
-
errors,
|
|
133
|
-
`${p}.path`,
|
|
134
|
-
"path-not-relative",
|
|
135
|
-
`path "${pf.path}" must be relative (no leading /, no .., no drive letter)`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
if (typeof pf.content !== "string") {
|
|
139
|
-
err(errors, `${p}.content`, "missing", "content must be a string");
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// env
|
|
146
|
-
if (!isPlainObject(fx.env)) {
|
|
147
|
-
err(errors, "fixture.env", "missing", "fixture.env must be an object");
|
|
148
|
-
} else {
|
|
149
|
-
if (fx.env.RDC_TEST !== "1") {
|
|
150
|
-
err(
|
|
151
|
-
errors,
|
|
152
|
-
"fixture.env.RDC_TEST",
|
|
153
|
-
"rdc-test-not-set",
|
|
154
|
-
`fixture.env.RDC_TEST must be the literal string "1" (sandbox contract)`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
for (const [k, v] of Object.entries(fx.env)) {
|
|
158
|
-
if (typeof v !== "string") {
|
|
159
|
-
err(
|
|
160
|
-
errors,
|
|
161
|
-
`fixture.env.${k}`,
|
|
162
|
-
"type",
|
|
163
|
-
`env var "${k}" must be a string (got ${typeof v})`,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// unknown fields in fixture
|
|
170
|
-
for (const k of Object.keys(fx)) {
|
|
171
|
-
if (!FIXTURE_FIELDS.has(k)) {
|
|
172
|
-
warn(warnings, `fixture.${k}`, "unknown-field", `unknown fixture field "${k}"`);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function validateWorkItems(wic, errors, warnings) {
|
|
178
|
-
if (!isPlainObject(wic)) {
|
|
179
|
-
err(errors, "assertions.work_items_created", "type", "must be an object");
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
|
-
let min, max;
|
|
183
|
-
if (wic.min !== undefined) {
|
|
184
|
-
if (!isInt(wic.min) || wic.min < 0) {
|
|
185
|
-
err(
|
|
186
|
-
errors,
|
|
187
|
-
"assertions.work_items_created.min",
|
|
188
|
-
"type",
|
|
189
|
-
"min must be an integer >= 0",
|
|
190
|
-
);
|
|
191
|
-
} else {
|
|
192
|
-
min = wic.min;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
if (wic.max !== undefined) {
|
|
196
|
-
if (!isInt(wic.max) || wic.max < 0) {
|
|
197
|
-
err(
|
|
198
|
-
errors,
|
|
199
|
-
"assertions.work_items_created.max",
|
|
200
|
-
"type",
|
|
201
|
-
"max must be an integer >= 0",
|
|
202
|
-
);
|
|
203
|
-
} else {
|
|
204
|
-
max = wic.max;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
if (min !== undefined && max !== undefined && min > max) {
|
|
208
|
-
err(
|
|
209
|
-
errors,
|
|
210
|
-
"assertions.work_items_created",
|
|
211
|
-
"range-invalid",
|
|
212
|
-
`min (${min}) must be <= max (${max})`,
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
if (wic.status !== undefined) {
|
|
216
|
-
if (typeof wic.status !== "string" || !VALID_STATUSES.has(wic.status)) {
|
|
217
|
-
err(
|
|
218
|
-
errors,
|
|
219
|
-
"assertions.work_items_created.status",
|
|
220
|
-
"status-invalid",
|
|
221
|
-
`status must be one of: ${[...VALID_STATUSES].join(", ")}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (wic.labels_include !== undefined) {
|
|
226
|
-
if (!Array.isArray(wic.labels_include)) {
|
|
227
|
-
err(
|
|
228
|
-
errors,
|
|
229
|
-
"assertions.work_items_created.labels_include",
|
|
230
|
-
"type",
|
|
231
|
-
"labels_include must be an array",
|
|
232
|
-
);
|
|
233
|
-
} else {
|
|
234
|
-
wic.labels_include.forEach((lbl, i) => {
|
|
235
|
-
if (typeof lbl !== "string") {
|
|
236
|
-
err(
|
|
237
|
-
errors,
|
|
238
|
-
`assertions.work_items_created.labels_include[${i}]`,
|
|
239
|
-
"type",
|
|
240
|
-
"label must be a string",
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
for (const k of Object.keys(wic)) {
|
|
247
|
-
if (!WIC_FIELDS.has(k)) {
|
|
248
|
-
warn(
|
|
249
|
-
warnings,
|
|
250
|
-
`assertions.work_items_created.${k}`,
|
|
251
|
-
"unknown-field",
|
|
252
|
-
`unknown field "${k}"`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function validateCommits(cm, errors, warnings) {
|
|
259
|
-
if (!isPlainObject(cm)) {
|
|
260
|
-
err(errors, "assertions.commits_made", "type", "must be an object");
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
if (cm.min !== undefined && (!isInt(cm.min) || cm.min < 0)) {
|
|
264
|
-
err(errors, "assertions.commits_made.min", "type", "min must be an integer >= 0");
|
|
265
|
-
}
|
|
266
|
-
if (cm.message_matches !== undefined) {
|
|
267
|
-
if (typeof cm.message_matches !== "string") {
|
|
268
|
-
err(
|
|
269
|
-
errors,
|
|
270
|
-
"assertions.commits_made.message_matches",
|
|
271
|
-
"type",
|
|
272
|
-
"message_matches must be a string (regex source)",
|
|
273
|
-
);
|
|
274
|
-
} else {
|
|
275
|
-
try {
|
|
276
|
-
new RegExp(cm.message_matches);
|
|
277
|
-
} catch (e) {
|
|
278
|
-
err(
|
|
279
|
-
errors,
|
|
280
|
-
"assertions.commits_made.message_matches",
|
|
281
|
-
"invalid-regex",
|
|
282
|
-
`invalid regex: ${e.message}`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
for (const k of Object.keys(cm)) {
|
|
288
|
-
if (!COMMITS_FIELDS.has(k)) {
|
|
289
|
-
warn(warnings, `assertions.commits_made.${k}`, "unknown-field", `unknown field "${k}"`);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function validateAssertions(a, errors, warnings) {
|
|
295
|
-
if (!isPlainObject(a)) {
|
|
296
|
-
err(errors, "assertions", "type", "assertions must be an object");
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
if (Object.keys(a).length === 0) {
|
|
300
|
-
warn(
|
|
301
|
-
warnings,
|
|
302
|
-
"assertions",
|
|
303
|
-
"empty-assertions",
|
|
304
|
-
"assertions object is empty — test will always pass",
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
if (a.exit_code !== undefined) {
|
|
308
|
-
if (!isInt(a.exit_code) || a.exit_code < 0) {
|
|
309
|
-
err(errors, "assertions.exit_code", "type", "exit_code must be an integer >= 0");
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
if (a.work_items_created !== undefined) {
|
|
313
|
-
validateWorkItems(a.work_items_created, errors, warnings);
|
|
314
|
-
}
|
|
315
|
-
if (a.files_modified !== undefined) {
|
|
316
|
-
if (!Array.isArray(a.files_modified)) {
|
|
317
|
-
err(errors, "assertions.files_modified", "type", "files_modified must be an array");
|
|
318
|
-
} else {
|
|
319
|
-
a.files_modified.forEach((p, i) => {
|
|
320
|
-
if (typeof p !== "string") {
|
|
321
|
-
err(errors, `assertions.files_modified[${i}]`, "type", "path must be a string");
|
|
322
|
-
} else if (!isRelativePath(p)) {
|
|
323
|
-
err(
|
|
324
|
-
errors,
|
|
325
|
-
`assertions.files_modified[${i}]`,
|
|
326
|
-
"path-not-relative",
|
|
327
|
-
`path "${p}" must be relative`,
|
|
328
|
-
);
|
|
329
|
-
}
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
if (a.commits_made !== undefined) {
|
|
334
|
-
validateCommits(a.commits_made, errors, warnings);
|
|
335
|
-
}
|
|
336
|
-
if (a.stderr_empty !== undefined && typeof a.stderr_empty !== "boolean") {
|
|
337
|
-
err(errors, "assertions.stderr_empty", "type", "stderr_empty must be a boolean");
|
|
338
|
-
}
|
|
339
|
-
if (a.stdout_contains !== undefined) {
|
|
340
|
-
if (!Array.isArray(a.stdout_contains)) {
|
|
341
|
-
err(errors, "assertions.stdout_contains", "type", "stdout_contains must be an array");
|
|
342
|
-
} else {
|
|
343
|
-
a.stdout_contains.forEach((s, i) => {
|
|
344
|
-
if (typeof s !== "string") {
|
|
345
|
-
err(errors, `assertions.stdout_contains[${i}]`, "type", "entry must be a string");
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
if (a.stdout_not_contains !== undefined) {
|
|
351
|
-
if (!Array.isArray(a.stdout_not_contains)) {
|
|
352
|
-
err(errors, "assertions.stdout_not_contains", "type", "stdout_not_contains must be an array");
|
|
353
|
-
} else {
|
|
354
|
-
a.stdout_not_contains.forEach((s, i) => {
|
|
355
|
-
if (typeof s !== "string") {
|
|
356
|
-
err(errors, `assertions.stdout_not_contains[${i}]`, "type", "entry must be a string");
|
|
357
|
-
}
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
for (const k of Object.keys(a)) {
|
|
362
|
-
if (!ASSERTION_FIELDS.has(k)) {
|
|
363
|
-
warn(warnings, `assertions.${k}`, "unknown-field", `unknown assertion "${k}"`);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
function validateTeardown(t, errors, warnings) {
|
|
369
|
-
if (!isPlainObject(t)) {
|
|
370
|
-
err(errors, "teardown", "type", "teardown must be an object");
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
if (t.reset_branch !== undefined && typeof t.reset_branch !== "boolean") {
|
|
374
|
-
err(errors, "teardown.reset_branch", "type", "reset_branch must be a boolean");
|
|
375
|
-
}
|
|
376
|
-
for (const k of Object.keys(t)) {
|
|
377
|
-
if (!TEARDOWN_FIELDS.has(k)) {
|
|
378
|
-
warn(warnings, `teardown.${k}`, "unknown-field", `unknown teardown field "${k}"`);
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
function validateStringArray(value, path, errors) {
|
|
384
|
-
if (!Array.isArray(value)) {
|
|
385
|
-
err(errors, path, "type", `${path} must be an array`);
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
value.forEach((s, i) => {
|
|
389
|
-
if (typeof s !== "string" || s.length === 0) {
|
|
390
|
-
err(errors, `${path}[${i}]`, "type", "entry must be a non-empty string");
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function validateAcceptance(a, errors, warnings) {
|
|
396
|
-
if (!isPlainObject(a)) {
|
|
397
|
-
err(errors, "acceptance", "type", "acceptance must be an object");
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (a.output_contains !== undefined) {
|
|
402
|
-
validateStringArray(a.output_contains, "acceptance.output_contains", errors);
|
|
403
|
-
}
|
|
404
|
-
if (a.output_not_contains !== undefined) {
|
|
405
|
-
validateStringArray(a.output_not_contains, "acceptance.output_not_contains", errors);
|
|
406
|
-
}
|
|
407
|
-
if (a.tool_calls_include_any !== undefined) {
|
|
408
|
-
validateStringArray(a.tool_calls_include_any, "acceptance.tool_calls_include_any", errors);
|
|
409
|
-
}
|
|
410
|
-
if (a.tool_calls_include_all !== undefined) {
|
|
411
|
-
validateStringArray(a.tool_calls_include_all, "acceptance.tool_calls_include_all", errors);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (a.tool_calls_argument_matches !== undefined) {
|
|
415
|
-
if (!Array.isArray(a.tool_calls_argument_matches)) {
|
|
416
|
-
err(
|
|
417
|
-
errors,
|
|
418
|
-
"acceptance.tool_calls_argument_matches",
|
|
419
|
-
"type",
|
|
420
|
-
"tool_calls_argument_matches must be an array",
|
|
421
|
-
);
|
|
422
|
-
} else {
|
|
423
|
-
a.tool_calls_argument_matches.forEach((matcher, i) => {
|
|
424
|
-
const p = `acceptance.tool_calls_argument_matches[${i}]`;
|
|
425
|
-
if (!isPlainObject(matcher)) {
|
|
426
|
-
err(errors, p, "type", "matcher must be an object");
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
validateStringArray(matcher.tools, `${p}.tools`, errors);
|
|
430
|
-
if (typeof matcher.pattern !== "string" || matcher.pattern.length === 0) {
|
|
431
|
-
err(errors, `${p}.pattern`, "type", "pattern must be a non-empty regex string");
|
|
432
|
-
} else {
|
|
433
|
-
try {
|
|
434
|
-
new RegExp(matcher.pattern);
|
|
435
|
-
} catch (e) {
|
|
436
|
-
err(errors, `${p}.pattern`, "invalid-regex", `invalid regex: ${e.message}`);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
for (const k of Object.keys(matcher)) {
|
|
440
|
-
if (!TOOL_CALL_MATCHER_FIELDS.has(k)) {
|
|
441
|
-
warn(warnings, `${p}.${k}`, "unknown-field", `unknown matcher field "${k}"`);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
});
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
for (const k of Object.keys(a)) {
|
|
449
|
-
if (!ACCEPTANCE_FIELDS.has(k)) {
|
|
450
|
-
warn(warnings, `acceptance.${k}`, "unknown-field", `unknown acceptance field "${k}"`);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export function validateManifest(raw) {
|
|
456
|
-
const errors = [];
|
|
457
|
-
const warnings = [];
|
|
458
|
-
|
|
459
|
-
if (!isPlainObject(raw)) {
|
|
460
|
-
return {
|
|
461
|
-
ok: false,
|
|
462
|
-
manifest: null,
|
|
463
|
-
errors: [{ path: "", code: "type", msg: "manifest must be a JSON object" }],
|
|
464
|
-
warnings: [],
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// manifest_version
|
|
469
|
-
if (raw.manifest_version === undefined) {
|
|
470
|
-
err(errors, "manifest_version", "missing", "manifest_version is required");
|
|
471
|
-
} else if (!isInt(raw.manifest_version) || raw.manifest_version !== MANIFEST_VERSION) {
|
|
472
|
-
err(
|
|
473
|
-
errors,
|
|
474
|
-
"manifest_version",
|
|
475
|
-
"version-unsupported",
|
|
476
|
-
`manifest_version must be ${MANIFEST_VERSION} (got ${JSON.stringify(raw.manifest_version)})`,
|
|
477
|
-
);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// skill
|
|
481
|
-
if (raw.skill === undefined) {
|
|
482
|
-
err(errors, "skill", "missing", "skill is required");
|
|
483
|
-
} else if (typeof raw.skill !== "string" || !SKILL_RE.test(raw.skill)) {
|
|
484
|
-
err(
|
|
485
|
-
errors,
|
|
486
|
-
"skill",
|
|
487
|
-
"skill-invalid",
|
|
488
|
-
`skill must be a slash skill like "rdc:build" or bare skill like "lifeai-brochure-author" (got ${JSON.stringify(raw.skill)})`,
|
|
489
|
-
);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// description
|
|
493
|
-
if (raw.description === undefined) {
|
|
494
|
-
err(errors, "description", "missing", "description is required");
|
|
495
|
-
} else if (typeof raw.description !== "string" || raw.description.trim().length === 0) {
|
|
496
|
-
err(errors, "description", "type", "description must be a non-empty string");
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// fixture
|
|
500
|
-
if (raw.fixture === undefined) {
|
|
501
|
-
err(errors, "fixture", "missing", "fixture is required");
|
|
502
|
-
} else {
|
|
503
|
-
validateFixture(raw.fixture, errors, warnings);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// assertions
|
|
507
|
-
if (raw.assertions === undefined) {
|
|
508
|
-
err(errors, "assertions", "missing", "assertions is required");
|
|
509
|
-
} else {
|
|
510
|
-
validateAssertions(raw.assertions, errors, warnings);
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (raw.acceptance !== undefined) {
|
|
514
|
-
validateAcceptance(raw.acceptance, errors, warnings);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
// teardown (optional)
|
|
518
|
-
if (raw.teardown !== undefined) {
|
|
519
|
-
validateTeardown(raw.teardown, errors, warnings);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// unknown top-level fields → warning
|
|
523
|
-
for (const k of Object.keys(raw)) {
|
|
524
|
-
if (!TOP_LEVEL_FIELDS.has(k)) {
|
|
525
|
-
warn(warnings, k, "unknown-field", `unknown top-level field "${k}"`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
const ok = errors.length === 0;
|
|
530
|
-
return { ok, manifest: ok ? raw : null, errors, warnings };
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export function loadManifest(filepath) {
|
|
534
|
-
let text;
|
|
535
|
-
try {
|
|
536
|
-
text = readFileSync(filepath, "utf8");
|
|
537
|
-
} catch (e) {
|
|
538
|
-
return {
|
|
539
|
-
ok: false,
|
|
540
|
-
manifest: null,
|
|
541
|
-
errors: [{ path: "", code: "io", msg: `cannot read ${filepath}: ${e.message}` }],
|
|
542
|
-
warnings: [],
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
let parsed;
|
|
546
|
-
try {
|
|
547
|
-
parsed = JSON.parse(text);
|
|
548
|
-
} catch (e) {
|
|
549
|
-
return {
|
|
550
|
-
ok: false,
|
|
551
|
-
manifest: null,
|
|
552
|
-
errors: [{ path: "", code: "parse", msg: `invalid JSON in ${filepath}: ${e.message}` }],
|
|
553
|
-
warnings: [],
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
return validateManifest(parsed);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
export function loadAllManifests(dir) {
|
|
560
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
561
|
-
const REPO_ROOT = resolve(__dirname, "..", "..");
|
|
562
|
-
const targetDir = dir || join(REPO_ROOT, "skills", "tests");
|
|
563
|
-
if (!existsSync(targetDir)) return [];
|
|
564
|
-
let files;
|
|
565
|
-
try {
|
|
566
|
-
files = readdirSync(targetDir).filter((f) => f.endsWith(".test.json"));
|
|
567
|
-
} catch {
|
|
568
|
-
return [];
|
|
569
|
-
}
|
|
570
|
-
return files.map((f) => {
|
|
571
|
-
const full = join(targetDir, f);
|
|
572
|
-
const res = loadManifest(full);
|
|
573
|
-
return { file: `skills/tests/${basename(full)}`, ...res };
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ─── Self-test ──────────────────────────────────────────────────────────────
|
|
578
|
-
const __isMain = (() => {
|
|
579
|
-
try {
|
|
580
|
-
return fileURLToPath(import.meta.url) === resolve(process.argv[1] || "");
|
|
581
|
-
} catch {
|
|
582
|
-
return false;
|
|
583
|
-
}
|
|
584
|
-
})();
|
|
585
|
-
if (__isMain) {
|
|
586
|
-
const validMinimal = {
|
|
587
|
-
manifest_version: 1,
|
|
588
|
-
skill: "rdc:fixit",
|
|
589
|
-
description: "minimal valid",
|
|
590
|
-
fixture: {
|
|
591
|
-
prompt: "rdc:fixit fix typo",
|
|
592
|
-
env: { RDC_TEST: "1" },
|
|
593
|
-
},
|
|
594
|
-
assertions: { exit_code: 0 },
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
const cases = [
|
|
598
|
-
{
|
|
599
|
-
n: 1,
|
|
600
|
-
desc: "valid minimal manifest",
|
|
601
|
-
input: validMinimal,
|
|
602
|
-
expect: (r) => r.ok && r.errors.length === 0 && r.warnings.length === 0,
|
|
603
|
-
expectStr: "ok=true, 0 errors, 0 warnings",
|
|
604
|
-
},
|
|
605
|
-
{
|
|
606
|
-
n: 2,
|
|
607
|
-
desc: "missing skill",
|
|
608
|
-
input: { ...validMinimal, skill: undefined },
|
|
609
|
-
expect: (r) =>
|
|
610
|
-
!r.ok && r.errors.some((e) => e.path === "skill" && e.code === "missing"),
|
|
611
|
-
expectStr: "error code=missing on skill",
|
|
612
|
-
},
|
|
613
|
-
{
|
|
614
|
-
n: 3,
|
|
615
|
-
desc: "manifest_version: 2",
|
|
616
|
-
input: { ...validMinimal, manifest_version: 2 },
|
|
617
|
-
expect: (r) =>
|
|
618
|
-
!r.ok && r.errors.some((e) => e.code === "version-unsupported"),
|
|
619
|
-
expectStr: "error code=version-unsupported",
|
|
620
|
-
},
|
|
621
|
-
{
|
|
622
|
-
n: 4,
|
|
623
|
-
desc: 'fixture.env.RDC_TEST: "0"',
|
|
624
|
-
input: {
|
|
625
|
-
...validMinimal,
|
|
626
|
-
fixture: { ...validMinimal.fixture, env: { RDC_TEST: "0" } },
|
|
627
|
-
},
|
|
628
|
-
expect: (r) => !r.ok && r.errors.some((e) => e.code === "rdc-test-not-set"),
|
|
629
|
-
expectStr: "error code=rdc-test-not-set",
|
|
630
|
-
},
|
|
631
|
-
{
|
|
632
|
-
n: 4.5,
|
|
633
|
-
desc: "valid bare installed skill",
|
|
634
|
-
input: {
|
|
635
|
-
...validMinimal,
|
|
636
|
-
skill: "lifeai-brochure-author",
|
|
637
|
-
fixture: { ...validMinimal.fixture, prompt: "lifeai-brochure-author review fixture" },
|
|
638
|
-
},
|
|
639
|
-
expect: (r) => r.ok && r.errors.length === 0,
|
|
640
|
-
expectStr: "ok=true, 0 errors",
|
|
641
|
-
},
|
|
642
|
-
{
|
|
643
|
-
n: 5,
|
|
644
|
-
desc: "invalid regex in commits_made.message_matches",
|
|
645
|
-
input: {
|
|
646
|
-
...validMinimal,
|
|
647
|
-
assertions: {
|
|
648
|
-
exit_code: 0,
|
|
649
|
-
commits_made: { min: 1, message_matches: "[unclosed" },
|
|
650
|
-
},
|
|
651
|
-
},
|
|
652
|
-
expect: (r) => !r.ok && r.errors.some((e) => e.code === "invalid-regex"),
|
|
653
|
-
expectStr: "error code=invalid-regex",
|
|
654
|
-
},
|
|
655
|
-
{
|
|
656
|
-
n: 6,
|
|
657
|
-
desc: "unknown top-level field `extra: 1`",
|
|
658
|
-
input: { ...validMinimal, extra: 1 },
|
|
659
|
-
expect: (r) =>
|
|
660
|
-
r.ok &&
|
|
661
|
-
r.warnings.length === 1 &&
|
|
662
|
-
r.warnings[0].code === "unknown-field" &&
|
|
663
|
-
r.warnings[0].path === "extra",
|
|
664
|
-
expectStr: "ok=true, 1 warning code=unknown-field",
|
|
665
|
-
},
|
|
666
|
-
{
|
|
667
|
-
n: 7,
|
|
668
|
-
desc: "work_items_created.min > max",
|
|
669
|
-
input: {
|
|
670
|
-
...validMinimal,
|
|
671
|
-
assertions: {
|
|
672
|
-
exit_code: 0,
|
|
673
|
-
work_items_created: { min: 5, max: 2 },
|
|
674
|
-
},
|
|
675
|
-
},
|
|
676
|
-
expect: (r) => !r.ok && r.errors.some((e) => e.code === "range-invalid"),
|
|
677
|
-
expectStr: "error code=range-invalid",
|
|
678
|
-
},
|
|
679
|
-
{
|
|
680
|
-
n: 8,
|
|
681
|
-
desc: 'files_modified: ["/abs/path"]',
|
|
682
|
-
input: {
|
|
683
|
-
...validMinimal,
|
|
684
|
-
assertions: { exit_code: 0, files_modified: ["/abs/path"] },
|
|
685
|
-
},
|
|
686
|
-
expect: (r) => !r.ok && r.errors.some((e) => e.code === "path-not-relative"),
|
|
687
|
-
expectStr: "error code=path-not-relative",
|
|
688
|
-
},
|
|
689
|
-
{
|
|
690
|
-
n: 9,
|
|
691
|
-
desc: "valid acceptance tool matcher",
|
|
692
|
-
input: {
|
|
693
|
-
...validMinimal,
|
|
694
|
-
acceptance: {
|
|
695
|
-
output_contains: ["PASS"],
|
|
696
|
-
output_not_contains: ["fabricated"],
|
|
697
|
-
tool_calls_include_any: ["Grep", "Glob"],
|
|
698
|
-
tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "global-corpus|web-search" }],
|
|
699
|
-
},
|
|
700
|
-
},
|
|
701
|
-
expect: (r) => r.ok && r.errors.length === 0,
|
|
702
|
-
expectStr: "ok=true, 0 errors",
|
|
703
|
-
},
|
|
704
|
-
{
|
|
705
|
-
n: 10,
|
|
706
|
-
desc: "invalid acceptance matcher regex",
|
|
707
|
-
input: {
|
|
708
|
-
...validMinimal,
|
|
709
|
-
acceptance: {
|
|
710
|
-
tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "[unclosed" }],
|
|
711
|
-
},
|
|
712
|
-
},
|
|
713
|
-
expect: (r) => !r.ok && r.errors.some((e) => e.path.endsWith(".pattern") && e.code === "invalid-regex"),
|
|
714
|
-
expectStr: "error code=invalid-regex",
|
|
715
|
-
},
|
|
716
|
-
{
|
|
717
|
-
n: 11,
|
|
718
|
-
desc: "unknown acceptance field",
|
|
719
|
-
input: {
|
|
720
|
-
...validMinimal,
|
|
721
|
-
acceptance: { made_up: true },
|
|
722
|
-
},
|
|
723
|
-
expect: (r) =>
|
|
724
|
-
r.ok &&
|
|
725
|
-
r.warnings.some((w) => w.path === "acceptance.made_up" && w.code === "unknown-field"),
|
|
726
|
-
expectStr: "ok=true, warning code=unknown-field",
|
|
727
|
-
},
|
|
728
|
-
];
|
|
729
|
-
|
|
730
|
-
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
|
|
731
|
-
console.log("\nmanifest-schema self-test\n");
|
|
732
|
-
console.log(pad("#", 3) + pad("description", 44) + pad("expected", 36) + pad("actual", 36) + "result");
|
|
733
|
-
console.log("─".repeat(130));
|
|
734
|
-
|
|
735
|
-
let passed = 0;
|
|
736
|
-
for (const c of cases) {
|
|
737
|
-
const r = validateManifest(c.input);
|
|
738
|
-
const ok = c.expect(r);
|
|
739
|
-
const actual = `ok=${r.ok}, ${r.errors.length}e/${r.warnings.length}w${
|
|
740
|
-
r.errors[0] ? ` [${r.errors[0].code}]` : ""
|
|
741
|
-
}${r.warnings[0] && r.errors.length === 0 ? ` [${r.warnings[0].code}]` : ""}`;
|
|
742
|
-
console.log(
|
|
743
|
-
pad(c.n, 3) +
|
|
744
|
-
pad(c.desc, 44) +
|
|
745
|
-
pad(c.expectStr, 36) +
|
|
746
|
-
pad(actual, 36) +
|
|
747
|
-
(ok ? "PASS" : "FAIL"),
|
|
748
|
-
);
|
|
749
|
-
if (ok) passed++;
|
|
750
|
-
}
|
|
751
|
-
console.log("─".repeat(130));
|
|
752
|
-
console.log(`\n${passed}/${cases.length} passed\n`);
|
|
753
|
-
process.exit(passed === cases.length ? 0 : 1);
|
|
754
|
-
}
|
|
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 installed 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
|
+
"acceptance",
|
|
40
|
+
"teardown",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const FIXTURE_FIELDS = new Set(["prompt", "precondition_files", "env", "slow"]);
|
|
44
|
+
|
|
45
|
+
const ASSERTION_FIELDS = new Set([
|
|
46
|
+
"exit_code",
|
|
47
|
+
"work_items_created",
|
|
48
|
+
"files_modified",
|
|
49
|
+
"commits_made",
|
|
50
|
+
"stderr_empty",
|
|
51
|
+
"stdout_contains",
|
|
52
|
+
"stdout_not_contains",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const WIC_FIELDS = new Set(["min", "max", "status", "labels_include"]);
|
|
56
|
+
const COMMITS_FIELDS = new Set(["min", "message_matches"]);
|
|
57
|
+
const TEARDOWN_FIELDS = new Set(["reset_branch"]);
|
|
58
|
+
const ACCEPTANCE_FIELDS = new Set([
|
|
59
|
+
"output_contains",
|
|
60
|
+
"output_not_contains",
|
|
61
|
+
"tool_calls_include_any",
|
|
62
|
+
"tool_calls_include_all",
|
|
63
|
+
"tool_calls_argument_matches",
|
|
64
|
+
]);
|
|
65
|
+
const TOOL_CALL_MATCHER_FIELDS = new Set(["tools", "pattern"]);
|
|
66
|
+
|
|
67
|
+
const SKILL_RE = /^(rdc:)?[a-z][a-z0-9-]*$/;
|
|
68
|
+
|
|
69
|
+
function isPlainObject(v) {
|
|
70
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isInt(v) {
|
|
74
|
+
return typeof v === "number" && Number.isInteger(v);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRelativePath(p) {
|
|
78
|
+
if (typeof p !== "string" || p.length === 0) return false;
|
|
79
|
+
if (p.startsWith("/") || p.startsWith("\\")) return false;
|
|
80
|
+
if (/^[a-zA-Z]:[\\/]/.test(p)) return false; // Windows drive letter
|
|
81
|
+
// Split on either slash and reject any '..' segment
|
|
82
|
+
const parts = p.split(/[\\/]/);
|
|
83
|
+
if (parts.some((seg) => seg === "..")) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function err(errors, path, code, msg) {
|
|
88
|
+
errors.push({ path, code, msg });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function warn(warnings, path, code, msg) {
|
|
92
|
+
warnings.push({ path, code, msg });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function validateFixture(fx, errors, warnings) {
|
|
96
|
+
if (!isPlainObject(fx)) {
|
|
97
|
+
err(errors, "fixture", "type", "fixture must be an object");
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// prompt
|
|
101
|
+
if (typeof fx.prompt !== "string" || fx.prompt.trim().length === 0) {
|
|
102
|
+
err(errors, "fixture.prompt", "missing", "fixture.prompt must be a non-empty string");
|
|
103
|
+
} else if (!SKILL_RE.test(fx.prompt.split(/\s+/, 1)[0] || "")) {
|
|
104
|
+
err(
|
|
105
|
+
errors,
|
|
106
|
+
"fixture.prompt",
|
|
107
|
+
"prompt-not-skill",
|
|
108
|
+
`fixture.prompt must start with a valid skill name (got "${fx.prompt.slice(0, 20)}...")`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// precondition_files (optional, default [])
|
|
113
|
+
if (fx.precondition_files !== undefined) {
|
|
114
|
+
if (!Array.isArray(fx.precondition_files)) {
|
|
115
|
+
err(
|
|
116
|
+
errors,
|
|
117
|
+
"fixture.precondition_files",
|
|
118
|
+
"type",
|
|
119
|
+
"precondition_files must be an array",
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
fx.precondition_files.forEach((pf, i) => {
|
|
123
|
+
const p = `fixture.precondition_files[${i}]`;
|
|
124
|
+
if (!isPlainObject(pf)) {
|
|
125
|
+
err(errors, p, "type", "precondition_file must be an object");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (typeof pf.path !== "string" || pf.path.length === 0) {
|
|
129
|
+
err(errors, `${p}.path`, "missing", "path must be a non-empty string");
|
|
130
|
+
} else if (!isRelativePath(pf.path)) {
|
|
131
|
+
err(
|
|
132
|
+
errors,
|
|
133
|
+
`${p}.path`,
|
|
134
|
+
"path-not-relative",
|
|
135
|
+
`path "${pf.path}" must be relative (no leading /, no .., no drive letter)`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (typeof pf.content !== "string") {
|
|
139
|
+
err(errors, `${p}.content`, "missing", "content must be a string");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// env
|
|
146
|
+
if (!isPlainObject(fx.env)) {
|
|
147
|
+
err(errors, "fixture.env", "missing", "fixture.env must be an object");
|
|
148
|
+
} else {
|
|
149
|
+
if (fx.env.RDC_TEST !== "1") {
|
|
150
|
+
err(
|
|
151
|
+
errors,
|
|
152
|
+
"fixture.env.RDC_TEST",
|
|
153
|
+
"rdc-test-not-set",
|
|
154
|
+
`fixture.env.RDC_TEST must be the literal string "1" (sandbox contract)`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
for (const [k, v] of Object.entries(fx.env)) {
|
|
158
|
+
if (typeof v !== "string") {
|
|
159
|
+
err(
|
|
160
|
+
errors,
|
|
161
|
+
`fixture.env.${k}`,
|
|
162
|
+
"type",
|
|
163
|
+
`env var "${k}" must be a string (got ${typeof v})`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// unknown fields in fixture
|
|
170
|
+
for (const k of Object.keys(fx)) {
|
|
171
|
+
if (!FIXTURE_FIELDS.has(k)) {
|
|
172
|
+
warn(warnings, `fixture.${k}`, "unknown-field", `unknown fixture field "${k}"`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function validateWorkItems(wic, errors, warnings) {
|
|
178
|
+
if (!isPlainObject(wic)) {
|
|
179
|
+
err(errors, "assertions.work_items_created", "type", "must be an object");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
let min, max;
|
|
183
|
+
if (wic.min !== undefined) {
|
|
184
|
+
if (!isInt(wic.min) || wic.min < 0) {
|
|
185
|
+
err(
|
|
186
|
+
errors,
|
|
187
|
+
"assertions.work_items_created.min",
|
|
188
|
+
"type",
|
|
189
|
+
"min must be an integer >= 0",
|
|
190
|
+
);
|
|
191
|
+
} else {
|
|
192
|
+
min = wic.min;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (wic.max !== undefined) {
|
|
196
|
+
if (!isInt(wic.max) || wic.max < 0) {
|
|
197
|
+
err(
|
|
198
|
+
errors,
|
|
199
|
+
"assertions.work_items_created.max",
|
|
200
|
+
"type",
|
|
201
|
+
"max must be an integer >= 0",
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
max = wic.max;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (min !== undefined && max !== undefined && min > max) {
|
|
208
|
+
err(
|
|
209
|
+
errors,
|
|
210
|
+
"assertions.work_items_created",
|
|
211
|
+
"range-invalid",
|
|
212
|
+
`min (${min}) must be <= max (${max})`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (wic.status !== undefined) {
|
|
216
|
+
if (typeof wic.status !== "string" || !VALID_STATUSES.has(wic.status)) {
|
|
217
|
+
err(
|
|
218
|
+
errors,
|
|
219
|
+
"assertions.work_items_created.status",
|
|
220
|
+
"status-invalid",
|
|
221
|
+
`status must be one of: ${[...VALID_STATUSES].join(", ")}`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (wic.labels_include !== undefined) {
|
|
226
|
+
if (!Array.isArray(wic.labels_include)) {
|
|
227
|
+
err(
|
|
228
|
+
errors,
|
|
229
|
+
"assertions.work_items_created.labels_include",
|
|
230
|
+
"type",
|
|
231
|
+
"labels_include must be an array",
|
|
232
|
+
);
|
|
233
|
+
} else {
|
|
234
|
+
wic.labels_include.forEach((lbl, i) => {
|
|
235
|
+
if (typeof lbl !== "string") {
|
|
236
|
+
err(
|
|
237
|
+
errors,
|
|
238
|
+
`assertions.work_items_created.labels_include[${i}]`,
|
|
239
|
+
"type",
|
|
240
|
+
"label must be a string",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const k of Object.keys(wic)) {
|
|
247
|
+
if (!WIC_FIELDS.has(k)) {
|
|
248
|
+
warn(
|
|
249
|
+
warnings,
|
|
250
|
+
`assertions.work_items_created.${k}`,
|
|
251
|
+
"unknown-field",
|
|
252
|
+
`unknown field "${k}"`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function validateCommits(cm, errors, warnings) {
|
|
259
|
+
if (!isPlainObject(cm)) {
|
|
260
|
+
err(errors, "assertions.commits_made", "type", "must be an object");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (cm.min !== undefined && (!isInt(cm.min) || cm.min < 0)) {
|
|
264
|
+
err(errors, "assertions.commits_made.min", "type", "min must be an integer >= 0");
|
|
265
|
+
}
|
|
266
|
+
if (cm.message_matches !== undefined) {
|
|
267
|
+
if (typeof cm.message_matches !== "string") {
|
|
268
|
+
err(
|
|
269
|
+
errors,
|
|
270
|
+
"assertions.commits_made.message_matches",
|
|
271
|
+
"type",
|
|
272
|
+
"message_matches must be a string (regex source)",
|
|
273
|
+
);
|
|
274
|
+
} else {
|
|
275
|
+
try {
|
|
276
|
+
new RegExp(cm.message_matches);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
err(
|
|
279
|
+
errors,
|
|
280
|
+
"assertions.commits_made.message_matches",
|
|
281
|
+
"invalid-regex",
|
|
282
|
+
`invalid regex: ${e.message}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const k of Object.keys(cm)) {
|
|
288
|
+
if (!COMMITS_FIELDS.has(k)) {
|
|
289
|
+
warn(warnings, `assertions.commits_made.${k}`, "unknown-field", `unknown field "${k}"`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function validateAssertions(a, errors, warnings) {
|
|
295
|
+
if (!isPlainObject(a)) {
|
|
296
|
+
err(errors, "assertions", "type", "assertions must be an object");
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (Object.keys(a).length === 0) {
|
|
300
|
+
warn(
|
|
301
|
+
warnings,
|
|
302
|
+
"assertions",
|
|
303
|
+
"empty-assertions",
|
|
304
|
+
"assertions object is empty — test will always pass",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (a.exit_code !== undefined) {
|
|
308
|
+
if (!isInt(a.exit_code) || a.exit_code < 0) {
|
|
309
|
+
err(errors, "assertions.exit_code", "type", "exit_code must be an integer >= 0");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (a.work_items_created !== undefined) {
|
|
313
|
+
validateWorkItems(a.work_items_created, errors, warnings);
|
|
314
|
+
}
|
|
315
|
+
if (a.files_modified !== undefined) {
|
|
316
|
+
if (!Array.isArray(a.files_modified)) {
|
|
317
|
+
err(errors, "assertions.files_modified", "type", "files_modified must be an array");
|
|
318
|
+
} else {
|
|
319
|
+
a.files_modified.forEach((p, i) => {
|
|
320
|
+
if (typeof p !== "string") {
|
|
321
|
+
err(errors, `assertions.files_modified[${i}]`, "type", "path must be a string");
|
|
322
|
+
} else if (!isRelativePath(p)) {
|
|
323
|
+
err(
|
|
324
|
+
errors,
|
|
325
|
+
`assertions.files_modified[${i}]`,
|
|
326
|
+
"path-not-relative",
|
|
327
|
+
`path "${p}" must be relative`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (a.commits_made !== undefined) {
|
|
334
|
+
validateCommits(a.commits_made, errors, warnings);
|
|
335
|
+
}
|
|
336
|
+
if (a.stderr_empty !== undefined && typeof a.stderr_empty !== "boolean") {
|
|
337
|
+
err(errors, "assertions.stderr_empty", "type", "stderr_empty must be a boolean");
|
|
338
|
+
}
|
|
339
|
+
if (a.stdout_contains !== undefined) {
|
|
340
|
+
if (!Array.isArray(a.stdout_contains)) {
|
|
341
|
+
err(errors, "assertions.stdout_contains", "type", "stdout_contains must be an array");
|
|
342
|
+
} else {
|
|
343
|
+
a.stdout_contains.forEach((s, i) => {
|
|
344
|
+
if (typeof s !== "string") {
|
|
345
|
+
err(errors, `assertions.stdout_contains[${i}]`, "type", "entry must be a string");
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (a.stdout_not_contains !== undefined) {
|
|
351
|
+
if (!Array.isArray(a.stdout_not_contains)) {
|
|
352
|
+
err(errors, "assertions.stdout_not_contains", "type", "stdout_not_contains must be an array");
|
|
353
|
+
} else {
|
|
354
|
+
a.stdout_not_contains.forEach((s, i) => {
|
|
355
|
+
if (typeof s !== "string") {
|
|
356
|
+
err(errors, `assertions.stdout_not_contains[${i}]`, "type", "entry must be a string");
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
for (const k of Object.keys(a)) {
|
|
362
|
+
if (!ASSERTION_FIELDS.has(k)) {
|
|
363
|
+
warn(warnings, `assertions.${k}`, "unknown-field", `unknown assertion "${k}"`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function validateTeardown(t, errors, warnings) {
|
|
369
|
+
if (!isPlainObject(t)) {
|
|
370
|
+
err(errors, "teardown", "type", "teardown must be an object");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (t.reset_branch !== undefined && typeof t.reset_branch !== "boolean") {
|
|
374
|
+
err(errors, "teardown.reset_branch", "type", "reset_branch must be a boolean");
|
|
375
|
+
}
|
|
376
|
+
for (const k of Object.keys(t)) {
|
|
377
|
+
if (!TEARDOWN_FIELDS.has(k)) {
|
|
378
|
+
warn(warnings, `teardown.${k}`, "unknown-field", `unknown teardown field "${k}"`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function validateStringArray(value, path, errors) {
|
|
384
|
+
if (!Array.isArray(value)) {
|
|
385
|
+
err(errors, path, "type", `${path} must be an array`);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
value.forEach((s, i) => {
|
|
389
|
+
if (typeof s !== "string" || s.length === 0) {
|
|
390
|
+
err(errors, `${path}[${i}]`, "type", "entry must be a non-empty string");
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function validateAcceptance(a, errors, warnings) {
|
|
396
|
+
if (!isPlainObject(a)) {
|
|
397
|
+
err(errors, "acceptance", "type", "acceptance must be an object");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (a.output_contains !== undefined) {
|
|
402
|
+
validateStringArray(a.output_contains, "acceptance.output_contains", errors);
|
|
403
|
+
}
|
|
404
|
+
if (a.output_not_contains !== undefined) {
|
|
405
|
+
validateStringArray(a.output_not_contains, "acceptance.output_not_contains", errors);
|
|
406
|
+
}
|
|
407
|
+
if (a.tool_calls_include_any !== undefined) {
|
|
408
|
+
validateStringArray(a.tool_calls_include_any, "acceptance.tool_calls_include_any", errors);
|
|
409
|
+
}
|
|
410
|
+
if (a.tool_calls_include_all !== undefined) {
|
|
411
|
+
validateStringArray(a.tool_calls_include_all, "acceptance.tool_calls_include_all", errors);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (a.tool_calls_argument_matches !== undefined) {
|
|
415
|
+
if (!Array.isArray(a.tool_calls_argument_matches)) {
|
|
416
|
+
err(
|
|
417
|
+
errors,
|
|
418
|
+
"acceptance.tool_calls_argument_matches",
|
|
419
|
+
"type",
|
|
420
|
+
"tool_calls_argument_matches must be an array",
|
|
421
|
+
);
|
|
422
|
+
} else {
|
|
423
|
+
a.tool_calls_argument_matches.forEach((matcher, i) => {
|
|
424
|
+
const p = `acceptance.tool_calls_argument_matches[${i}]`;
|
|
425
|
+
if (!isPlainObject(matcher)) {
|
|
426
|
+
err(errors, p, "type", "matcher must be an object");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
validateStringArray(matcher.tools, `${p}.tools`, errors);
|
|
430
|
+
if (typeof matcher.pattern !== "string" || matcher.pattern.length === 0) {
|
|
431
|
+
err(errors, `${p}.pattern`, "type", "pattern must be a non-empty regex string");
|
|
432
|
+
} else {
|
|
433
|
+
try {
|
|
434
|
+
new RegExp(matcher.pattern);
|
|
435
|
+
} catch (e) {
|
|
436
|
+
err(errors, `${p}.pattern`, "invalid-regex", `invalid regex: ${e.message}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
for (const k of Object.keys(matcher)) {
|
|
440
|
+
if (!TOOL_CALL_MATCHER_FIELDS.has(k)) {
|
|
441
|
+
warn(warnings, `${p}.${k}`, "unknown-field", `unknown matcher field "${k}"`);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
for (const k of Object.keys(a)) {
|
|
449
|
+
if (!ACCEPTANCE_FIELDS.has(k)) {
|
|
450
|
+
warn(warnings, `acceptance.${k}`, "unknown-field", `unknown acceptance field "${k}"`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function validateManifest(raw) {
|
|
456
|
+
const errors = [];
|
|
457
|
+
const warnings = [];
|
|
458
|
+
|
|
459
|
+
if (!isPlainObject(raw)) {
|
|
460
|
+
return {
|
|
461
|
+
ok: false,
|
|
462
|
+
manifest: null,
|
|
463
|
+
errors: [{ path: "", code: "type", msg: "manifest must be a JSON object" }],
|
|
464
|
+
warnings: [],
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// manifest_version
|
|
469
|
+
if (raw.manifest_version === undefined) {
|
|
470
|
+
err(errors, "manifest_version", "missing", "manifest_version is required");
|
|
471
|
+
} else if (!isInt(raw.manifest_version) || raw.manifest_version !== MANIFEST_VERSION) {
|
|
472
|
+
err(
|
|
473
|
+
errors,
|
|
474
|
+
"manifest_version",
|
|
475
|
+
"version-unsupported",
|
|
476
|
+
`manifest_version must be ${MANIFEST_VERSION} (got ${JSON.stringify(raw.manifest_version)})`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// skill
|
|
481
|
+
if (raw.skill === undefined) {
|
|
482
|
+
err(errors, "skill", "missing", "skill is required");
|
|
483
|
+
} else if (typeof raw.skill !== "string" || !SKILL_RE.test(raw.skill)) {
|
|
484
|
+
err(
|
|
485
|
+
errors,
|
|
486
|
+
"skill",
|
|
487
|
+
"skill-invalid",
|
|
488
|
+
`skill must be a slash skill like "rdc:build" or bare skill like "lifeai-brochure-author" (got ${JSON.stringify(raw.skill)})`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// description
|
|
493
|
+
if (raw.description === undefined) {
|
|
494
|
+
err(errors, "description", "missing", "description is required");
|
|
495
|
+
} else if (typeof raw.description !== "string" || raw.description.trim().length === 0) {
|
|
496
|
+
err(errors, "description", "type", "description must be a non-empty string");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// fixture
|
|
500
|
+
if (raw.fixture === undefined) {
|
|
501
|
+
err(errors, "fixture", "missing", "fixture is required");
|
|
502
|
+
} else {
|
|
503
|
+
validateFixture(raw.fixture, errors, warnings);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// assertions
|
|
507
|
+
if (raw.assertions === undefined) {
|
|
508
|
+
err(errors, "assertions", "missing", "assertions is required");
|
|
509
|
+
} else {
|
|
510
|
+
validateAssertions(raw.assertions, errors, warnings);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (raw.acceptance !== undefined) {
|
|
514
|
+
validateAcceptance(raw.acceptance, errors, warnings);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// teardown (optional)
|
|
518
|
+
if (raw.teardown !== undefined) {
|
|
519
|
+
validateTeardown(raw.teardown, errors, warnings);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// unknown top-level fields → warning
|
|
523
|
+
for (const k of Object.keys(raw)) {
|
|
524
|
+
if (!TOP_LEVEL_FIELDS.has(k)) {
|
|
525
|
+
warn(warnings, k, "unknown-field", `unknown top-level field "${k}"`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const ok = errors.length === 0;
|
|
530
|
+
return { ok, manifest: ok ? raw : null, errors, warnings };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function loadManifest(filepath) {
|
|
534
|
+
let text;
|
|
535
|
+
try {
|
|
536
|
+
text = readFileSync(filepath, "utf8");
|
|
537
|
+
} catch (e) {
|
|
538
|
+
return {
|
|
539
|
+
ok: false,
|
|
540
|
+
manifest: null,
|
|
541
|
+
errors: [{ path: "", code: "io", msg: `cannot read ${filepath}: ${e.message}` }],
|
|
542
|
+
warnings: [],
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
let parsed;
|
|
546
|
+
try {
|
|
547
|
+
parsed = JSON.parse(text);
|
|
548
|
+
} catch (e) {
|
|
549
|
+
return {
|
|
550
|
+
ok: false,
|
|
551
|
+
manifest: null,
|
|
552
|
+
errors: [{ path: "", code: "parse", msg: `invalid JSON in ${filepath}: ${e.message}` }],
|
|
553
|
+
warnings: [],
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
return validateManifest(parsed);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export function loadAllManifests(dir) {
|
|
560
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
561
|
+
const REPO_ROOT = resolve(__dirname, "..", "..");
|
|
562
|
+
const targetDir = dir || join(REPO_ROOT, "skills", "tests");
|
|
563
|
+
if (!existsSync(targetDir)) return [];
|
|
564
|
+
let files;
|
|
565
|
+
try {
|
|
566
|
+
files = readdirSync(targetDir).filter((f) => f.endsWith(".test.json"));
|
|
567
|
+
} catch {
|
|
568
|
+
return [];
|
|
569
|
+
}
|
|
570
|
+
return files.map((f) => {
|
|
571
|
+
const full = join(targetDir, f);
|
|
572
|
+
const res = loadManifest(full);
|
|
573
|
+
return { file: `skills/tests/${basename(full)}`, ...res };
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ─── Self-test ──────────────────────────────────────────────────────────────
|
|
578
|
+
const __isMain = (() => {
|
|
579
|
+
try {
|
|
580
|
+
return fileURLToPath(import.meta.url) === resolve(process.argv[1] || "");
|
|
581
|
+
} catch {
|
|
582
|
+
return false;
|
|
583
|
+
}
|
|
584
|
+
})();
|
|
585
|
+
if (__isMain) {
|
|
586
|
+
const validMinimal = {
|
|
587
|
+
manifest_version: 1,
|
|
588
|
+
skill: "rdc:fixit",
|
|
589
|
+
description: "minimal valid",
|
|
590
|
+
fixture: {
|
|
591
|
+
prompt: "rdc:fixit fix typo",
|
|
592
|
+
env: { RDC_TEST: "1" },
|
|
593
|
+
},
|
|
594
|
+
assertions: { exit_code: 0 },
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const cases = [
|
|
598
|
+
{
|
|
599
|
+
n: 1,
|
|
600
|
+
desc: "valid minimal manifest",
|
|
601
|
+
input: validMinimal,
|
|
602
|
+
expect: (r) => r.ok && r.errors.length === 0 && r.warnings.length === 0,
|
|
603
|
+
expectStr: "ok=true, 0 errors, 0 warnings",
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
n: 2,
|
|
607
|
+
desc: "missing skill",
|
|
608
|
+
input: { ...validMinimal, skill: undefined },
|
|
609
|
+
expect: (r) =>
|
|
610
|
+
!r.ok && r.errors.some((e) => e.path === "skill" && e.code === "missing"),
|
|
611
|
+
expectStr: "error code=missing on skill",
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
n: 3,
|
|
615
|
+
desc: "manifest_version: 2",
|
|
616
|
+
input: { ...validMinimal, manifest_version: 2 },
|
|
617
|
+
expect: (r) =>
|
|
618
|
+
!r.ok && r.errors.some((e) => e.code === "version-unsupported"),
|
|
619
|
+
expectStr: "error code=version-unsupported",
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
n: 4,
|
|
623
|
+
desc: 'fixture.env.RDC_TEST: "0"',
|
|
624
|
+
input: {
|
|
625
|
+
...validMinimal,
|
|
626
|
+
fixture: { ...validMinimal.fixture, env: { RDC_TEST: "0" } },
|
|
627
|
+
},
|
|
628
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "rdc-test-not-set"),
|
|
629
|
+
expectStr: "error code=rdc-test-not-set",
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
n: 4.5,
|
|
633
|
+
desc: "valid bare installed skill",
|
|
634
|
+
input: {
|
|
635
|
+
...validMinimal,
|
|
636
|
+
skill: "lifeai-brochure-author",
|
|
637
|
+
fixture: { ...validMinimal.fixture, prompt: "lifeai-brochure-author review fixture" },
|
|
638
|
+
},
|
|
639
|
+
expect: (r) => r.ok && r.errors.length === 0,
|
|
640
|
+
expectStr: "ok=true, 0 errors",
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
n: 5,
|
|
644
|
+
desc: "invalid regex in commits_made.message_matches",
|
|
645
|
+
input: {
|
|
646
|
+
...validMinimal,
|
|
647
|
+
assertions: {
|
|
648
|
+
exit_code: 0,
|
|
649
|
+
commits_made: { min: 1, message_matches: "[unclosed" },
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "invalid-regex"),
|
|
653
|
+
expectStr: "error code=invalid-regex",
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
n: 6,
|
|
657
|
+
desc: "unknown top-level field `extra: 1`",
|
|
658
|
+
input: { ...validMinimal, extra: 1 },
|
|
659
|
+
expect: (r) =>
|
|
660
|
+
r.ok &&
|
|
661
|
+
r.warnings.length === 1 &&
|
|
662
|
+
r.warnings[0].code === "unknown-field" &&
|
|
663
|
+
r.warnings[0].path === "extra",
|
|
664
|
+
expectStr: "ok=true, 1 warning code=unknown-field",
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
n: 7,
|
|
668
|
+
desc: "work_items_created.min > max",
|
|
669
|
+
input: {
|
|
670
|
+
...validMinimal,
|
|
671
|
+
assertions: {
|
|
672
|
+
exit_code: 0,
|
|
673
|
+
work_items_created: { min: 5, max: 2 },
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "range-invalid"),
|
|
677
|
+
expectStr: "error code=range-invalid",
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
n: 8,
|
|
681
|
+
desc: 'files_modified: ["/abs/path"]',
|
|
682
|
+
input: {
|
|
683
|
+
...validMinimal,
|
|
684
|
+
assertions: { exit_code: 0, files_modified: ["/abs/path"] },
|
|
685
|
+
},
|
|
686
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.code === "path-not-relative"),
|
|
687
|
+
expectStr: "error code=path-not-relative",
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
n: 9,
|
|
691
|
+
desc: "valid acceptance tool matcher",
|
|
692
|
+
input: {
|
|
693
|
+
...validMinimal,
|
|
694
|
+
acceptance: {
|
|
695
|
+
output_contains: ["PASS"],
|
|
696
|
+
output_not_contains: ["fabricated"],
|
|
697
|
+
tool_calls_include_any: ["Grep", "Glob"],
|
|
698
|
+
tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "global-corpus|web-search" }],
|
|
699
|
+
},
|
|
700
|
+
},
|
|
701
|
+
expect: (r) => r.ok && r.errors.length === 0,
|
|
702
|
+
expectStr: "ok=true, 0 errors",
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
n: 10,
|
|
706
|
+
desc: "invalid acceptance matcher regex",
|
|
707
|
+
input: {
|
|
708
|
+
...validMinimal,
|
|
709
|
+
acceptance: {
|
|
710
|
+
tool_calls_argument_matches: [{ tools: ["Grep"], pattern: "[unclosed" }],
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
expect: (r) => !r.ok && r.errors.some((e) => e.path.endsWith(".pattern") && e.code === "invalid-regex"),
|
|
714
|
+
expectStr: "error code=invalid-regex",
|
|
715
|
+
},
|
|
716
|
+
{
|
|
717
|
+
n: 11,
|
|
718
|
+
desc: "unknown acceptance field",
|
|
719
|
+
input: {
|
|
720
|
+
...validMinimal,
|
|
721
|
+
acceptance: { made_up: true },
|
|
722
|
+
},
|
|
723
|
+
expect: (r) =>
|
|
724
|
+
r.ok &&
|
|
725
|
+
r.warnings.some((w) => w.path === "acceptance.made_up" && w.code === "unknown-field"),
|
|
726
|
+
expectStr: "ok=true, warning code=unknown-field",
|
|
727
|
+
},
|
|
728
|
+
];
|
|
729
|
+
|
|
730
|
+
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - String(s).length));
|
|
731
|
+
console.log("\nmanifest-schema self-test\n");
|
|
732
|
+
console.log(pad("#", 3) + pad("description", 44) + pad("expected", 36) + pad("actual", 36) + "result");
|
|
733
|
+
console.log("─".repeat(130));
|
|
734
|
+
|
|
735
|
+
let passed = 0;
|
|
736
|
+
for (const c of cases) {
|
|
737
|
+
const r = validateManifest(c.input);
|
|
738
|
+
const ok = c.expect(r);
|
|
739
|
+
const actual = `ok=${r.ok}, ${r.errors.length}e/${r.warnings.length}w${
|
|
740
|
+
r.errors[0] ? ` [${r.errors[0].code}]` : ""
|
|
741
|
+
}${r.warnings[0] && r.errors.length === 0 ? ` [${r.warnings[0].code}]` : ""}`;
|
|
742
|
+
console.log(
|
|
743
|
+
pad(c.n, 3) +
|
|
744
|
+
pad(c.desc, 44) +
|
|
745
|
+
pad(c.expectStr, 36) +
|
|
746
|
+
pad(actual, 36) +
|
|
747
|
+
(ok ? "PASS" : "FAIL"),
|
|
748
|
+
);
|
|
749
|
+
if (ok) passed++;
|
|
750
|
+
}
|
|
751
|
+
console.log("─".repeat(130));
|
|
752
|
+
console.log(`\n${passed}/${cases.length} passed\n`);
|
|
753
|
+
process.exit(passed === cases.length ? 0 : 1);
|
|
754
|
+
}
|