@lariocpt/crust-mongo 0.0.0-stage → 0.3.6

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.
@@ -0,0 +1,134 @@
1
+ // The "install the agent skills?" question, asked once, right after the binary
2
+ // lands in the cache.
3
+ //
4
+ // WHY HERE. The skills are the difference between an agent that guesses at
5
+ // crust's grammar and one that gets it right on the first try, and a user who
6
+ // installs the package is exactly the moment that decision is live. Asking once,
7
+ // at install, beats documenting the verb in a README nobody re-reads.
8
+ //
9
+ // WHAT IT MUST NEVER DO. Break `npm install`. Every failure mode — no TTY, no
10
+ // binary, spawn refused, user typed noise — resolves to "declined", and the
11
+ // caller keeps exit 0. A postinstall that exits non-zero aborts the whole
12
+ // install, and "I did not want to answer a question" is not a reason to refuse
13
+ // to install a package.
14
+ //
15
+ // The gating is a separate pure function because it is the part with real blast
16
+ // radius: prompt in CI and the build hangs on a question nobody is there to
17
+ // answer; skip the cwd check and the skills land inside node_modules/crust.
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync, readdirSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ // Where the skills live. `.agents/skills` is the canonical, cross-client path;
23
+ // `.claude/skills` is where Claude Code looks, and crust keeps a per-skill link
24
+ // there. Both are checked for "already installed" so someone upgrading from a
25
+ // crust that wrote only the Claude path is not asked to start over.
26
+ const SKILLS_SUBDIR = ".agents/skills";
27
+ const OTHER_SKILL_SUBDIRS = [".claude/skills"];
28
+
29
+ /**
30
+ * Decide whether to ask, and where "this project" is.
31
+ *
32
+ * `INIT_CWD` is the directory `npm`/`bun`/`pnpm` were actually run from; the
33
+ * process cwd during a lifecycle script is the package directory
34
+ * (node_modules/crust), which is NOT where a project-scoped skill belongs.
35
+ */
36
+ export function shouldAsk({
37
+ env = {},
38
+ stdinTty = false,
39
+ stdoutTty = false,
40
+ dir = env.INIT_CWD,
41
+ } = {}) {
42
+ if (!stdinTty || !stdoutTty) return { ask: false, reason: "not an interactive terminal" };
43
+ // A CI runner has no human to answer, and `CI` is set by every mainstream one.
44
+ if (env.CI) return { ask: false, reason: "CI" };
45
+ if (env.CRUST_NO_SKILLS) return { ask: false, reason: "CRUST_NO_SKILLS is set" };
46
+ // A global install has no project to write into; suggest the global verb instead.
47
+ if (env.npm_config_global === "true" || env.npm_config_global === "1") {
48
+ return { ask: false, reason: "global install" };
49
+ }
50
+ // Nested install (crust pulled in as somebody's dependency): the skills are for
51
+ // the developer's own agent, not for the package that happens to depend on us.
52
+ if (!dir || dir.split(path.sep).includes("node_modules")) {
53
+ return { ask: false, reason: "not run from a project directory" };
54
+ }
55
+ if (!existsSync(dir)) return { ask: false, reason: "project directory is gone" };
56
+ const installedHere = [SKILLS_SUBDIR, ...OTHER_SKILL_SUBDIRS].some((sub) =>
57
+ hasCrustSkills(path.join(dir, sub)),
58
+ );
59
+ if (installedHere) {
60
+ return { ask: false, reason: "skills already installed" };
61
+ }
62
+ return { ask: true, dir };
63
+ }
64
+
65
+ /** Any `crust-*` skill already on disk counts as "this user has done this". */
66
+ function hasCrustSkills(skillsDir) {
67
+ let names;
68
+ try {
69
+ names = readdirSync(skillsDir);
70
+ } catch {
71
+ return false;
72
+ }
73
+ return names.some((n) => n.startsWith("crust-"));
74
+ }
75
+
76
+ /**
77
+ * Ask, and on yes hand the terminal to `crust skills install` so the picker —
78
+ * not this script — decides which skills land where.
79
+ *
80
+ * stdio is inherited precisely so the picker can take raw mode over the same
81
+ * terminal. Reading the answer here and then re-reading keystrokes there would
82
+ * otherwise fight over stdin.
83
+ */
84
+ export async function askSkills({ bin, env = {}, log = () => {}, cwd = env.INIT_CWD }) {
85
+ // No binary, no question: the picker lives inside it, and asking something we
86
+ // cannot act on turns a helpful prompt into a broken one.
87
+ if (!bin || !existsSync(bin)) return { asked: false, reason: "binary not available yet" };
88
+ const gate = shouldAsk({
89
+ env,
90
+ stdinTty: process.stdin.isTTY === true,
91
+ stdoutTty: process.stdout.isTTY === true,
92
+ dir: cwd,
93
+ });
94
+ if (!gate.ask) return { asked: false, reason: gate.reason };
95
+
96
+ log("");
97
+ log("crust ships agent skills — the SKILL.md guides Codex, Cursor, Claude Code");
98
+ log("and OpenCode read to drive crust correctly.");
99
+ log(`Install them into ${gate.dir}/.agents/skills? [y/N] `);
100
+
101
+ const answer = await readAnswer();
102
+ if (!/^y(es)?$/i.test(answer.trim())) {
103
+ log("skipped — `crust skills install` does it later (add --global for ~/.agents).");
104
+ log("to stop being asked: CRUST_NO_SKILLS=1");
105
+ return { asked: true, installed: false };
106
+ }
107
+ const code = await new Promise((resolve) => {
108
+ const child = spawn(bin, ["skills", "install"], { cwd: gate.dir, stdio: "inherit" });
109
+ child.on("error", () => resolve(-1));
110
+ child.on("close", (c) => resolve(c ?? -1));
111
+ });
112
+ return { asked: true, installed: code === 0, code };
113
+ }
114
+
115
+ /** One line from stdin. Resolves empty on EOF so a half-open pipe cannot hang. */
116
+ function readAnswer() {
117
+ return new Promise((resolve) => {
118
+ let buf = "";
119
+ const done = (value) => {
120
+ process.stdin.off("data", onData);
121
+ process.stdin.off("end", onEnd);
122
+ resolve(value);
123
+ };
124
+ const onData = (chunk) => {
125
+ buf += chunk.toString("utf8");
126
+ const nl = buf.indexOf("\n");
127
+ if (nl !== -1) done(buf.slice(0, nl));
128
+ };
129
+ const onEnd = () => done(buf);
130
+ process.stdin.on("data", onData);
131
+ process.stdin.once("end", onEnd);
132
+ process.stdin.resume();
133
+ });
134
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@lariocpt/crust-mongo",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.6",
4
+ "description": "crust with the MongoDB driver bundled — the mongo variant of the crust binary launcher",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Lario Borges",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/lariocpt/crust.git"
11
+ },
12
+ "homepage": "https://lariocpt.github.io/crust-website/",
13
+ "bugs": "https://github.com/lariocpt/crust/issues",
14
+ "keywords": [
15
+ "bun",
16
+ "devops",
17
+ "pipeline",
18
+ "load-testing",
19
+ "mock-server",
20
+ "openapi",
21
+ "testing",
22
+ "fixtures",
23
+ "test-generation",
24
+ "cli",
25
+ "mongo",
26
+ "mongodb"
27
+ ],
28
+ "bin": {
29
+ "crust": "./bin/crust"
30
+ },
31
+ "crust": {
32
+ "source": "github",
33
+ "repo": "lariocpt/crust",
34
+ "variant": "mongo"
35
+ },
36
+ "files": [
37
+ "bin",
38
+ "lib",
39
+ "scripts",
40
+ "skills",
41
+ "README.md"
42
+ ],
43
+ "scripts": {
44
+ "postinstall": "node scripts/postinstall.mjs"
45
+ },
46
+ "engines": {
47
+ "node": ">=18"
48
+ },
49
+ "os": [
50
+ "linux",
51
+ "darwin"
52
+ ],
53
+ "cpu": [
54
+ "x64",
55
+ "arm64"
56
+ ]
57
+ }
@@ -0,0 +1,36 @@
1
+ // crust — npm postinstall.
2
+ //
3
+ // A fast path, not the mechanism. npm 12 blocks install scripts by default, so bin/crust
4
+ // fetches the binary lazily on first use and this script only front-loads that work for
5
+ // environments where scripts still run. All the logic lives in lib/install.mjs so the two
6
+ // entry points cannot drift apart.
7
+ //
8
+ // It must never fail the install. A postinstall that exits non-zero aborts the whole
9
+ // `npm install`, and "you are not on the LAN right now" is not a reason to refuse to install
10
+ // a package whose launcher can recover later.
11
+ import { ensureBinary, installHint } from "../lib/install.mjs";
12
+ import { askSkills } from "../lib/skills-prompt.mjs";
13
+
14
+ const say = (m) => console.log(`[crust] ${m}`);
15
+ const warn = (m) => console.warn(`[crust] ${m}`);
16
+
17
+ let manifest = null;
18
+ try {
19
+ manifest = await ensureBinary({ log: say, warn });
20
+ } catch (e) {
21
+ warn(e.message);
22
+ warn("it will be fetched on first run instead.");
23
+ for (const l of installHint()) warn(l);
24
+ }
25
+
26
+ // The one question this installer asks. Its own gate keeps it silent on CI, on a
27
+ // non-tty, on a global install, and once the skills are already in place — and a
28
+ // throw here must not take the install down with it, so it is caught rather than
29
+ // trusted.
30
+ try {
31
+ await askSkills({ bin: manifest?.bin, env: process.env, log: say });
32
+ } catch (e) {
33
+ warn(`skills prompt skipped: ${e.message}`);
34
+ }
35
+
36
+ process.exit(0);
@@ -0,0 +1,299 @@
1
+ ---
2
+ name: crust-api-testing
3
+ description: Test HTTP APIs with crust — .pipes suites (one fixture pipeline per line, request chaining via capture, inline SQL verification), .crust.ts fixtures with setup/matchers, gen-fixtures which derives negative-case matrices and CRUD flow suites from an OpenAPI spec, and the evidence-tier taxonomy (snapshot vs DB-verified vs demo). Use for API integration tests, contract tests from a spec, auth/authz negative cases, or DB-verified endpoint tests.
4
+ ---
5
+
6
+ # crust API testing
7
+
8
+ ## The shared principle: the lightest CORRECT layer
9
+
10
+ Choose the lightest native layer that preserves correctness, observability,
11
+ and safety — not merely the shortest syntax.
12
+
13
+ - Use `.pipes` for straightforward requests, assertions, and supported request chaining.
14
+ - Use `.crust.ts` when setup, custom matchers, or required safety controls cannot be expressed in pipes.
15
+ - Use `gen-fixtures` for supported contract-derived cases, followed by review.
16
+ - Use native load pipelines for paced traffic, measurements, and explicit success gates.
17
+ - Do not force a case into pipes when doing so removes a required guarantee.
18
+ - Consult repository-specific execution and credential policies before running examples.
19
+
20
+ Three layers, one binary:
21
+
22
+ | Layer | File | Best for |
23
+ |---|---|---|
24
+ | `test-pipes` | `.pipes` — one pipeline per line | readable CRUD suites, chaining, SQL cross-checks |
25
+ | `test-fixture` | `.crust.ts` — TS fixture modules | complex setup, matcher functions, stress (`-n`) |
26
+ | `gen-fixtures` | generated from `openapi.json` | negative matrices + CRUD flows for every documented op |
27
+
28
+ ## .pipes suites (test-pipes)
29
+
30
+ One shorthand pipeline per line; `#` comments; lines run sequentially per
31
+ file. `capture` chains requests; `sql` verifies the database inline:
32
+
33
+ ```crust
34
+ {"name": "Court", "floors": 3} | POST $BASE/api/buildings -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture BID (b => b.building.id)
35
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 200
36
+ sql "SELECT count(*)::int AS c FROM buildings WHERE name = 'Court'" | assert (r => r.c === 1)
37
+ DELETE $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 204
38
+ GET $BASE/api/buildings/$BID -H "authorization: Bearer $TOKEN" | expect 404
39
+ ```
40
+
41
+ Run: `test-pipes 'tests/**/*.pipes' [-b] [-t ms] [-s mod.ts] [-o report.xml]`.
42
+ PASS/FAIL report lines are prefixed `file:LINE` with real file line numbers.
43
+ `-o`'s extension picks the format: `.xml` is JUnit (testcase per line),
44
+ `.json` the raw report — both CI-ingestable. Any other extension (or none)
45
+ writes the same text report stdout shows; the file never changes the exit code.
46
+
47
+ - **Setup module**: `-s mod.ts`, else sibling `<name>.setup.ts`; its
48
+ default export is awaited before the file and seeds `process.env`
49
+ ($BASE/$TOKEN). `sql` needs `$DATABASE_URL`.
50
+ - **SQL row types**: rows arrive as the driver returns them — uuid and enum
51
+ columns as strings, but `count(*)` and `numeric` may arrive as
52
+ strings/bigints. Cast in SQL when comparing numbers
53
+ (`count(*)::int AS c`), and compare ids as strings.
54
+ - **Hermetic per file**: `process.env` is snapshotted/restored around each
55
+ file — captures and setup vars never leak across files.
56
+ - Status-check placement: `expect N` when the check ends the line;
57
+ `assert (r => r.status === N)` when more stages follow (expect only
58
+ throws at drain).
59
+ - Redirect control: `--redirect error|manual|follow` on any http line —
60
+ `error` refuses a 3xx (a moved endpoint cannot pass by landing elsewhere;
61
+ the line fails with `redirect refused (--redirect error)`), `manual`
62
+ yields the 3xx so `GET … --redirect manual | expect 301` asserts a
63
+ redirect exists, `follow` is the default. This is the pipe-side twin of a
64
+ fixture's `input.redirect` — when a pipe cannot express a case, the
65
+ fixture can, and it has always had this control. In per-item/load position
66
+ a refusal is a `{status: 0, redirectRefused: true}` RECORD and the line
67
+ exits 0 — gate with `expect 200` or `stats | assert (s =>
68
+ s.redirectRefused === 0)`; exit 0 alone is never request success in load
69
+ mode.
70
+ - Result shapes per execution context (`Response` vs timed `{status, ms, url}`
71
+ records, body draining, `.json()` availability): canonical table at
72
+ https://lariocpt.github.io/crust-website/docs/http-result-shapes/.
73
+ - Exit codes: 0 all pass · 1 any fail · 2 no files/bad args.
74
+
75
+ ## .crust.ts fixtures (test-fixture)
76
+
77
+ One fixture = one request. `setup()` returns a ctx; `input` fields that are
78
+ 1-arg functions receive it; `output` fields that are 1+-arg functions are
79
+ matchers `(actual, ctx)` — async matchers awaited:
80
+
81
+ ```ts
82
+ export default {
83
+ name: "creates a user",
84
+ setup: async () => ({ token: await login() }),
85
+ input: {
86
+ url: "http://localhost:3000/api/users",
87
+ method: "POST",
88
+ headers: (ctx) => ({ authorization: `Bearer ${ctx.token}` }),
89
+ body: { name: "x" },
90
+ },
91
+ output: {
92
+ status: 201,
93
+ data: async (d, ctx) => (await db.userById(d.id)) !== null,
94
+ },
95
+ };
96
+ ```
97
+
98
+ Run: `test-fixture 'tests/*.crust.ts' -j8 [-n N] [-t ms] [-b] [-o report.xml]`.
99
+ `-o`'s extension picks the format — `.xml` is JUnit for CI: testcase per
100
+ run (stress iterations individually), percentiles in `<system-out>`; `.json` is
101
+ the raw report, `.md` markdown, anything else the text report.
102
+
103
+ `input.redirect: "error" | "manual" | "follow"` reaches fetch like any other
104
+ `RequestInit` field — the fixture-side twin of the http stage's `--redirect`
105
+ flag. `"error"` fails the fixture with the field named when the upstream
106
+ answers 3xx (a moved endpoint cannot pass by landing elsewhere); `"manual"`
107
+ records the 3xx so `output: { status: 301 }` asserts a redirect exists. Any
108
+ other value is a loud fixture error, not a fetch mystery.
109
+
110
+ `output.schema`: give it an inline JSON Schema and the response body must
111
+ conform — violations fail with per-field pointer paths. Inline means
112
+ $ref-free: a `$ref` anywhere in the schema is a loud error (the runner has
113
+ no spec to resolve it against — it would otherwise pass silently).
114
+ Unknown schema keywords pass (never-invent-a-violation). gen-fixtures emits
115
+ this automatically when the spec documents a response schema for a case's
116
+ expected status.
117
+ Fixtures may run CONCURRENTLY under -j/--threads — share state only via a
118
+ module-scope promise-cached factory. Fixture files may import ONLY relative
119
+ modules and Bun builtins (the binary can't resolve npm at runtime) —
120
+ `Bun.SQL`, `Bun.file`, `Bun.jwt`.
121
+
122
+ ## What evidence does your test produce?
123
+
124
+ **Tier 1 — snapshot / contract verification.** Response vs a declared case +
125
+ supplied snapshot; setup prepares REQUEST CONTEXT (url, headers, expected),
126
+ not state. Never call it "seeded"/"integration" when nothing is seeded or
127
+ verified at the data layer — it proves contract shape + declared oracle.
128
+
129
+ **Tier 2 — DB-verified flow.** seed → verify the seed → call the API → query
130
+ the DB → compare against the LIVE query result. Native shape:
131
+
132
+ ```crust
133
+ {"email": "u-$RUN_ID@t.dev"} | POST $BASE/api/users -H "authorization: Bearer $TOKEN" | assert (r => r.status === 201) | (r => r.json()) | capture UID (u => u.id)
134
+ sql "SELECT email FROM users WHERE id = '$UID'" | assert (r => r.email === "u-$RUN_ID@t.dev")
135
+ ```
136
+
137
+ Carries: run-owned data (`$RUN_ID` unique per run, set by setup — never
138
+ shared mutable state); explicit identity matching (query the id you
139
+ captured, never an unrelated record); failure-path cleanup defined BEFORE
140
+ writes — a later line is happy-path only; guaranteed teardown is `crust.onExit`.
141
+
142
+ **Tier 3 — demo / quickstart.** Disposable local resource, ordinary CRUD —
143
+ the built-in mock server is the native fit — isolates "how little glue does
144
+ crust need" from app safeguards (graph resolution, provisioning denial,
145
+ identity-sensitive stores) — separate approved fixture, never folded in.
146
+
147
+ Cross-cutting: label the tier so a green run cannot imply DB-level proof it
148
+ never produced; approval/redirect/transport safeguards stay regardless of
149
+ tier; setup attestations (flags asserting IAM/graph/provisioning policy) are
150
+ DECLARATIONS, not backend verification.
151
+
152
+ ## gen-fixtures — spec-driven cases
153
+
154
+ ```
155
+ gen-fixtures ./openapi.json [--no-flows] # --out/--setup default to tests/gen
156
+ ```
157
+
158
+ Emits (deterministic, byte-stable — check the output in, regenerate, review
159
+ the `git diff`):
160
+
161
+ - **Negative matrix** per op (`<tag>.gen.crust.ts`): 401 no-creds, 403
162
+ authenticated-outsider (scope-gated paths), 404 unknown-id, and a 400
163
+ matrix per body field — missing required, wrong type, bad enum, too
164
+ short/long (minLength/maxLength), below/above numeric bounds, pattern
165
+ violations, unexpected extra property. Nullable `anyOf` fields unwrapped.
166
+ - **CRUD flows** (`flows/flows.gen.pipes` + sibling setup, run by
167
+ test-pipes with zero extra flags): create → read (body round-trips the
168
+ captured id) → update → delete → read-after-delete-404, chained with
169
+ `capture`, auth via `$GEN_AUTH_HEADER`. SQL assertions are NOT derivable
170
+ from a spec — add those by hand in your own .pipes.
171
+
172
+ Setup-module contract (imported lazily): `shared()` promise-cached context,
173
+ `headersFor(ctx, "none"|"member"|"outsider")`, `resolvePath(ctx, template)`,
174
+ `scopeParam`, `scopeRoots?`, `JSON_HEADERS`, and optional
175
+ `flowOverrides: { "<template>": { body?: {...}, skip?: true } }` for creates
176
+ whose values a spec can't express (business date rules → `body` merge;
177
+ required foreign keys → `skip`).
178
+ When the contract disagrees with the 400 convention (422 backends, 404/403
179
+ concealment), the optional `expectations` block configures correct statuses —
180
+ a documented status always wins over a configured one;
181
+ `docs/USAGE.md §gen-fixtures` has the schema and precedence.
182
+
183
+ The `--out` dir is DELETED and recreated every run — never hand-edit
184
+ generated files.
185
+
186
+ What crust cannot attribute it does not emit. The pattern sampler returns a
187
+ knowingly-wrong placeholder for a pattern it cannot read — a lookahead, Python
188
+ `\A`/`\Z` anchors, a JavaScript regex literal written into `pattern` (groups
189
+ it reads: `(?:…)`, `(a|b)`, `(…){n}`, so an AWS id like
190
+ `^(subnet-[0-9a-f]{8}|subnet-[0-9a-f]{17})$` yields `subnet-00000000`); in a
191
+ base body a placeholder makes a case about `name` invalid in `subnetId`, so its
192
+ 400 names the wrong field. Those cases — and a CRUD flow
193
+ whose create body cannot be filled — are dropped and NAMED on stdout
194
+ (`gen-fixtures: 2 case(s) skipped for POST /things …`, `skipping flow for
195
+ /things …`). A silently smaller suite would look like coverage.
196
+
197
+ Every generation's LAST write is `<out>/gen-manifest.json`: each case with
198
+ its expectation's provenance (`contract:<spec pointer>` when the spec
199
+ documents the status), each skip, each unsupported op — review it BEFORE
200
+ running. Deterministic (same spec + version → byte-identical); it records
201
+ generation, never execution.
202
+ https://lariocpt.github.io/crust-website/docs/recipes/gen-fixtures/
203
+
204
+ ## Generated cases are candidates, not certified coverage
205
+
206
+ A generator can derive request mutations from a schema. It cannot universally
207
+ infer backend-specific status codes, error envelopes, authorization
208
+ concealment policies, business preconditions, or cleanup behavior.
209
+
210
+ For negative tests:
211
+
212
+ - Begin with an otherwise valid request.
213
+ - Mutate one intended condition.
214
+ - Ensure authentication and resource preconditions allow the request to
215
+ reach the intended validation layer (a request that dies at auth never
216
+ proves the validation code).
217
+ - Assert the documented status.
218
+ - Where the contract provides structured error evidence, verify the relevant
219
+ field, location, or error code.
220
+ - Avoid exact human-readable message matching unless wording is contractual.
221
+
222
+ A generic error status is insufficient evidence when the request could have
223
+ failed for an unrelated reason. And the generated **400 validation matrix is
224
+ a generator assumption, not a universal API convention** — some contracts
225
+ specify `422` (or another status) for validation failures; when the contract
226
+ disagrees, say so in review and adjust expectations to the CONTRACT, never
227
+ the other way around. Document unsupported assumptions and skipped cases
228
+ rather than silently reducing coverage or normalizing the contract to fit
229
+ generation.
230
+
231
+ ## Contract review before generation
232
+
233
+ Review the contract BEFORE generating and record the review beside the
234
+ generated output. Record fields (one per line):
235
+
236
+ ```
237
+ API/service:
238
+ Contract path:
239
+ Contract revision:
240
+ Backend revision, if available:
241
+ Crust version:
242
+ Reviewer:
243
+ Review date:
244
+ Generated output directory:
245
+ Intended execution scope:
246
+ Unresolved discrepancies:
247
+ ```
248
+
249
+ Highest-risk checks (full checklist:
250
+ https://lariocpt.github.io/crust-website/docs/contract-review/):
251
+
252
+ - Expected statuses come from the reviewed contract — validation cases do
253
+ NOT default to 400 (a 422 contract must generate 422 expectations).
254
+ - Authorization cases account for intentional 404 concealment; unknown-id
255
+ cases use syntactically valid identifiers.
256
+ - One intended condition per negative case; unrelated fields stay valid so
257
+ the request reaches the intended validation layer.
258
+ - Dedicated `--out` dir; regeneration cannot touch manual fixtures; never
259
+ hand-edit generated files.
260
+ - Classify every failure (backend · contract · generator · environment ·
261
+ incorrect test) BEFORE changing any expectation.
262
+ - Execution is approved separately — target, identity, resource scope,
263
+ writes, cleanup. This review grants no permission beyond the recorded
264
+ execution scope.
265
+
266
+ ## Workflow for a new API
267
+
268
+ 1. Identify the authoritative contract and record its version or revision.
269
+ 2. Review generation assumptions against that contract: validation status
270
+ codes; authentication and authorization behavior; error envelopes;
271
+ resource identifiers; response schemas; CRUD lifecycle support.
272
+ 3. Generate into a dedicated output directory (`--out`, default `tests/gen`).
273
+ Generation DELETES and recreates that directory; never target manually
274
+ authored sources.
275
+ 4. Review generated cases, expected responses, setup requirements, and
276
+ skipped cases (they are NAMED on stdout) before execution.
277
+ 5. Obtain the required target, identity, resource, write, cleanup, and load
278
+ approvals from repository policy — the skill never grants any of them.
279
+ 6. Execute only the approved scope.
280
+ 7. Classify failures before changing expectations or implementation:
281
+ backend defect · contract defect · unsupported generator assumption ·
282
+ environment/setup failure · incorrect test.
283
+ 8. Add manually authored native fixtures or pipelines for unsupported cases.
284
+ Do not hand-edit generated files (the next regeneration erases the edit).
285
+ 9. Record remaining coverage gaps explicitly.
286
+
287
+ > Never change an expected result solely because the backend returned
288
+ > something different. A passing test must reflect a reviewed contract.
289
+
290
+ ## Version and evidence
291
+
292
+ Behavior described here was verified against **crust v0.3.5** (source-read
293
+ and run through the compiled binary where it matters: `--redirect` record
294
+ shapes, `output.schema` $ref refusal, gen-fixtures skip reporting). New in
295
+ **v0.3.6**: the `gen-manifest.json` review artifact, the setup-module
296
+ `expectations` block, and the evidence-tier taxonomy. Newer
297
+ crust versions may extend behavior; re-verify before documenting it. Keep
298
+ approved targets, credentials, transport exceptions, resource ownership, and
299
+ execution restrictions in repository policy, not in this skill.