Skip to content

Commit 925f68d

Browse files
committed
refactor(binary): factor IE canonical-reader/json-snapshot into ie-common
The IE binary formats (ITM, SPL, EFF) carried byte-identical canonical-reader and json-snapshot scaffolding — `s/itm/spl/eff/` across three files each. Hand-rolling the same shape per format meant a bug fix or new envelope field had to be applied three times, with nothing preventing one format from drifting. Two new factories under `binary/src/ie-common/`: - `canonical-reader.ts` produces `getDocument` / `rebuildDocument` / `createSnapshot` for any IE format given its (Doc, Snap) zod schema pair. Snapshot envelope shape (`schemaVersion: 1`, `format`, `formatName`, optional `opaqueRanges` / `warnings`) lives here as one definition. - `json-snapshot.ts` produces `createJson` / `loadJson` with the same parser-round-trip discipline (`parseWithSchemaValidation` → serialize → reparse → semantic-stringify match). The parser is passed as a thunk to break the canonical-layer ↔ parser import cycle. Each format's `<format>/canonical-reader.ts` and `<format>/json-snapshot.ts` shrink to ~10 LOC of factory invocation plus the named re-exports (`getItmCanonicalDocument`, `createCanonicalItmJsonSnapshot`, etc.) that the per-format adapter / serializer / tests already import. Writers stay per-format: ITM/SPL share a header+abilities+effects layout but EFF is a single fixed-size record, so no clean shared shape exists across all three. format-adapter.ts also stays per format because each format's `semanticFieldKey` routing differs structurally (ITM/SPL emit "abilities[]" / "effects[]" segments; EFF emits "header" / "body"). 933 tests pass (no behavior change).
1 parent b284ea9 commit 925f68d

8 files changed

Lines changed: 258 additions & 233 deletions

File tree

binary/src/eff/canonical-reader.ts

Lines changed: 12 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,23 @@
11
/**
2-
* Reader helpers for rebuilding EffCanonicalSnapshot/EffCanonicalDocument
3-
* from a parsed display tree (ParseResult). The parser stores the canonical
4-
* doc on `result.document` directly; the rebuild path mirrors the other IE
5-
* formats and is exercised by the JSON-snapshot reload flow.
2+
* EFF canonical-reader: thin wrapper around the IE canonical-reader factory
3+
* (`ie-common/canonical-reader.ts`).
64
*/
75

8-
import { parseWithSchemaValidation } from "../schema-validation";
6+
import { createIeCanonicalReader } from "../ie-common/canonical-reader";
97
import {
108
type EffCanonicalDocument,
119
type EffCanonicalSnapshot,
1210
effCanonicalDocumentSchemaPermissive,
1311
effCanonicalSnapshotSchemaPermissive,
1412
} from "./canonical-schemas";
15-
import type { ParseResult } from "../types";
1613

17-
export function getEffCanonicalDocument(result: ParseResult): EffCanonicalDocument | undefined {
18-
if (!result.document) return undefined;
19-
return parseWithSchemaValidation(
20-
effCanonicalDocumentSchemaPermissive,
21-
result.document,
22-
"Invalid EFF canonical document",
23-
);
24-
}
14+
const reader = createIeCanonicalReader<EffCanonicalDocument, EffCanonicalSnapshot>({
15+
formatId: "eff",
16+
formatLabel: "EFF",
17+
documentSchemaPermissive: effCanonicalDocumentSchemaPermissive,
18+
snapshotSchemaPermissive: effCanonicalSnapshotSchemaPermissive,
19+
});
2520

26-
export function rebuildEffCanonicalDocument(result: ParseResult): EffCanonicalDocument {
27-
const doc = getEffCanonicalDocument(result);
28-
if (!doc) {
29-
throw new Error(
30-
"EFF canonical document missing from ParseResult; display-tree-only rebuild is not implemented",
31-
);
32-
}
33-
return doc;
34-
}
35-
36-
export function createEffCanonicalSnapshot(result: ParseResult): EffCanonicalSnapshot {
37-
const document = rebuildEffCanonicalDocument(result);
38-
const snapshot: EffCanonicalSnapshot = {
39-
schemaVersion: 1,
40-
format: "eff",
41-
formatName: result.formatName,
42-
document,
43-
};
44-
if (result.opaqueRanges && result.opaqueRanges.length > 0) {
45-
return parseWithSchemaValidation(
46-
effCanonicalSnapshotSchemaPermissive,
47-
{ ...snapshot, opaqueRanges: result.opaqueRanges },
48-
"Invalid EFF canonical snapshot",
49-
);
50-
}
51-
if (result.warnings) {
52-
return { ...snapshot, warnings: result.warnings };
53-
}
54-
return snapshot;
55-
}
21+
export const getEffCanonicalDocument = reader.getDocument;
22+
export const rebuildEffCanonicalDocument = reader.rebuildDocument;
23+
export const createEffCanonicalSnapshot = reader.createSnapshot;

binary/src/eff/json-snapshot.ts

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,24 @@
1+
/**
2+
* EFF JSON-snapshot: thin wrapper around the IE json-snapshot factory
3+
* (`ie-common/json-snapshot.ts`).
4+
*/
5+
16
import {
27
createEffCanonicalSnapshot,
38
effCanonicalSnapshotSchemaPermissive,
49
serializeEffCanonicalSnapshot,
510
type EffCanonicalSnapshot,
611
} from "./canonical";
712
import { effParser } from "./index";
8-
import { parseWithSchemaValidation } from "../schema-validation";
9-
import type { ParseOptions, ParseResult } from "../types";
10-
11-
interface LoadedCanonicalEffSnapshot {
12-
readonly snapshot: EffCanonicalSnapshot;
13-
readonly bytes: Uint8Array;
14-
readonly parseResult: ParseResult;
15-
}
16-
17-
export function createCanonicalEffJsonSnapshot(parseResult: ParseResult): string {
18-
return `${JSON.stringify(createEffCanonicalSnapshot(parseResult), null, 2)}\n`;
19-
}
20-
21-
export function loadCanonicalEffJsonSnapshot(
22-
jsonText: string,
23-
parseOptions?: ParseOptions,
24-
): LoadedCanonicalEffSnapshot {
25-
const snapshot = parseWithSchemaValidation(
26-
effCanonicalSnapshotSchemaPermissive,
27-
JSON.parse(jsonText),
28-
"Invalid canonical EFF snapshot",
29-
);
30-
const bytes = serializeEffCanonicalSnapshot(snapshot);
31-
const reparsed = effParser.parse(bytes, parseOptions);
32-
if (reparsed.errors && reparsed.errors.length > 0) {
33-
throw new Error(`Canonical EFF snapshot did not round-trip: ${reparsed.errors[0]}`);
34-
}
13+
import { createIeJsonSnapshot } from "../ie-common/json-snapshot";
3514

36-
const reparsedSnapshot = createEffCanonicalSnapshot(reparsed);
37-
if (JSON.stringify(snapshot) !== JSON.stringify(reparsedSnapshot)) {
38-
throw new Error("Canonical EFF snapshot did not round-trip semantically");
39-
}
15+
const layer = createIeJsonSnapshot<EffCanonicalSnapshot>({
16+
formatLabel: "EFF",
17+
snapshotSchemaPermissive: effCanonicalSnapshotSchemaPermissive,
18+
createSnapshot: createEffCanonicalSnapshot,
19+
serializeSnapshot: serializeEffCanonicalSnapshot,
20+
getParser: () => effParser,
21+
});
4022

41-
return { snapshot, bytes, parseResult: reparsed };
42-
}
23+
export const createCanonicalEffJsonSnapshot = layer.createJson;
24+
export const loadCanonicalEffJsonSnapshot = layer.loadJson;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* Shared canonical-reader factory for the IE binary formats (ITM, SPL, EFF).
3+
*
4+
* Each format's `<format>/canonical-reader.ts` calls `createIeCanonicalReader`
5+
* to get the three operations every IE format needs:
6+
* - `getDocument(result)` — pull the canonical doc off `result.document`
7+
* with permissive schema validation, undefined
8+
* if absent.
9+
* - `rebuildDocument(result)` — same plus a non-null assertion; the parser
10+
* always sets `result.document`, so a missing
11+
* doc here is a programming error.
12+
* - `createSnapshot(result)` — wrap the doc in the IE snapshot envelope
13+
* (`schemaVersion: 1`, `format`, `formatName`,
14+
* optional `opaqueRanges` / `warnings`).
15+
*
16+
* The body of each operation was byte-identical across the three formats
17+
* (`s/itm/spl/eff/`); only the schema types and the format-id discriminant
18+
* varied. Hand-rolling the same shape per format guaranteed they'd drift —
19+
* one format could grow a snapshot field the others didn't get.
20+
*/
21+
22+
import { z } from "zod";
23+
import { parseWithSchemaValidation } from "../schema-validation";
24+
import type { ParseResult } from "../types";
25+
26+
export type IeFormatId = "itm" | "spl" | "eff";
27+
28+
interface IeSnapshotEnvelope<Doc> {
29+
readonly schemaVersion: 1;
30+
readonly format: IeFormatId;
31+
readonly formatName?: string;
32+
readonly document: Doc;
33+
}
34+
35+
export interface IeCanonicalReaderConfig<Doc, Snap extends IeSnapshotEnvelope<Doc>> {
36+
readonly formatId: IeFormatId;
37+
/** Display label used in error messages (`"ITM"`, `"SPL"`, `"EFF"`). */
38+
readonly formatLabel: string;
39+
readonly documentSchemaPermissive: z.ZodType<Doc>;
40+
readonly snapshotSchemaPermissive: z.ZodType<Snap>;
41+
}
42+
43+
export interface IeCanonicalReader<Doc, Snap extends IeSnapshotEnvelope<Doc>> {
44+
readonly getDocument: (result: ParseResult) => Doc | undefined;
45+
readonly rebuildDocument: (result: ParseResult) => Doc;
46+
readonly createSnapshot: (result: ParseResult) => Snap;
47+
}
48+
49+
export function createIeCanonicalReader<Doc, Snap extends IeSnapshotEnvelope<Doc>>(
50+
config: IeCanonicalReaderConfig<Doc, Snap>,
51+
): IeCanonicalReader<Doc, Snap> {
52+
const { formatId, formatLabel, documentSchemaPermissive, snapshotSchemaPermissive } = config;
53+
54+
const getDocument = (result: ParseResult): Doc | undefined => {
55+
if (!result.document) return undefined;
56+
return parseWithSchemaValidation(
57+
documentSchemaPermissive,
58+
result.document,
59+
`Invalid ${formatLabel} canonical document`,
60+
);
61+
};
62+
63+
const rebuildDocument = (result: ParseResult): Doc => {
64+
const doc = getDocument(result);
65+
if (!doc) {
66+
throw new Error(
67+
`${formatLabel} canonical document missing from ParseResult; display-tree-only rebuild is not implemented`,
68+
);
69+
}
70+
return doc;
71+
};
72+
73+
const createSnapshot = (result: ParseResult): Snap => {
74+
const document = rebuildDocument(result);
75+
// Build the wire-shape envelope every IE snapshot has, then promote
76+
// through the snapshot zod for variants that carry `opaqueRanges`
77+
// (validates the shape) or attach `warnings` directly. The cast is
78+
// safe by construction: Snap extends IeSnapshotEnvelope<Doc>, and
79+
// we only ever produce the union of {base, base+opaqueRanges,
80+
// base+warnings} which the per-format snapshot schema enumerates.
81+
const envelope: IeSnapshotEnvelope<Doc> = {
82+
schemaVersion: 1,
83+
format: formatId,
84+
formatName: result.formatName,
85+
document,
86+
};
87+
if (result.opaqueRanges && result.opaqueRanges.length > 0) {
88+
return parseWithSchemaValidation(
89+
snapshotSchemaPermissive,
90+
{ ...envelope, opaqueRanges: result.opaqueRanges },
91+
`Invalid ${formatLabel} canonical snapshot`,
92+
);
93+
}
94+
if (result.warnings) {
95+
return { ...envelope, warnings: result.warnings } as unknown as Snap;
96+
}
97+
return envelope as unknown as Snap;
98+
};
99+
100+
return { getDocument, rebuildDocument, createSnapshot };
101+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Shared JSON-snapshot factory for the IE binary formats (ITM, SPL, EFF).
3+
*
4+
* Each format's `<format>/json-snapshot.ts` calls `createIeJsonSnapshot` to
5+
* get a serialise/load pair that follows the same round-trip discipline:
6+
* - `createJson(result)` — JSON.stringify the canonical snapshot.
7+
* - `loadJson(text, opts)` — parse the JSON, validate against the
8+
* permissive snapshot schema, serialise to
9+
* bytes, re-parse, and assert the re-parsed
10+
* snapshot stringifies identically. The
11+
* parser is provided as a thunk so per-format
12+
* callers can avoid the canonical layer →
13+
* parser → canonical layer import cycle.
14+
*
15+
* Failure-mode design: load throws on either parser-level errors or a
16+
* semantic round-trip mismatch. Both indicate a hand-edited snapshot whose
17+
* bytes don't reflect the doc shape (or a parser bug); the caller doesn't
18+
* silently get an out-of-sync result.
19+
*/
20+
21+
import { z } from "zod";
22+
import { parseWithSchemaValidation } from "../schema-validation";
23+
import type { BinaryParser, ParseOptions, ParseResult } from "../types";
24+
25+
export interface IeJsonSnapshotConfig<Snap> {
26+
readonly formatLabel: string;
27+
readonly snapshotSchemaPermissive: z.ZodType<Snap>;
28+
readonly createSnapshot: (result: ParseResult) => Snap;
29+
readonly serializeSnapshot: (snapshot: Snap) => Uint8Array;
30+
/**
31+
* Lazy parser accessor — the parser usually transitively imports the
32+
* canonical layer, so resolving it eagerly here would create a cycle.
33+
* The thunk is invoked on first load.
34+
*/
35+
readonly getParser: () => BinaryParser;
36+
}
37+
38+
export interface IeLoadedJsonSnapshot<Snap> {
39+
readonly snapshot: Snap;
40+
readonly bytes: Uint8Array;
41+
readonly parseResult: ParseResult;
42+
}
43+
44+
export interface IeJsonSnapshot<Snap> {
45+
readonly createJson: (result: ParseResult) => string;
46+
readonly loadJson: (jsonText: string, parseOptions?: ParseOptions) => IeLoadedJsonSnapshot<Snap>;
47+
}
48+
49+
export function createIeJsonSnapshot<Snap>(config: IeJsonSnapshotConfig<Snap>): IeJsonSnapshot<Snap> {
50+
const { formatLabel, snapshotSchemaPermissive, createSnapshot, serializeSnapshot, getParser } = config;
51+
52+
const createJson = (result: ParseResult): string => {
53+
return `${JSON.stringify(createSnapshot(result), null, 2)}\n`;
54+
};
55+
56+
const loadJson = (jsonText: string, parseOptions?: ParseOptions): IeLoadedJsonSnapshot<Snap> => {
57+
const snapshot = parseWithSchemaValidation(
58+
snapshotSchemaPermissive,
59+
JSON.parse(jsonText),
60+
`Invalid canonical ${formatLabel} snapshot`,
61+
);
62+
const bytes = serializeSnapshot(snapshot);
63+
const reparsed = getParser().parse(bytes, parseOptions);
64+
if (reparsed.errors && reparsed.errors.length > 0) {
65+
throw new Error(`Canonical ${formatLabel} snapshot did not round-trip: ${reparsed.errors[0]}`);
66+
}
67+
const reparsedSnapshot = createSnapshot(reparsed);
68+
if (JSON.stringify(snapshot) !== JSON.stringify(reparsedSnapshot)) {
69+
throw new Error(`Canonical ${formatLabel} snapshot did not round-trip semantically`);
70+
}
71+
return { snapshot, bytes, parseResult: reparsed };
72+
};
73+
74+
return { createJson, loadJson };
75+
}

binary/src/itm/canonical-reader.ts

Lines changed: 13 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,24 @@
11
/**
2-
* Reader helpers for rebuilding ItmCanonicalSnapshot/ItmCanonicalDocument
3-
* from a parsed display tree (ParseResult). The parser stores the canonical
4-
* doc on `result.document` directly; the rebuild path mirrors PRO/MAP and
5-
* is exercised by the JSON-snapshot reload flow.
2+
* ITM canonical-reader: thin wrapper around the IE canonical-reader factory
3+
* (`ie-common/canonical-reader.ts`). The factory body documents the shared
4+
* shape; this file only supplies ITM's schemas and format discriminants.
65
*/
76

8-
import { parseWithSchemaValidation } from "../schema-validation";
7+
import { createIeCanonicalReader } from "../ie-common/canonical-reader";
98
import {
109
type ItmCanonicalDocument,
1110
type ItmCanonicalSnapshot,
1211
itmCanonicalDocumentSchemaPermissive,
1312
itmCanonicalSnapshotSchemaPermissive,
1413
} from "./canonical-schemas";
15-
import type { ParseResult } from "../types";
1614

17-
export function getItmCanonicalDocument(result: ParseResult): ItmCanonicalDocument | undefined {
18-
if (!result.document) return undefined;
19-
return parseWithSchemaValidation(
20-
itmCanonicalDocumentSchemaPermissive,
21-
result.document,
22-
"Invalid ITM canonical document",
23-
);
24-
}
15+
const reader = createIeCanonicalReader<ItmCanonicalDocument, ItmCanonicalSnapshot>({
16+
formatId: "itm",
17+
formatLabel: "ITM",
18+
documentSchemaPermissive: itmCanonicalDocumentSchemaPermissive,
19+
snapshotSchemaPermissive: itmCanonicalSnapshotSchemaPermissive,
20+
});
2521

26-
export function rebuildItmCanonicalDocument(result: ParseResult): ItmCanonicalDocument {
27-
// The parser always sets result.document. A caller arriving here without
28-
// one would be programming error — display-tree-only rebuild via walkGroup
29-
// is not implemented (and not currently needed by any flow).
30-
const doc = getItmCanonicalDocument(result);
31-
if (!doc) {
32-
throw new Error(
33-
"ITM canonical document missing from ParseResult; display-tree-only rebuild is not implemented",
34-
);
35-
}
36-
return doc;
37-
}
38-
39-
export function createItmCanonicalSnapshot(result: ParseResult): ItmCanonicalSnapshot {
40-
const document = rebuildItmCanonicalDocument(result);
41-
const snapshot: ItmCanonicalSnapshot = {
42-
schemaVersion: 1,
43-
format: "itm",
44-
formatName: result.formatName,
45-
document,
46-
};
47-
if (result.opaqueRanges && result.opaqueRanges.length > 0) {
48-
return parseWithSchemaValidation(
49-
itmCanonicalSnapshotSchemaPermissive,
50-
{ ...snapshot, opaqueRanges: result.opaqueRanges },
51-
"Invalid ITM canonical snapshot",
52-
);
53-
}
54-
if (result.warnings) {
55-
return { ...snapshot, warnings: result.warnings };
56-
}
57-
return snapshot;
58-
}
22+
export const getItmCanonicalDocument = reader.getDocument;
23+
export const rebuildItmCanonicalDocument = reader.rebuildDocument;
24+
export const createItmCanonicalSnapshot = reader.createSnapshot;

0 commit comments

Comments
 (0)