@app-studio/qa-core
The contract every other @app-studio/qa package reads.
A host repository declares its QA facts once, in qa.bindings.json at its root:
which commands exist, which layers the test suite is made of, where the tests
and seeds live, which ports the apps bind, which accounts the seeds create. The
runner, the hooks and the doctor read that file at execution time. Nothing is
copied into the host and left to go stale.
That rule is not stylistic. A QA system copied from one repository into another
without its bindings rewritten produces a guard whose patterns match no file —
it denies nothing while looking exactly like a guard with nothing to complain
about. @app-studio/qa-doctor exists to make that state impossible; this
package is the contract it checks against.
What is here
| Export | Purpose |
|---|---|
bindingsSchema, QaBindings | The zod schema and its types. strict() throughout: an unknown key is an error, because a typo that silently does nothing is how a binding becomes a lie. |
loadBindings, findBindingsFile | Walk up from any directory to the repository root and validate. Returns failure as data so the doctor can report it instead of crashing. |
acquireLock, withLock | A cross-process lock keyed by checkout. Atomic creation; a stale lock is reported, never auto-deleted. |
runCommand, startBackground | Command execution with signal forwarding and SIGTERM→SIGKILL shutdown. |
waitForUrl, ensurePortFree, isPortFree | Readiness and port hygiene for orchestrating servers. |
DEFAULT_LAYERS, selectLayers, runLayers, describeLayers | The layered walk: bottom-up, stop at the first break, say what was not run and why. |
Rule, Finding, DoctorContext | The doctor's plugin contract, here so packages can contribute rules without depending on the doctor. |
appStudioFeatureSpecSchema, appStudioFormSpecSchema | The original App Studio feature/form contracts and their cross-reference validation. |
compileAppStudioFeatureSpec | Compiles feature.userExperience into live nominal and conditional browser paths without a duplicate scenario file. |
The bindings file
{
"$schema": "./node_modules/@app-studio/qa-core/schema/qa.bindings.schema.json",
"version": "2",
"repo": { "name": "my-app" },
// The canonical registry. Layers, gates and docs reference the KEYS, never
// the shell strings, so "what this repository runs" is written down once.
"commands": {
"typecheck": "pnpm typecheck",
"test.unit": "pnpm vitest run --project unit",
"test.integration": "pnpm vitest run --project integration",
"test.e2e": "pnpm playwright test",
"seed.apply": "pnpm seed",
"seed.check": "pnpm seed:check"
},
// Bottom-up. `qa run` stops at the first break.
"layers": [
{
"key": "logic",
"title": "Pure logic",
"restsOn": "nothing — no database, no application",
"means": "a sanitiser or a validator is wrong. Nothing above can be trusted.",
"commands": ["test.unit"]
}
],
"paths": { "seeds": "src/features/*/seed/index.ts", "tests.e2e": "tests/e2e" },
"ports": { "api": { "port": 3000, "boundBy": "playwright.config.ts" } },
"seedAccounts": [
{ "role": "ADMIN", "email": "[email protected]", "password": { "kind": "seeded-constant", "value": "password123456" } }
]
}
restsOn and means are not decoration. qa run --list prints them, and a
broken layer prints its own means next to the list of layers it did not run —
so a red run says which floor is broken and why the floors above it were
skipped, instead of five hundred failures that are one bug wearing different
names.
The lock, in three decisions
- Keyed by checkout, hashing the repository root into the lock filename, so two worktrees of the same project seed concurrently and two shells in the same checkout do not.
- Atomic creation (
open(path, 'wx')). Check-then-create leaves a window in which both contenders believe they won. - A stale lock is reported, never removed automatically. Two waiters can
both observe a dead holder and both delete the file — and the second delete
evicts a lock the first has just legitimately taken. The error names the
holder and prints the
rmcommand; a human decides.
Design acceptance for an explicitly limited viewport
Ordinary design-acceptance records require at least two distinct viewport
labels. A signed contract that authorizes exactly one viewport can be represented
by the optional scope field:
{
"scope": {
"kind": "single-viewport",
"viewport": { "name": "desktop", "width": 1440, "height": 900 },
"contract": {
"file": "knowledge/design/feature-ui.md",
"sha256": "<SHA-256 of the signed contract>"
},
"reason": "The signed contract limits this review to the desktop viewport."
}
}
This is a scope fragment, not an acceptance verdict. The existing record still
needs its reviewer, time, states, source digests and evidence for all three
checks. checks.responsive.viewports must contain exactly the scope's label;
width and height are positive integers describing the authorized viewport.
The contract file must also appear in sources with the identical digest.
The CLI checks that digest against the file on disk, as it does for every source.
Recording refreshes source hashes but does not silently update the scope's
contract hash: a changed contract requires an explicit scope review.
The schema does not interpret the contract's prose or inspect screenshots. The Designer verifies that the scope and dimensions match the signed contract and the observed evidence. Restricting the responsive scope does not waive accessibility, design-system, verdict, fixture-id or source-freshness checks. Duplicate labels are rejected for both ordinary and scoped records.
Requirements
Node.js 20 or newer.