Skip to content

Commit 05c9415

Browse files
committed
feat(binary): permissive parse + strict save
Splits canonical-doc validation into two modes so the editor and JSON snapshot dump stay graceful for files with value-level deviations (out-of-enum, out-of-domain, linked-count drift) while save-back-to-bytes still rejects committable garbage. Layering: - spec/derive-zod adds a `mode: "strict" | "permissive"` option to toZodSchema. Permissive omits enum-membership refinements, domain bounds, and the linked-count superRefine; structural refinements (codec range, bit-width, array length, strictObject keys, integer typing) stay on in both modes — the doc shape must remain walkable regardless. - pro/canonical-schemas exposes both proCanonicalSnapshotSchema (strict, default) and proCanonicalSnapshotSchemaPermissive from one factory. - pro/canonical-reader and pro/json-snapshot.ts switch to the permissive schema. Out-of-enum / out-of-domain values now produce a usable canonical doc instead of being rejected at parse-time. (MAP unchanged: its canonical layer is hand-written and was already value-level permissive by design — see the comment at map/canonical-schemas.ts:89.) - pro/canonical-writer's serializeProCanonicalDocument continues to validate against the strict schema before serialising — that's now the singular save gate. New regression test pro-canonical-writer-strict-gate pins the behaviour. - pro/index.ts: enumField (which pushed errors on unknown enum values) is replaced by enumFieldTolerant, which renders "Unknown (N)" inline like walkStruct already does for spec-driven fields. parseCritter loses the `errors` parameter; the parser's errors array is now reserved for structural fatals only (size mismatch, truncation, unknown root type). - map/parse-helpers.ts: enumField was dead code (no callers); deleted. map/parse-sections.ts had a post-display scan that re-converted walkStruct's tolerant "Unknown (N)" rows into errors; deleted, as it was actively defeating the tolerant design. - cli: warnings print to stderr but no longer block JSON snapshot output. The editor's banner infrastructure (errors-container, warnings-container, renderMessages) was already wired through the init message channel; with the parser changes it now naturally surfaces graceful-load info without rendering an empty tree on what were previously fatal-treated value-level issues.
1 parent 1ac6677 commit 05c9415

13 files changed

Lines changed: 489 additions & 290 deletions

binary/src/cli.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ async function processFile(filePath: string, mode: OutputMode): Promise<FileResu
5252
return "error";
5353
}
5454

55+
// Warnings are non-fatal: surface them to stderr but proceed with the
56+
// snapshot output. `parse` reserves `errors` for structural failures
57+
// that prevent display (size mismatch, truncation, unknown root type);
58+
// value-level oddities arrive in `warnings` and the canonical doc is
59+
// built permissively for them.
60+
if (result.warnings && result.warnings.length > 0) {
61+
console.error(`Warnings parsing ${filePath}:`);
62+
for (const w of result.warnings) {
63+
console.error(` ${w}`);
64+
}
65+
}
66+
5567
const json = createBinaryJsonSnapshot(result).trimEnd();
5668
const jsonPath = getSnapshotPath(filePath);
5769

binary/src/map/parse-helpers.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -59,21 +59,6 @@ export function flagsField(
5959
return field(name, display, offset, size, "flags", undefined, value);
6060
}
6161

62-
export function enumField(
63-
name: string,
64-
value: number,
65-
lookup: Record<number, string>,
66-
offset: number,
67-
size: number,
68-
errors?: string[],
69-
): ParsedField {
70-
const resolved = lookup[value];
71-
if (resolved === undefined && errors) {
72-
errors.push(`Invalid ${name} at offset 0x${offset.toString(16)}: ${value}`);
73-
}
74-
return field(name, resolved ?? `Unknown (${value})`, offset, size, "enum", undefined, value);
75-
}
76-
7762
export function int32Field(name: string, data: Uint8Array, offset: number): ParsedField {
7863
const view = new DataView(data.buffer, data.byteOffset + offset, 4);
7964
return field(name, view.getInt32(0, false), offset, 4, "int32");

binary/src/map/parse-sections.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
import { walkStruct } from "../spec/walk-display";
3333
import { field, makeGroup, int32Field, HEADER_PADDING_OFFSET, HEADER_PADDING_SIZE } from "./parse-helpers";
3434

35-
export function parseHeaderSection(data: Uint8Array, errors: string[]): ParsedGroup {
35+
export function parseHeaderSection(data: Uint8Array, _errors: string[]): ParsedGroup {
3636
const header = parseHeader(data);
3737

3838
// walkStruct produces the 11 numeric/enum/flags rows from the spec +
@@ -41,25 +41,18 @@ export function parseHeaderSection(data: Uint8Array, errors: string[]): ParsedGr
4141
// The cast widens MapHeader (specific shape) to walkStruct's generic
4242
// record constraint; the spec keys are a subset of MapHeader's so the
4343
// runtime access is sound.
44+
//
45+
// Out-of-enum values surface inline as `Unknown (N)`; they are not pushed
46+
// to `errors` because the parser is read-permissive (mirroring PRO). The
47+
// strict gate against committable garbage lives at the canonical-write
48+
// path; here we just describe what the file actually says.
4449
const numericGroup = walkStruct(
4550
mapHeaderCanonicalSpec,
4651
mapHeaderPresentation,
4752
0,
4853
header as unknown as Record<string, number>,
4954
"Header",
5055
);
51-
for (const fieldEntry of numericGroup.fields) {
52-
if ("fields" in fieldEntry) continue;
53-
if (
54-
fieldEntry.type === "enum" &&
55-
typeof fieldEntry.value === "string" &&
56-
fieldEntry.value.startsWith("Unknown (")
57-
) {
58-
errors.push(
59-
`Invalid ${fieldEntry.name} at offset 0x${fieldEntry.offset.toString(16)}: ${fieldEntry.rawValue}`,
60-
);
61-
}
62-
}
6356

6457
const fields = [...numericGroup.fields];
6558
fields.splice(1, 0, field("Filename", header.filename, 0x04, 16, "string"));

binary/src/pro/canonical-reader.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ import {
2020
} from "./types";
2121
import type { ParsedField, ParsedGroup, ParseResult } from "../types";
2222
import {
23-
proCanonicalSnapshotSchema,
24-
proCanonicalDocumentSchema,
23+
proCanonicalSnapshotSchemaPermissive,
24+
proCanonicalDocumentSchemaPermissive,
2525
type ProCanonicalSnapshot,
2626
type ProCanonicalDocument,
2727
} from "./canonical-schemas";
@@ -377,7 +377,7 @@ function rebuildProCanonicalSnapshot(parseResult: ParseResult): ProCanonicalSnap
377377
}
378378

379379
return parseWithSchemaValidation(
380-
proCanonicalSnapshotSchema,
380+
proCanonicalSnapshotSchemaPermissive,
381381
{
382382
schemaVersion: 1,
383383
format: "pro",
@@ -395,7 +395,7 @@ export function createProCanonicalSnapshot(parseResult: ParseResult): ProCanonic
395395
const embeddedDocument = getProCanonicalDocument(parseResult);
396396
if (embeddedDocument) {
397397
return parseWithSchemaValidation(
398-
proCanonicalSnapshotSchema,
398+
proCanonicalSnapshotSchemaPermissive,
399399
{
400400
schemaVersion: 1,
401401
format: "pro",
@@ -414,6 +414,6 @@ export function rebuildProCanonicalDocument(parseResult: ParseResult): ProCanoni
414414
}
415415

416416
export function getProCanonicalDocument(parseResult: ParseResult): ProCanonicalDocument | undefined {
417-
const parsed = proCanonicalDocumentSchema.safeParse(parseResult.document);
417+
const parsed = proCanonicalDocumentSchemaPermissive.safeParse(parseResult.document);
418418
return parsed.success ? parsed.data : undefined;
419419
}

0 commit comments

Comments
 (0)