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
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- **Task order and caching live in `turbo.json`.** Each task is an underscore-prefixed leaf script (`_build`, `_typecheck`, `_test`, `_lint`) and the public script of the same name is `turbo run _<task>`. Ordering is a `dependsOn` edge, never a sequence of commands in a hook, a workflow or a script, so every entry point (hook, CI, a developer's shell) gets the same order and the same cache. `src/turbo.unit.test.ts` fails when a script, task or hook drifts from this. Incremental state that lives outside turbo's task cache is restored by its own CI cache step: the lint scripts cache by file content (mtimes do not survive a checkout) and `tsc` writes its build info beside each tsconfig, which the CI typecheck job caches.
- **Generated files are never hand-edited or committed.** `schemas/crossmap.schema.json` and `dist/` come from `pnpm build` and are gitignored. If the generated schema looks wrong, fix the Zod schema or `scripts/generate-json-schema.ts`.
- **No wall-clock figures in tests or docs.** Assert structure and counts of work.
- **The executor answers to a reference.** `test/support/reference-executor.ts` is the plain rule-at-a-time executor, and the property tests in `src/executor-reference.unit.test.ts` require the same output, key order included, or the same error from the shipped one over generated crosswalks and documents. A deliberate change to what encode or decode does is made in both, in the same commit; a change that only makes them faster changes the shipped one and leaves the reference alone. `pnpm bench` measures a change against a hand-written copy, as ratios and input reads per node rather than times.

## Releases

Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ The exit status is 0 on success, 1 when the library rejects the crosswalk or the

## The format

A crosswalk is `{ source, target, rules, oneWay }`: two labels naming the shapes, a list of two-way rules and a list of one-way rules. `parseCrosswalk` validates the syntax and throws one error carrying every issue. `validateCrosswalk` checks the properties of the rule list as a whole, and `encode`, `decode` and `assertRoundTrip` run it first.
A crosswalk is `{ source, target, rules, keep, oneWay }`: two labels naming the shapes, a list of two-way rules, a list of paths carried through unchanged and a list of one-way rules. Only `source` and `target` are required; each list defaults to empty. `parseCrosswalk` validates the syntax and throws one error carrying every issue. `validateCrosswalk` checks the properties of the rule list as a whole, and `compileCrosswalk`, `encode`, `decode` and `assertRoundTrip` run it before anything executes (once per crosswalk object, see [Execution](#execution)).

### Paths

Expand All @@ -190,7 +190,7 @@ Every `from` path and every `to` path is unique across the crosswalk, compared i

### Two-way rules

Each derives both directions from one declaration. Decode swaps `from` and `to` and applies the inverse. A `template` reads several fields and a `copy` writes several places; all of a rule's paths have the same number of wildcards, a template's fields come from the same element, and each path counts towards the uniqueness of `from` and `to` paths.
Each derives both directions from one declaration. Decode swaps `from` and `to` and applies the inverse. A `template` reads several fields and a `copy` writes several places; all of a rule's paths have the same number of wildcards, a template's fields come from the same element, and each path counts towards the uniqueness of `from` and `to` paths. A rule with one path on each side (`move`, `scale`, `enum`, `format` and `split`) may leave out `to` when it keeps the field at the same path, so `{ op: "move", from: "nodes[].id" }` means `{ op: "move", from: "nodes[].id", to: "nodes[].id" }`; parsing fills it in, so a parsed crosswalk always carries `to`, and the published JSON Schema leaves it optional for those ops.

| op | fields | encode | decode | inverse holds when |
| --- | --- | --- | --- | --- |
Expand All @@ -209,6 +209,17 @@ For example (a template writes a literal brace as `{{` or `}}`):
{ op: "copy", from: "items[].id", to: ["nodes[].id", "labels[].text"] }
```

### Kept paths

`keep` lists paths whose value goes across unchanged, at the same path, in both directions. Each one means exactly the identity `move` from that path to itself, so these two crosswalks are the same crosswalk:

```ts
{ source: "export", target: "graph/v1", keep: ["meta", "nodes[].id", "nodes[].label"] }
{ source: "export", target: "graph/v1", rules: [{ op: "move", from: "meta" }, { op: "move", from: "nodes[].id" }, { op: "move", from: "nodes[].label" }] }
```

A kept path is any path a `from` may be, wildcards included, and carries no `when`. `parseCrosswalk` turns each one into its `move`, appended after `rules` in the order listed, so a parsed crosswalk has no `keep` and everything else (validation, execution, round trips) sees only rules. Errors number a kept path as that rule: the n-th kept path is rule `rules.length + n`, counting from zero, and one-way rules are numbered after it. A path kept twice, or kept and also named by a rule, is a repeated path like any other. `keep` names each path it carries: a field that neither a rule nor `keep` names still does not survive encode.

### One-way rules

These are the declared lossy remainder, each applying in one named direction.
Expand Down Expand Up @@ -238,7 +249,17 @@ An element that every rule writing into it skips leaves a gap in the output arra

### Execution

The executor is pure and synchronous. Encode reads every `from` from the input as given and writes every `to` into a fresh output, so the input is never mutated. A `from` that is absent from the document is a `RuleApplicationError`, never a silent skip. The output is checked to be valid JSON throughout, so an input value that is not JSON (an `undefined` property, say) fails that check with a Zod error rather than reaching a caller.
The executor is pure and synchronous. Encode reads every `from` from the input as given and writes every `to` into a fresh output, so the input is never mutated. A `from` that is absent from the document is a `RuleApplicationError`, never a silent skip. The output is checked to be valid JSON throughout, so an input value that is not JSON (an `undefined` property, say) fails that check with a Zod error rather than reaching a caller. The check does not copy the output: a record or array that a `move` carries whole is the input's own object, shared by the output, so copy it (`structuredClone`) before changing either one in place.

A crosswalk is validated and prepared once, the first time it is used: `encode` and `decode` keep the prepared form for each crosswalk object, so running one crosswalk over many documents (every instance of a collection, both halves of a round trip) validates it once. `compileCrosswalk(crosswalk)` does the same step explicitly, throwing `CrosswalkStructureError` there rather than on the first run, and returns `{ encode, decode }` taking only the document. The crosswalk is read when it is prepared, so changing a crosswalk object after it has been used has no effect on it; parse a new one instead.

```ts
import { compileCrosswalk } from "crossmap";

const codec = compileCrosswalk(network);
const shared = codec.encode(exported);
codec.decode(shared);
```

The errors crossmap defines all extend `CrossmapError`: `CrosswalkSyntaxError` for a document that is not a crosswalk, `CrosswalkStructureError` for a rule list that parses but cannot execute, `RuleApplicationError` naming the rule and path that failed, and `RoundTripMismatchError` naming the sample and path.

Expand All @@ -254,7 +275,7 @@ The errors crossmap defines all extend `CrossmapError`: `CrosswalkSyntaxError` f

## JSON Schema

The crosswalk format is published as a JSON Schema 2020-12 document, generated from the Zod definitions as part of the build and canonicalised per RFC 8785. It is exposed through the package exports map as `crossmap/schemas/crossmap.schema.json`. The `when` field is a `$ref` to trilean's published predicate schema, pinned to the trilean version this package depends on, so the predicate grammar has one source of truth.
The crosswalk format is published as a JSON Schema 2020-12 document, generated from the Zod definitions as part of the build and canonicalised per RFC 8785. It is exposed through the package exports map as `crossmap/schemas/crossmap.schema.json`. It describes a crosswalk as a document may be written, so what `parseCrosswalk` fills in (the `rules`, `keep` and `oneWay` lists, a single-path rule's `to`) is optional in it. The `when` field is a `$ref` to trilean's published predicate schema, pinned to the trilean version this package depends on, so the predicate grammar has one source of truth.

## Not in this version

Expand All @@ -276,6 +297,7 @@ Node 22 or later, pnpm, ESM only. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
| `pnpm typecheck` | `tsc` over both tsconfig programs |
| `pnpm test` | Vitest, tests colocated as `*.unit.test.ts` |
| `pnpm build` | tsdown into `dist`, then the JSON Schema generation script |
| `pnpm bench` | Encode and decode against a hand-written copy of the same fields: the ratio of the two on this machine in this run, and the input reads each makes per node |

`CLAUDE.md` and `AGENTS.md` are symlinks to this file.

Expand Down
Loading