@devfellowship/ux-paths-spec
v1.3.1
Published
The DFL UX Paths data contract: the v1 JSON Schema, TypeScript types generated from it, and a validate() with zero runtime dependencies and zero network I/O.
Readme
@devfellowship/ux-paths-spec
The DFL UX Paths data contract, on its own: the v1 JSON Schema, TypeScript
types generated from it, and a validate() with zero runtime dependencies
and zero network I/O.
pnpm add @devfellowship/ux-paths-specimport { validate, assertValid, SCHEMA_V1 } from "@devfellowship/ux-paths-spec";
import type { UxPathsDoc } from "@devfellowship/ux-paths-spec";
const result = validate(JSON.parse(text));
if (!result.valid) {
for (const error of result.errors) console.error(error.formatted);
// screens[0].id: must be string (expected type: string)
process.exit(1);
}
result.doc.screens; // typed as UxPathsDoc
// Or the throwing form, which narrows in place.
assertValid(doc, "itera-player.flows.json");Why this is a separate package
Its consumers are a CI validator, a capture job and an AI explorer.
None of them should need React to find out whether a flows.json is well-formed,
and one of them runs in a browser. The schema is a data contract, so it ships as
data — not folded into a component library.
It also ends a vendored copy kept in step by a promise.
dfl-components-cli carries its own copy of v1.schema.json with a comment
asking a human to "mirror it in the same round". A promise fails quietly: the
copy goes stale, the validator keeps exiting 0, and a document nobody should have
accepted sails through. Here, every artifact that restates the schema is
generated from it, and CI fails if any of them stops matching.
What it exports
| Export | What it is |
|---|---|
| validate(doc, options?) | { valid: true, doc } or { valid: false, errors }. No I/O. Synchronous. |
| assertValid(doc, location?, options?) | Throws SpecValidationError; narrows doc to UxPathsDoc on success. |
| formatErrors(errors) | One human-readable line per violation. |
| SCHEMA_V1 | The schema itself, as an object. |
| SCHEMA_ID | The schema's $id. A name, not an address — see below. |
| SUPPORTED_SCHEMA_VERSIONS | ["1.0.0", "1.1.0", "1.2.0", "1.3.0"]. |
| UxPathsDoc, Screen, Flow, Screenshot, SourceRef, … | Types generated from the schema. |
| stepScreenId, flowScreenIds, danglingScreenIds | Helpers for rules JSON Schema cannot express. |
| assertSchemaSupported, isDateTime, isUri | Internals, exported for testing. |
The raw schema also ships in the tarball, for consumers that are not TypeScript:
import schema from "@devfellowship/ux-paths-spec/schema/v1.json" with { type: "json" };The $id is a name, not an address
SCHEMA_ID is https://raw.githubusercontent.com/devfellowship/dfl-ux-paths/main/schema/v1.json.
dfl-ux-paths is a private repo, so that URL 404s for anyone outside the
org — and GitHub reports a private path as not found, never forbidden, so the
failure disguises itself as a broken link. The $id identifies the schema. It
is never fetched. This package exists in large part so that nothing has to try.
Zero runtime dependencies is a tested property
dependencies is absent from package.json, and src/__tests__/package-contract.test.ts
fails the build if one appears, if shipped source imports anything non-relative,
or if it references fetch / node:fs / any other host API.
tsconfig.build.json drops @types/node from the shipped compile, so a node:
import is a compile error rather than a convention someone remembers.
So how does it validate without Ajv?
It interprets the schema at runtime. validate() reads SCHEMA_V1 and walks
the document against it — the schema is the input, never a thing transcribed into
TypeScript. Add a field upstream and this validator enforces it with no edit here.
A hand-written validator would have re-created the exact drift this package exists
to end.
Two guards keep that honest:
- Ajv parity. Ajv is the reference implementation and stays a devDependency.
src/__tests__/ajv-parity.test.tsruns both engines over every committed flows document plus ~30 single-rule mutations, and fails on any disagreement. - Unknown keywords are a hard stop. The interpreter implements a fixed keyword
set. If a future schema uses one it does not know,
assertSchemaSupported()throws naming the keyword, rather than skipping it and quietly accepting documents it should refuse. A v1.4 needingmaxLengthfails here loudly, in the round it lands.
Versioning
major.minor tracks the highest schema_version the package admits — 1.3.x
ships schema v1.3. A schema v1.4 makes it 1.4.0. A fix in validate() is a
patch bump. A test asserts the version and the schema agree.
Changing the schema
schema/v1.json at the repo root is the single source of truth. Edit it, then:
pnpm --filter @devfellowship/ux-paths-spec run generateThat rewrites spec/schema/v1.json (byte copy), spec/src/schema.generated.ts
and spec/src/types.generated.ts. Never edit those three by hand. CI runs
generate --check and fails, naming the stale files, if you forget.
Not in the schema yet (a v1.4 conversation)
The viewer app reads three fields the schema has never defined:
requirements[], screen.fidelity and screen.archetype (plus screen.page_class).
The schema sets additionalProperties: false at the top level and on $defs/screen,
so a document carrying any of them is refused — this is a real gap, not a
tolerated extra. Three committed viewer fixtures fail validation for exactly this
reason, and ajv-parity.test.ts pins that fact with both engines so it stays
visible until v1.4 closes it.
License
MIT.
