Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ The package ships `schemas/combinatrix.schema.json`, a JSON Schema (draft 2020-1
]
```

To have an editor check such files, map their names to the schema in its settings, for example VS Code's `json.schemas` with the `fileMatch` pattern `*.rules.json` and the `url` `./node_modules/combinatrix/schemas/combinatrix.schema.json`. To validate in code, import it through the package's `exports` (`combinatrix/schemas/combinatrix.schema.json`) and give it to a draft 2020-12 validator such as Ajv's `ajv/dist/2020`. To load a model file without a validator, give its parsed contents to `parseModel`, which needs no schema at runtime; `parseRules` loads a rules file for dimensions written in code.
A model file can name the schema itself with a `$schema` key, which `parseModel` and `isModelData` accept and ignore and an editor reads without any setting: `"$schema": "https://cdn.jsdelivr.net/npm/combinatrix@<version>/schemas/combinatrix.schema.json"`, the `$id` of the version it was written against. The explorer writes it into every model it starts, and a list of rules has no place for one. To have an editor check files that do not name it, map their names to the schema in its settings, for example VS Code's `json.schemas` with the `fileMatch` pattern `*.rules.json` and the `url` `./node_modules/combinatrix/schemas/combinatrix.schema.json`. To validate in code, import it through the package's `exports` (`combinatrix/schemas/combinatrix.schema.json`) and give it to a draft 2020-12 validator such as Ajv's `ajv/dist/2020`. To load a model file without a validator, give its parsed contents to `parseModel`, which needs no schema at runtime; `parseRules` loads a rules file for dimensions written in code.

The schema covers only what is data. Computed dimensions, `sampled` domains, `predicate` and every other constraint given through the constraint contract are functions and are not described, and unset is not a JSON value, so a dimension is unset only through `activeWhen`. It states the shape, not what depends on the whole model: that names are unique, that every condition names a dimension and a value the model has and uses only node kinds an assignment of dimensions can back are checked by `defineModel`, and `parseModel` and `parseRules` check them for a model file and a rules file. A test requires the schema and `isCondition`, `isRule`, `isModelData`, `parseModel`, `parseRules` and `defineModel` to accept and refuse the same data, and `pnpm build` regenerates the file from the Zod definitions in `scripts/`, which are a development dependency only.

Expand Down
6 changes: 5 additions & 1 deletion e2e/form.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { EXAMPLES, exampleText } from '../site/src/examples';
import type { Json, JsonObject } from '../site/src/json';
import { getIn, isJsonArray, isJsonObject } from '../site/src/json';
import { EXPRESSION_KINDS, PREDICATE_KINDS } from '../site/src/nodes';
import { SCHEMA_URL } from '../site/src/schema';
import { Explorer, inOrder, reference, RESULT_TIMEOUT_MS, textEquals, textLiteral, trackExternalRequests } from './explorer';

/** The model the first test builds by hand, written as the form writes it. The rule excludes a combination on Windows or macOS that is not arm64. */
/** The model the first test builds by hand, written as the form writes it, naming the schema it is written against. The rule excludes a combination on Windows or macOS that is not arm64. */
const BUILT_BY_HAND: JsonObject = {
$schema: SCHEMA_URL,
dimensions: [
{ kind: 'choice', name: 'os', values: ['linux', 'macos', 'windows'] },
{ kind: 'choice', name: 'arch', values: ['x64', 'arm64'] },
Expand Down Expand Up @@ -246,6 +248,8 @@ test.describe('the form and the JSON view are one model', () => {
await expect(explorer.cards('dimensions')).toHaveCount(dimensions.length);
await expect(explorer.cards('rules')).toHaveCount(rules.length);
await expect(explorer.page.locator('textarea.raw-json')).toHaveCount(0);
// The schema a model names is part of the model, not a key the form leaves unshown.
await expect(explorer.page.getByText('which the form does not show and keeps')).toHaveCount(0);
await inOrder(
dimensions.map((dimension, index) => ({ dimension, index })),
async ({ dimension, index }) => {
Expand Down
6 changes: 4 additions & 2 deletions scripts/json-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ExpressionNodeSchema, JsonValueSchema, PredicateNodeSchema } from "tril
import { z } from "zod";

import pkg from "../package.json";
import { schemaId } from "./schema-id";

/**
* The Zod definitions of the library's serialisable data, from which `buildJsonSchema` derives the published JSON Schema. A condition is trilean's `PredicateNodeSchema` itself, so the schema states trilean trees exactly as trilean does. Zod is otherwise a development dependency used only for that derivation (trilean depends on it at runtime): the package validates at runtime with the guards in `src/guards.ts` and `src/shape.ts`, and `scripts/json-schema.unit.test.ts` proves the two accept and refuse the same data. `scripts/json-schema.types.unit.test.ts` proves at compile time that what each schema infers and the library type it describes accept the same data.
Expand Down Expand Up @@ -81,6 +82,7 @@ export const FirstValidDimensionSchema = z
/** The data form of a model: dimensions with literal domains, and rules. */
export const ModelSchema = z
.strictObject({
$schema: z.string().optional().describe("The URI of the JSON Schema the model is written against, which an editor reads to check the file. It has no effect on the model."),
dimensions: z.array(z.union([ChoiceDimensionSchema, FirstValidDimensionSchema])).describe("The dimensions, in enumeration order: the first varies slowest."),
rules: RulesSchema.optional().describe("Declarative constraints over the dimensions."),
})
Expand All @@ -95,11 +97,11 @@ const DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema";
const ROOT_DESCRIPTION =
"The serialisable data of combinatrix: a model (choice and first-valid dimensions with literal values, activity conditions and rules), a list of rules, a single rule, or a single condition, which is a trilean predicate tree. Computed dimensions, sampled domains, predicates and other constraints given through the constraint contract are functions, not data, and are out of scope. A value that is unset is not a JSON value: a dimension is unset only through `activeWhen`.";

/** The published JSON Schema document, a draft 2020-12 schema whose `$id` names the package version it ships in. The `$id` is version-pinned, never `latest`, so a reference to it keeps meaning the schema it was written against. */
/** The published JSON Schema document, a draft 2020-12 schema whose `$id` names the package version it ships in. */
export function buildJsonSchema(version: string): z.core.JSONSchema.JSONSchema {
const root = z.union([ModelSchema, RulesSchema, RuleSchema, ConditionSchema]).meta({ title: "combinatrix", description: ROOT_DESCRIPTION });

return { $schema: DRAFT_2020_12, $id: `https://cdn.jsdelivr.net/npm/combinatrix@${version}/schemas/combinatrix.schema.json`, ...z.toJSONSchema(root, { target: "draft-2020-12" }) };
return { $schema: DRAFT_2020_12, $id: schemaId(version), ...z.toJSONSchema(root, { target: "draft-2020-12" }) };
}

/** The version of the package this schema ships in. */
Expand Down
26 changes: 26 additions & 0 deletions scripts/parse-model.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,19 @@ describe("isModelData", () => {
expectAgreement(model, isModelData, accepts("Model"));
});

it("agrees with the JSON Schema on a model that declares a \"$schema\", whatever it holds", ONE_PROPERTY, () => {
const declared = fc.oneof(fc.string(), fc.jsonValue());
const validModel = accepts("Model");
fc.assert(
fc.property(model, declared, (data, schemaValue) => {
const written: unknown = { $schema: schemaValue, ...data };
expect(isModelData(written), JSON.stringify(written)).toBe(validModel(written));
expect(isModelData(written), JSON.stringify(written)).toBe(typeof schemaValue === "string");
}),
{ seed: SEED, numRuns: RUNS },
);
});

it("accepts every value the schema's Model accepts, and every value it accepts the schema's root accepts", ONE_PROPERTY, () => {
const validModel = accepts("Model");
const validRoot = accepts(undefined);
Expand Down Expand Up @@ -91,6 +104,19 @@ describe("parseModel", () => {
);
});

it("builds the same model whether or not the data declares a \"$schema\"", ONE_PROPERTY, () => {
fc.assert(
fc.property(model, fc.string(), (data, schemaValue) => {
const plain = parseModel(data);
const declared = parseModel({ $schema: schemaValue, ...data });
expect(declared.dimensions, JSON.stringify(data)).toEqual(plain.dimensions);
expect(declared.rules, JSON.stringify(data)).toEqual(plain.rules);
expect(collect(declared)).toEqual(collect(plain));
}),
{ seed: SEED, numRuns: RUNS },
);
});

it("is refused with every issue for data of the wrong shape, as the guard refuses it", ONE_PROPERTY, () => {
fc.assert(
fc.property(nearMiss(model), ({ value }) => {
Expand Down
4 changes: 4 additions & 0 deletions scripts/schema-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** The `$id` of the published JSON Schema for a package version, which a model file names in its `$schema`. It is version-pinned, never `latest`, so a reference to it keeps meaning the schema it was written against. */
export function schemaId(version: string): string {
return `https://cdn.jsdelivr.net/npm/combinatrix@${version}/schemas/combinatrix.schema.json`;
}
9 changes: 7 additions & 2 deletions site/src/editorState.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { emptyState, nodeSwitch, stateAfterEdit, stateAfterSwitch, stateFromMode
import type { JsonObject } from "./json";
import { NO_MEMORY, noSlots } from "./typeMemory";
import { convertJson } from "./modelEdit";
import { SCHEMA_URL } from "./schema";

const PREVIOUS: JsonObject = { dimensions: [{ kind: "choice", name: "kept", values: ["a"] }] };
const BEFORE = stateFromModel(PREVIOUS);
Expand All @@ -17,8 +18,12 @@ describe("stateFromModel", () => {
expect(state.problem).toBeUndefined();
});

it("starts empty with a model that has no dimensions", () => {
expect(emptyState().model).toEqual({ dimensions: [] });
it("starts empty with a model that has no dimensions and names the schema it is written against", () => {
expect(emptyState().model).toEqual({ $schema: SCHEMA_URL, dimensions: [] });
});

it("writes the schema first, where an editor looks for it", () => {
expect(emptyState().text).toBe(`{\n "$schema": ${JSON.stringify(SCHEMA_URL)},\n "dimensions": []\n}`);
});
});

Expand Down
Loading
Loading