diff --git a/src/codegen/scan/pack_refs.zig b/src/codegen/scan/pack_refs.zig index 7f340909..110046df 100644 --- a/src/codegen/scan/pack_refs.zig +++ b/src/codegen/scan/pack_refs.zig @@ -944,29 +944,3 @@ test "wrapFlatEntityComponents: a flat @ pair moves into the synthesized wrapper try std.testing.expect(target_at > wrapper_at); try std.testing.expect(std.mem.indexOf(u8, out, "citizens__Worker") != null); } - -test "rewritePackLocalRefs: the JSON-escaped @ spelling opens a component map too (labelle-engine#801)" { - const allocator = std.testing.allocator; - // `"\u0040slot"` decodes to `@slot` at engine load — the raw-byte - // walkers must treat it as a target key or the pack rewrite is - // silently lost on a supported engine (codex round 2 on #650). - const src = - \\{ "prefab": "base", "overrides": { "\u0040slot": { "Worker": { "hp": 9 } } } } - ; - const out = try rewritePackLocalRefs(allocator, src, &.{"Worker"}, &.{}, "citizens"); - defer allocator.free(out); - try std.testing.expect(std.mem.indexOf(u8, out, "\"citizens__Worker\": { \"hp\": 9 }") != null); -} - -test "wrapFlatEntityComponents: a flat escaped-@ pair rides into the wrapper (labelle-engine#801)" { - const allocator = std.testing.allocator; - const src = - \\{ "prefab": "base", "Worker": { "hp": 1 }, "\u0040slot": { "Position": { "x": 1 } } } - ; - const out = try rewritePackLocalRefs(allocator, src, &.{"Worker"}, &.{}, "citizens"); - defer allocator.free(out); - const wrapper_at = std.mem.indexOf(u8, out, "\"overrides\": {").?; - const target_at = std.mem.indexOf(u8, out, "\"\\u0040slot\":").?; - try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, out, "\"overrides\": {")); - try std.testing.expect(target_at > wrapper_at); -} diff --git a/src/codegen/scan/pack_refs/common.zig b/src/codegen/scan/pack_refs/common.zig index 02bf5447..e39f8bdf 100644 --- a/src/codegen/scan/pack_refs/common.zig +++ b/src/codegen/scan/pack_refs/common.zig @@ -3,7 +3,8 @@ //! split of `scan/pack_refs.zig` (labelle-assembler#534 follow-up). //! //! Owns the low-level JSONC byte scanners (`skipTrivia`/`scanString`/ -//! `scanValue`/`scanBalanced`), the pure key classifiers (`isPascalCase`/ +//! `scanValue`/`scanBalanced`), the key classifiers (`isTargetKey`/ +//! `isFlatComponentKey`, re-exported from `scene_keys.zig`, plus //! `isEntityListKey`/`containsKey`), and the container-shape probes that //! mirror the engine's `unified_format.zig` (`isOnlyMetaHeaderObject`/ //! `bundleHeaderOpen`/`rootWrapperValueOpen`/`bundleHeaderLegacyEntitiesOffset`). @@ -13,6 +14,7 @@ //! the free functions here directly. const std = @import("std"); +const scene_keys = @import("../../../scene_keys.zig"); pub const Error = error{ Malformed, OutOfMemory }; @@ -124,42 +126,18 @@ pub fn scanBalanced(src: []const u8, at: usize) Error!usize { return error.Malformed; } -/// Byte-parity twin of the engine's `unified_format.zig` `isPascalCase` -/// (RFC #596): an entity-scope key is a flat component declaration iff its -/// first byte is an ASCII uppercase letter; everything else (lowercase -/// structural keys like `prefab`/`children`/`meta`/`ref`, empty, non-ASCII -/// start) is structural. The flat-wrap transform must classify keys EXACTLY -/// like the engine's flat loader, or the copy's shape drifts from what the -/// author's file would have meant at game root. -pub fn isPascalCase(name: []const u8) bool { - if (name.len == 0) return false; - return name[0] >= 'A' and name[0] <= 'Z'; -} - -/// Byte-parity twin of the engine's `unified_format.zig` `isTargetKey` -/// (labelle-engine#801): `"@"` on a prefab reference's override map -/// targets a ref-named entity inside the referenced prefab's body. Like -/// PascalCase component keys, `@` keys are flat patch CONTENT at entity -/// scope — pass 1 must move them into the synthesized wrapper and pass 2 -/// must open a component map for their values, or the copy's shape drifts -/// from the author's meaning. -pub fn isTargetKey(name: []const u8) bool { - if (name.len > 1 and name[0] == '@') return true; - // The JSON-escaped spelling decodes to `@` at engine load. These - // walkers classify RAW bytes, so recognize it here too — otherwise - // pass 1 leaves an escaped target outside the synthesized wrapper - // and pass 2 treats its value as opaque payload, silently losing - // the pack-namespace rewrite on a supported engine (codex P2 on - // #650). The PascalCase analog (`\u0057orker`) is the broader - // pre-existing class tracked in #651. - return name.len > 6 and std.mem.startsWith(u8, name, "\\u0040"); -} - -/// A flat patch-content key at entity scope: PascalCase component -/// (RFC #596 axis 2) or `@` target (labelle-engine#801). -pub fn isFlatComponentKey(name: []const u8) bool { - return isPascalCase(name) or isTargetKey(name); -} +/// Key classification is shared with every other scene walker through +/// `scene_keys.zig` (labelle-assembler#651, #652): byte-parity with the +/// engine's `unified_format.zig`, applied to RAW key spans (see that +/// module for why raw bytes classify exactly like the engine's decoded +/// keys, and why an escape the engine rejects makes a key inert). A flat +/// patch-content key at entity scope is a component-shaped key +/// (PascalCase or `__`, engine#806) or a `@` target +/// (engine#801). Pass 1 must move all of them into the synthesized +/// wrapper, and pass 2 must open a component map for a target's value, or +/// the copy's shape drifts from the author's meaning. +pub const isTargetKey = scene_keys.rawIsTargetKey; +pub const isFlatComponentKey = scene_keys.rawIsFlatComponentKey; /// The two array-valued entity-list keys the engine walks (`children` on any /// entity scope, legacy `entities` at file level). Shared between pass 2's @@ -179,9 +157,9 @@ pub fn containsKey(keys: []const []const u8, needle: []const u8) bool { /// Byte-parity twin of the engine's `unified_format.zig` `isFileHeader` /// (RFC #596, #516): true iff the object opening at `open` is a file-level /// metadata header — it carries a `"meta"` key and NO entity-shape key -/// (`prefab`, `children`, `components`, `overrides`, `ref`, or any -/// PascalCase key). Other lowercase keys are tolerated on a header, exactly -/// as the engine tolerates them. `{}` carries no `meta` and is NOT a header +/// (`prefab`, `children`, `components`, `overrides`, `ref`, or any flat +/// content key: component-shaped or `@` target, `isFlatComponentKey`). +/// Other lowercase keys are tolerated on a header, exactly as the engine tolerates them. `{}` carries no `meta` and is NOT a header /// (the engine treats it as an entity and rejects it at load), and any /// object this scanner cannot fully account for is not a header either — /// both rewrite passes then keep treating the element as an entity, so the diff --git a/src/codegen/scan/pack_refs/pass1.zig b/src/codegen/scan/pack_refs/pass1.zig index 2ac2b88d..599ed2d2 100644 --- a/src/codegen/scan/pack_refs/pass1.zig +++ b/src/codegen/scan/pack_refs/pass1.zig @@ -26,6 +26,9 @@ const common = @import("common.zig"); /// learns to accept `__Pascal` keys (they are the documented /// global-registry key format — a prerequisite for engine v2.0, which drops /// the wrapper entirely), this pass can flip to an in-place key rewrite. +/// engine#806 now accepts flat `__Pascal` keys, so pass 1 also moves +/// any flat namespaced pair into the wrapper (labelle-assembler#652); the +/// wrap itself stays because it loads on engines older than #806 too. /// /// **What is transformed.** For each entity object (the file's entity /// content in any of the three accepted top-level shapes — see `emitRoot` — diff --git a/src/root.zig b/src/root.zig index 4ac6e3c7..fe1d9dad 100644 --- a/src/root.zig +++ b/src/root.zig @@ -90,6 +90,7 @@ test { _ = @import("scene_name_lint.zig"); _ = @import("scene_manifest.zig"); _ = @import("scene_manifest_test.zig"); + _ = @import("scene_keys_test.zig"); // #651/#652 key classification _ = @import("scene_override_check.zig"); _ = @import("tilemap_scan_test.zig"); // covers tilemap_scan + tilemap_scene_scan _ = @import("asset_validator.zig"); @@ -854,7 +855,10 @@ pub fn generate( // at generate instead. Covers scenes AND prefabs (a prefab body can // use `@` on its own ref-array entries). Unparseable pins (`local:` // dev overrides, branch pins) pass — see - // `engineSupportsTargetOverrides`. + // `engineSupportsTargetOverrides`. Flat pack-namespaced component keys + // (engine#806, labelle-assembler#652) shipped in the same engine + // release and are dropped the same way, so the gate covers them too + // (`sourceNeedsV211Keys`). if (!scene_manifest.engineSupportsTargetOverrides(cfg.engine_version)) { const prefabs_target = try std.fs.path.join(allocator, &.{ target_dir, "prefabs" }); defer allocator.free(prefabs_target); @@ -882,9 +886,9 @@ pub fn generate( if (hit) |offender| { defer allocator.free(offender); std.debug.print( - "labelle-assembler: '{s}' uses \"@\" target-override keys (labelle-engine#801), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++ - " An older engine silently DROPS `@` keys — the exact failure this syntax replaces.\n" ++ - " Bump `engine_version` in project.labelle, or remove the `@` overrides.\n", + "labelle-assembler: '{s}' uses \"@\" target-override keys (labelle-engine#801) or flat \"__Pascal\" component keys (labelle-engine#806), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++ + " An older engine silently DROPS those keys — the exact failure this syntax replaces.\n" ++ + " Bump `engine_version` in project.labelle, or remove the `@` overrides / move namespaced keys into a \"components\"/\"overrides\" wrapper.\n", .{ offender, cfg.engine_version, scene_manifest.MIN_ENGINE_FOR_TARGET_OVERRIDES }, ); return error.EngineTooOldForTargetOverrides; @@ -1248,6 +1252,33 @@ pub fn generate( pack_scans.deinit(allocator); } + // Flat namespaced keys in pack prefabs (engine#806) are gated on the + // STAGED, rewritten copies: pack rewrite pass 1 wraps any entity that + // declares a pack-local component, and a wrapped key loads on every + // engine. Only keys still flat after that would be dropped by an + // engine older than v2.11.0 (codex on #798). + if (!scene_manifest.engineSupportsTargetOverrides(cfg.engine_version)) { + // Prefabs only: a pack's other staged `.jsonc` (locales, …) is not + // entity data. + var flat_hit: ?[]const u8 = null; + for (pack_scans.items) |ps| { + const staged_prefabs = try std.fs.path.join(allocator, &.{ target_dir, "packs", ps.name, "prefabs" }); + defer allocator.free(staged_prefabs); + flat_hit = try scene_manifest.findFlatNamespacedKeyUsageInTree(allocator, staged_prefabs); + if (flat_hit != null) break; + } + if (flat_hit) |offender| { + defer allocator.free(offender); + std.debug.print( + "labelle-assembler: '{s}' uses flat \"__Pascal\" component keys (labelle-engine#806), but the pinned engine v{s} predates them (needs >= v{s}).\n" ++ + " An older engine silently DROPS those keys.\n" ++ + " Bump `engine_version` in project.labelle, or move the namespaced keys into a \"components\"/\"overrides\" wrapper.\n", + .{ offender, cfg.engine_version, scene_manifest.MIN_ENGINE_FOR_TARGET_OVERRIDES }, + ); + return error.EngineTooOldForTargetOverrides; + } + } + // ── Asset-Plugins Phase 1: copy + namespace + validate pack assets ── // Runs AFTER `loadPackScans` copied each pack's convention dirs (its // prefabs are now under `/packs//prefabs/`). For every pack diff --git a/src/scene_keys.zig b/src/scene_keys.zig new file mode 100644 index 00000000..7ed2260c --- /dev/null +++ b/src/scene_keys.zig @@ -0,0 +1,197 @@ +//! Scene/prefab JSONC key classification: the one assembler-side copy of +//! the engine's key rules (labelle-assembler#651, #652). +//! +//! Every assembler walker that decides what a scene/prefab key MEANS: the +//! pack-namespace rewrite (`codegen/scan/pack_refs/{common,pass1,pass2}`), +//! the bare-name lint and `@` version gate (`scene_name_lint`), and the +//! scene manifest validator (`scene_manifest`). All of them classify through +//! this file, so they cannot drift from one another or from the engine. +//! +//! Two engine sources are mirrored byte-for-byte: +//! +//! * **Key shapes:** labelle-engine `src/jsonc/unified_format.zig` +//! (`isPascalCase`, `isComponentKeyShape`, `isTargetKey`, +//! `isFlatComponentKey`). engine#806 widened flat-form content from +//! PascalCase-only to include pack-namespaced `__` keys, +//! so a flat `rooms__Room` at entity scope is a real component (#652). +//! * **String escapes:** labelle-engine `jsonc/src/parser.zig` +//! `parseString`. The engine decodes exactly `\n \t \r \b \f \\ \" \/` +//! and fails the WHOLE file with `error.InvalidEscape` on anything else, +//! `\uXXXX` included. `\u` has never been supported (checked against +//! every revision of that parser). +//! +//! **Why raw bytes can be classified directly (#651).** The textual walkers +//! see raw key spans, while the engine classifies decoded keys. Those two +//! agree for every key the engine can load. An accepted escape starts with +//! `\` and decodes to one of `\n \t \r \b \f \ " /`. None of those bytes (nor +//! `\` itself) is `A`-`Z`, `@` or `_`, and none of them appears in a +//! lowercase structural key (`prefab`, `children`, `components`, …). So the +//! first byte, every `__` position, the byte after the last `__`, and every +//! exact structural-name match are the same before and after decoding. +//! The only keys where raw and decoded bytes differ in meaning are keys +//! with an escape the engine REJECTS (`"\u0057orker"`, `"\u0040slot"`). +//! Those never reach the engine's classifier because the file fails to +//! load. `classifyRaw` returns `.unloadable` for them, so walkers treat +//! them as inert, and `scene_manifest` rejects such a file at build time +//! (`firstInvalidEscape`) instead of letting `std.json`, which DOES decode +//! `\u`, give it a meaning the engine never will. `decode` is the reference +//! implementation the tests check this against. + +const std = @import("std"); + +// ── Decoded-key rules (byte-parity with engine unified_format.zig) ──────── + +/// PascalCase (RFC #596): first byte is ASCII `A`-`Z`. Empty and +/// non-ASCII-start names are structural. +pub fn isPascalCase(name: []const u8) bool { + if (name.len == 0) return false; + return name[0] >= 'A' and name[0] <= 'Z'; +} + +/// A component-shaped key: PascalCase, or pack-namespaced +/// `__` with a nonempty prefix and a PascalCase suffix +/// after the LAST `__` (engine #803/#806: `industry__TendableWorkstation` +/// yes, `capacity__oops` no, `__Worker` no). +pub fn isComponentKeyShape(name: []const u8) bool { + if (isPascalCase(name)) return true; + const i = std.mem.lastIndexOf(u8, name, "__") orelse return false; + if (i == 0) return false; + return isPascalCase(name[i + 2 ..]); +} + +/// `@` target-override key (engine #801). A bare `@` is structural. +pub fn isTargetKey(name: []const u8) bool { + return name.len > 1 and name[0] == '@'; +} + +/// Flat patch content at entity scope: a component-shaped key or a `@` +/// target. Everything else at entity scope is structural. +pub fn isFlatComponentKey(name: []const u8) bool { + return isComponentKeyShape(name) or isTargetKey(name); +} + +// ── Escape decoding (byte-parity with engine jsonc parser.zig) ──────────── + +/// The byte an accepted escape letter decodes to, or null when the engine's +/// JSONC parser rejects the escape (`error.InvalidEscape`). +pub fn escapeByte(letter: u8) ?u8 { + return switch (letter) { + 'n' => '\n', + 't' => '\t', + 'r' => '\r', + 'b' => 0x08, + 'f' => 0x0C, + '\\' => '\\', + '"' => '"', + '/' => '/', + else => null, + }; +} + +/// Decode a raw string-literal body (the bytes between the quotes) the way +/// the engine does, into `buf`. `error.InvalidEscape` is exactly the +/// engine's load failure. A trailing lone `\` is also invalid there. +pub fn decode(raw: []const u8, buf: []u8) error{ InvalidEscape, NoSpaceLeft }![]const u8 { + var n: usize = 0; + var i: usize = 0; + while (i < raw.len) : (i += 1) { + var b = raw[i]; + if (b == '\\') { + i += 1; + if (i >= raw.len) return error.InvalidEscape; + b = escapeByte(raw[i]) orelse return error.InvalidEscape; + } + if (n >= buf.len) return error.NoSpaceLeft; + buf[n] = b; + n += 1; + } + return buf[0..n]; +} + +/// Offset (within `raw`) of the first escape the engine rejects, or null. +pub fn invalidEscapeIn(raw: []const u8) ?usize { + var i: usize = 0; + while (i < raw.len) : (i += 1) { + if (raw[i] != '\\') continue; + if (i + 1 >= raw.len or escapeByte(raw[i + 1]) == null) return i; + i += 1; + } + return null; +} + +// ── Raw-key classification for the textual walkers ──────────────────────── + +pub const Class = enum { + /// Lowercase structural key (`prefab`, `children`, `meta`, …) or any + /// other non-content key. + structural, + /// Component-shaped key (PascalCase or `__`). + component, + /// `@` target-override key. + target, + /// Carries an escape the engine rejects, so the file never loads and + /// the key has no meaning. Walkers treat it as inert. + unloadable, +}; + +/// Classify a raw key span (string-literal body, escapes undecoded) as the +/// engine classifies the decoded key. See the module doc for why no decode +/// buffer is needed. +pub fn classifyRaw(raw: []const u8) Class { + if (invalidEscapeIn(raw) != null) return .unloadable; + if (isTargetKey(raw)) return .target; + if (isComponentKeyShape(raw)) return .component; + return .structural; +} + +pub fn rawIsTargetKey(raw: []const u8) bool { + return classifyRaw(raw) == .target; +} + +pub fn rawIsComponentKeyShape(raw: []const u8) bool { + return classifyRaw(raw) == .component; +} + +pub fn rawIsFlatComponentKey(raw: []const u8) bool { + return switch (classifyRaw(raw)) { + .component, .target => true, + .structural, .unloadable => false, + }; +} + +// ── Whole-file escape check (engine load parity) ─────────────────────────── + +/// Byte offset of the first string escape in `src` that the engine's JSONC +/// parser rejects, or null if it has none. Comment-aware, so a `\u` inside a +/// `//` or `/* */` comment is ignored exactly as the engine ignores it. +/// Strings are scanned for the escape wherever they sit: the engine's +/// parser decodes keys and values alike, and one bad escape fails the file. +pub fn firstInvalidEscape(src: []const u8) ?usize { + var i: usize = 0; + while (i < src.len) { + const c = src[i]; + if (c == '/' and i + 1 < src.len and src[i + 1] == '/') { + i = std.mem.indexOfScalarPos(u8, src, i, '\n') orelse src.len; + continue; + } + if (c == '/' and i + 1 < src.len and src[i + 1] == '*') { + const close = std.mem.indexOfPos(u8, src, i + 2, "*/"); + i = if (close) |p| p + 2 else src.len; + continue; + } + if (c == '"') { + var j = i + 1; + while (j < src.len) : (j += 1) { + if (src[j] == '"') break; + if (src[j] == '\\') { + if (j + 1 >= src.len or escapeByte(src[j + 1]) == null) return j; + j += 1; + } + } + i = j + 1; + continue; + } + i += 1; + } + return null; +} diff --git a/src/scene_keys_test.zig b/src/scene_keys_test.zig new file mode 100644 index 00000000..a4f1d23a --- /dev/null +++ b/src/scene_keys_test.zig @@ -0,0 +1,398 @@ +//! Tests for `scene_keys.zig` and for every walker that classifies scene +//! keys through it (labelle-assembler#651 escaped spellings, #652 flat +//! pack-namespaced keys). Discovered via the `test {}` block in `root.zig`. + +const std = @import("std"); +const scene_keys = @import("scene_keys.zig"); +const scene_manifest = @import("scene_manifest.zig"); +const scene_name_lint = @import("scene_name_lint.zig"); +const pack_refs = @import("codegen/scan/pack_refs.zig"); + +const Class = scene_keys.Class; + +/// Reference classification of a DECODED key, spelled out from the engine's +/// `unified_format.zig` (`isTargetKey`, then `isComponentKeyShape`). +fn engineClass(decoded: []const u8) Class { + if (scene_keys.isTargetKey(decoded)) return .target; + if (scene_keys.isComponentKeyShape(decoded)) return .component; + return .structural; +} + +// ── Decoded-key rules: engine parity (#652) ──────────────────────────────── + +test "scene_keys: component-key shape matches engine unified_format (table)" { + const cases = [_]struct { key: []const u8, component: bool, flat: bool }{ + .{ .key = "Worker", .component = true, .flat = true }, + .{ .key = "industry__TendableWorkstation", .component = true, .flat = true }, + .{ .key = "rooms__Room", .component = true, .flat = true }, + // Suffix after the LAST `__` decides. + .{ .key = "a__b__Room", .component = true, .flat = true }, + .{ .key = "a__Room__b", .component = false, .flat = false }, + .{ .key = "capacity__oops", .component = false, .flat = false }, + // Empty prefix / empty suffix. + .{ .key = "__Worker", .component = false, .flat = false }, + .{ .key = "rooms__", .component = false, .flat = false }, + .{ .key = "rooms_Room", .component = false, .flat = false }, + .{ .key = "prefab", .component = false, .flat = false }, + .{ .key = "children", .component = false, .flat = false }, + .{ .key = "", .component = false, .flat = false }, + .{ .key = "@slot", .component = false, .flat = true }, + .{ .key = "@", .component = false, .flat = false }, + }; + for (cases) |c| { + errdefer std.debug.print("key '{s}'\n", .{c.key}); + try std.testing.expectEqual(c.component, scene_keys.isComponentKeyShape(c.key)); + try std.testing.expectEqual(c.flat, scene_keys.isFlatComponentKey(c.key)); + // No escapes, so the raw path must agree with the decoded rules. + try std.testing.expectEqual(c.flat, scene_keys.rawIsFlatComponentKey(c.key)); + } +} + +// ── Escaped spellings (#651) ─────────────────────────────────────────────── + +test "scene_keys: decode mirrors the engine parser's escape set" { + var buf: [64]u8 = undefined; + try std.testing.expectEqualStrings("W/orker", try scene_keys.decode("W\\/orker", &buf)); + try std.testing.expectEqualStrings("a\"b\\c\n", try scene_keys.decode("a\\\"b\\\\c\\n", &buf)); + // `\u` is NOT decoded by the engine: its parser fails the file. + try std.testing.expectError(error.InvalidEscape, scene_keys.decode("\\u0057orker", &buf)); + try std.testing.expectError(error.InvalidEscape, scene_keys.decode("\\u0040slot", &buf)); + try std.testing.expectError(error.InvalidEscape, scene_keys.decode("Worker\\", &buf)); + try std.testing.expectError(error.InvalidEscape, scene_keys.decode("Wor\\ker", &buf)); +} + +test "scene_keys: escaped spellings classify like their decoded key (table)" { + // Every key here is loadable by the engine; the raw classification must + // equal the engine's classification of the decoded key. + const cases = [_]struct { raw: []const u8, want: Class }{ + .{ .raw = "Worker", .want = .component }, + .{ .raw = "Wor\\/ker", .want = .component }, + .{ .raw = "Worker\\n", .want = .component }, + .{ .raw = "rooms__Ro\\\"om", .want = .component }, + .{ .raw = "ro\\toms__Room", .want = .component }, + .{ .raw = "rooms__\\/Room", .want = .structural }, + .{ .raw = "\\/Worker", .want = .structural }, + .{ .raw = "\\\\Worker", .want = .structural }, + .{ .raw = "@slot", .want = .target }, + .{ .raw = "@\\/slot", .want = .target }, + .{ .raw = "@\\n", .want = .target }, + .{ .raw = "\\/@slot", .want = .structural }, + .{ .raw = "compo\\/nents", .want = .structural }, + .{ .raw = "prefab", .want = .structural }, + }; + var buf: [64]u8 = undefined; + for (cases) |c| { + errdefer std.debug.print("raw '{s}'\n", .{c.raw}); + const decoded = try scene_keys.decode(c.raw, &buf); + try std.testing.expectEqual(c.want, engineClass(decoded)); + try std.testing.expectEqual(c.want, scene_keys.classifyRaw(c.raw)); + } +} + +test "scene_keys: raw == decoded classification for every accepted escape at every position" { + // Exhaustive over the engine's escape letters and insertion points in a + // set of base keys that cover each rule (PascalCase first byte, + // namespaced suffix, `@` target, structural names). + const bases = [_][]const u8{ "Worker", "rooms__Room", "a__b__C", "@slot", "prefab", "components", "x__y", "@" }; + const letters = "ntrbf\\\"/"; + var raw_buf: [64]u8 = undefined; + var dec_buf: [64]u8 = undefined; + for (bases) |base| { + for (letters) |letter| { + var pos: usize = 0; + while (pos <= base.len) : (pos += 1) { + const raw = try std.fmt.bufPrint(&raw_buf, "{s}\\{c}{s}", .{ base[0..pos], letter, base[pos..] }); + const decoded = try scene_keys.decode(raw, &dec_buf); + errdefer std.debug.print("raw '{s}'\n", .{raw}); + try std.testing.expectEqual(engineClass(decoded), scene_keys.classifyRaw(raw)); + } + } + } +} + +test "scene_keys: \\u spellings are unloadable, never content" { + const cases = [_][]const u8{ "\\u0057orker", "\\u0040slot", "rooms__\\u0052oom", "Worker\\u0021", "\\u0063omponents" }; + for (cases) |raw| { + errdefer std.debug.print("raw '{s}'\n", .{raw}); + try std.testing.expectEqual(Class.unloadable, scene_keys.classifyRaw(raw)); + try std.testing.expect(!scene_keys.rawIsFlatComponentKey(raw)); + try std.testing.expect(!scene_keys.rawIsTargetKey(raw)); + } +} + +test "scene_keys: firstInvalidEscape finds engine-rejected escapes in strings only" { + try std.testing.expectEqual(@as(?usize, null), scene_keys.firstInvalidEscape( + \\{ "W\/orker": { "s": "a\"b\\c\n" } } + )); + // In a comment: the engine skips comments, so this is fine. + try std.testing.expectEqual(@as(?usize, null), scene_keys.firstInvalidEscape( + \\{ // "\u0057orker" + \\ /* "\u0040slot" */ "Worker": {} } + )); + const src = "{ \"\\u0057orker\": {} }"; + try std.testing.expectEqual(@as(?usize, 3), scene_keys.firstInvalidEscape(src)); + // Values count too: the engine's parser decodes every string. + try std.testing.expect(scene_keys.firstInvalidEscape("{ \"Label\": { \"t\": \"caf\\u00e9\" } }") != null); +} + +// ── Walkers: scene_manifest (std.json path) ──────────────────────────────── + +test "scene_manifest: flat pack-namespaced top-level key is accepted (#652)" { + const m = try scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", + \\{ "prefab": "room", "rooms__Room": { "w": 3 } } + ); + defer scene_manifest.freeManifest(std.testing.allocator, m); +} + +test "scene_manifest: top-level keys are accepted exactly where the engine accepts them (table)" { + const cases = [_]struct { src: []const u8, ok: bool }{ + .{ .src = "{ \"prefab\": \"room\", \"rooms__Room\": {} }", .ok = true }, + .{ .src = "{ \"prefab\": \"room\", \"a__b__Room\": {} }", .ok = true }, + .{ .src = "{ \"prefab\": \"room\", \"Room\": {} }", .ok = true }, + .{ .src = "{ \"prefab\": \"room\", \"capacity__oops\": {} }", .ok = false }, + .{ .src = "{ \"prefab\": \"room\", \"__Room\": {} }", .ok = false }, + .{ .src = "{ \"prefab\": \"room\", \"rooms_Room\": {} }", .ok = false }, + }; + for (cases) |c| { + errdefer std.debug.print("src {s}\n", .{c.src}); + const r = scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", c.src); + if (c.ok) { + scene_manifest.freeManifest(std.testing.allocator, try r); + } else { + try std.testing.expectError(error.UnknownSceneKey, r); + } + } +} + +test "scene_manifest: a namespaced-only file is flat form, and namespaced + wrapper is hybrid (#652)" { + var parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, + \\{ "rooms__Room": {} } + , .{}); + defer parsed.deinit(); + try std.testing.expectEqualStrings("top level", (try scene_manifest.classifyTopLevel(parsed.value.object)).?); + + var hybrid = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, + \\{ "prefab": "room", "overrides": { "Room": {} }, "rooms__Room": {} } + , .{}); + defer hybrid.deinit(); + try std.testing.expectEqualStrings("overrides", scene_manifest.checkHybridForm(hybrid.value.object).?); + + // A lowercase non-component `__` key is not content: no hybrid. + var not_hybrid = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, + \\{ "prefab": "room", "overrides": { "Room": {} }, "meta__notes": {} } + , .{}); + defer not_hybrid.deinit(); + try std.testing.expectEqual(@as(?[]const u8, null), scene_manifest.checkHybridForm(not_hybrid.value.object)); + + try std.testing.expectError(error.HybridForm, scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", + \\{ "components": { "Position": {} }, "rooms__Room": {} } + )); +} + +test "scene_manifest: a \\u escape the engine cannot load is rejected before std.json decodes it (#651)" { + // std.json alone would decode this to a valid `Worker` component. + try std.testing.expectError(error.InvalidSceneJson, scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", + \\{ "prefab": "p", "\u0057orker": {} } + )); + try std.testing.expectError(error.InvalidSceneJson, scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", + \\{ "name": "caf\u00e9", "entities": [] } + )); + // Engine-accepted escapes still parse. + const m = try scene_manifest.parseSceneSource(std.testing.allocator, "s", "s.jsonc", + \\{ "name": "a\/b", "prefab": "p", "Worker": {} } // "\u0057" + ); + scene_manifest.freeManifest(std.testing.allocator, m); +} + +// ── Walkers: scene_name_lint ─────────────────────────────────────────────── + +fn refNames(arena: std.mem.Allocator, src: []const u8) ![]const []const u8 { + const refs = try scene_name_lint.collectComponentRefs(arena, src); + const names = try arena.alloc([]const u8, refs.len); + for (refs, names) |r, *n| n.* = r.name; + return names; +} + +test "scene_name_lint: flat entity-scope refs follow the engine's key shape (table)" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const cases = [_]struct { key: []const u8, is_ref: bool }{ + .{ .key = "Worker", .is_ref = true }, + .{ .key = "citizens__Worker", .is_ref = true }, // #652 + .{ .key = "Wor\\/ker", .is_ref = true }, // #651: escaped, still a component + .{ .key = "capacity__oops", .is_ref = false }, + .{ .key = "__Worker", .is_ref = false }, + .{ .key = "meta", .is_ref = false }, + .{ .key = "\\u0057orker", .is_ref = false }, // engine can't load it + }; + for (cases) |c| { + errdefer std.debug.print("key '{s}'\n", .{c.key}); + const src = try std.fmt.allocPrint(arena, "{{ \"prefab\": \"p\", \"{s}\": {{ \"Inner\": 1 }} }}", .{c.key}); + const names = try refNames(arena, src); + if (c.is_ref) { + try std.testing.expectEqual(@as(usize, 1), names.len); + try std.testing.expectEqualStrings(c.key, names[0]); + } else { + // And the value stays payload: `Inner` is never a ref. + try std.testing.expectEqual(@as(usize, 0), names.len); + } + } +} + +test "scene_name_lint: @ gate classifies escaped spellings like the engine (#651)" { + try std.testing.expect(scene_name_lint.sourceUsesTargetKeys( + \\{ "prefab": "m", "@slot": { "Storage": {} } } + )); + try std.testing.expect(scene_name_lint.sourceUsesTargetKeys( + \\{ "prefab": "m", "@\/slot": { "Storage": {} } } + )); + // The engine's parser rejects `\u` on every version, so this file never + // loads anywhere: no silent drop for the version gate to catch. + try std.testing.expect(!scene_name_lint.sourceUsesTargetKeys( + \\{ "prefab": "m", "\u0040slot": { "Storage": {} } } + )); + // A namespaced flat component opens a payload, not a component map, so + // an `@` key below it is payload data. + try std.testing.expect(!scene_name_lint.sourceUsesTargetKeys( + \\{ "prefab": "m", "rooms__Room": { "@id": 1 } } + )); +} + +// ── Walkers: pack rewrite (pass 1 + pass 2) ──────────────────────────────── + +fn rewrite(src: []const u8) ![]u8 { + return pack_refs.rewritePackLocalRefs(std.testing.allocator, src, &.{"Worker"}, &.{}, "citizens"); +} + +/// Compare ignoring spaces: pass 1 keeps the author's trivia around moved +/// pairs, and these tests pin WHERE each pair lands, not the spacing. +fn expectSameShape(expected: []const u8, actual: []const u8) !void { + var a: std.ArrayList(u8) = .empty; + defer a.deinit(std.testing.allocator); + var b: std.ArrayList(u8) = .empty; + defer b.deinit(std.testing.allocator); + for (expected) |c| if (c != ' ') try a.append(std.testing.allocator, c); + for (actual) |c| if (c != ' ') try b.append(std.testing.allocator, c); + try std.testing.expectEqualStrings(a.items, b.items); +} + +test "pack rewrite: flat namespaced pairs move into the synthesized wrapper (#652)" { + const out = try rewrite( + \\{ "prefab": "base", "Worker": { "hp": 1 }, "rooms__Room": { "w": 2 } } + ); + defer std.testing.allocator.free(out); + // One wrapper, and the namespaced pair is INSIDE it. Left flat beside + // the wrapper, the engine would warn wrapper-wins and drop it. + try expectSameShape( + \\{ "prefab": "base", "overrides": { "citizens__Worker": { "hp": 1 }, "rooms__Room": { "w": 2 } } } + , out); +} + +test "pack rewrite: which flat keys ride into the wrapper follows the engine (table)" { + const cases = [_]struct { key: []const u8, moves: bool }{ + .{ .key = "Other", .moves = true }, + .{ .key = "rooms__Room", .moves = true }, // #652 + .{ .key = "Oth\\/er", .moves = true }, // #651: escaped component + .{ .key = "@slot", .moves = true }, + .{ .key = "@\\/slot", .moves = true }, // #651: escaped target + .{ .key = "capacity__oops", .moves = false }, + .{ .key = "meta", .moves = false }, + .{ .key = "\\u0040slot", .moves = false }, // unloadable: inert + .{ .key = "\\u0057orker", .moves = false }, // unloadable: inert + }; + for (cases) |c| { + errdefer std.debug.print("key '{s}'\n", .{c.key}); + const src = try std.fmt.allocPrint(std.testing.allocator, "{{ \"prefab\": \"base\", \"Worker\": {{}}, \"{s}\": {{ \"v\": 1 }} }}", .{c.key}); + defer std.testing.allocator.free(src); + const out = try rewrite(src); + defer std.testing.allocator.free(out); + const moved = try std.fmt.allocPrint(std.testing.allocator, "{{ \"prefab\": \"base\", \"overrides\": {{ \"citizens__Worker\": {{}}, \"{s}\": {{ \"v\": 1 }} }} }}", .{c.key}); + defer std.testing.allocator.free(moved); + const stayed = try std.fmt.allocPrint(std.testing.allocator, "{{ \"prefab\": \"base\", \"overrides\": {{ \"citizens__Worker\": {{}} }}, \"{s}\": {{ \"v\": 1 }} }}", .{c.key}); + defer std.testing.allocator.free(stayed); + try expectSameShape(if (c.moves) moved else stayed, out); + } +} + +test "pack rewrite: an escaped @ target's value is a component map exactly when the engine loads it (#651)" { + // Loadable escape: the target opens a component map, so `Worker` under + // it is namespaced. + const ok = try rewrite( + \\{ "prefab": "base", "overrides": { "@\/slot": { "Worker": { "hp": 9 } } } } + ); + defer std.testing.allocator.free(ok); + try std.testing.expect(std.mem.indexOf(u8, ok, "\"citizens__Worker\": { \"hp\": 9 }") != null); + // `\u`: the file never loads, and the walker leaves it byte-identical. + const src = + \\{ "prefab": "base", "overrides": { "\u0040slot": { "Worker": { "hp": 9 } } } } + ; + const inert = try rewrite(src); + defer std.testing.allocator.free(inert); + try std.testing.expectEqualStrings(src, inert); +} + +// ── Version gate: keys an engine older than v2.11.0 drops (codex on #798) ── + +test "version gate: flat namespaced keys need v2.11 exactly where the engine reads them (table)" { + const cases = [_]struct { src: []const u8, needs: bool }{ + // Flat on an entity (file root, child, bundle element): engine#806. + .{ .src = "{ \"prefab\": \"room\", \"rooms__Room\": {} }", .needs = true }, + .{ .src = "{ \"children\": [ { \"rooms__Room\": {} } ] }", .needs = true }, + .{ .src = "[ { \"rooms__Room\": {} } ]", .needs = true }, + // `@` targets: engine#801, same release. + .{ .src = "{ \"prefab\": \"m\", \"@slot\": { \"Storage\": {} } }", .needs = true }, + // Wrapped namespaced keys always loaded; PascalCase always loaded. + .{ .src = "{ \"prefab\": \"room\", \"overrides\": { \"rooms__Room\": {} } }", .needs = false }, + .{ .src = "{ \"components\": { \"rooms__Room\": {} } }", .needs = false }, + .{ .src = "{ \"prefab\": \"room\", \"Room\": {} }", .needs = false }, + // Not component-shaped, or inside opaque payload. + .{ .src = "{ \"prefab\": \"room\", \"capacity__oops\": 1 }", .needs = false }, + .{ .src = "{ \"Config\": { \"a__B\": 1 } }", .needs = false }, + // Unloadable on every engine: nothing for the gate to catch. + .{ .src = "{ \"prefab\": \"room\", \"rooms__\\u0052oom\": {} }", .needs = false }, + }; + for (cases) |c| { + errdefer std.debug.print("src {s}\n", .{c.src}); + try std.testing.expectEqual(c.needs, scene_manifest.sourceNeedsV211Keys(c.src)); + } + // The `@`-only probe stays `@`-only. + try std.testing.expect(!scene_manifest.sourceUsesTargetKeys("{ \"rooms__Room\": {} }")); +} + +// ── std.json best-effort scanners: engine-unloadable files are empty ────── + +test "scanTilemapAssets: an escaped key the engine cannot load fabricates no tilemap (codex on #798)" { + const plain = try scene_manifest.scanTilemapAssets(std.testing.allocator, + \\{ "components": { "Tilemap": { "asset_name": "room_map" } } } + ); + defer scene_manifest.freeTilemapAssets(std.testing.allocator, plain); + try std.testing.expectEqual(@as(usize, 1), plain.len); + + const escaped = try scene_manifest.scanTilemapAssets(std.testing.allocator, + \\{ "components": { "\u0054ilemap": { "asset_name": "room_map" } } } + ); + defer scene_manifest.freeTilemapAssets(std.testing.allocator, escaped); + try std.testing.expectEqual(@as(usize, 0), escaped.len); +} + +test "version gate: pack prefabs are gated AFTER the rewrite wraps them (codex on #798)" { + const gate = @import("scene_target_gate.zig"); + // Source prefab: flat `rooms__Room` beside a pack-local `Worker`. + const src = + \\{ "prefab": "base", "Worker": {}, "rooms__Room": { "w": 2 } } + ; + try std.testing.expect(gate.sourceUsesFlatNamespacedKeys(src)); + // Staged copy: pass 1 wrapped both, which loads on every engine. + const staged = try rewrite(src); + defer std.testing.allocator.free(staged); + try std.testing.expect(!gate.sourceUsesFlatNamespacedKeys(staged)); + // No pack-local key to trigger the wrap: still flat, still gated. + const unwrapped = try rewrite( + \\{ "prefab": "base", "rooms__Room": { "w": 2 } } + ); + defer std.testing.allocator.free(unwrapped); + try std.testing.expect(gate.sourceUsesFlatNamespacedKeys(unwrapped)); + // The pre-staging pack SOURCE scan is `@`-only, so it lets `src` through. + try std.testing.expect(!gate.sourceUsesTargetKeys(src)); +} diff --git a/src/scene_manifest.zig b/src/scene_manifest.zig index 022485f1..04f1e945 100644 --- a/src/scene_manifest.zig +++ b/src/scene_manifest.zig @@ -15,6 +15,7 @@ const std = @import("std"); const config = @import("config.zig"); const tilemap_scene_scan = @import("tilemap_scene_scan.zig"); +const scene_keys = @import("scene_keys.zig"); /// Write a formatted diagnostic directly to stderr, matching the /// repo-wide convention (see `flow_scanner.reportFlowError`, @@ -28,7 +29,7 @@ const tilemap_scene_scan = @import("tilemap_scene_scan.zig"); /// this file plus a reasonably long scene path; if a path is so /// pathological that it overflows we fall back to streaming the /// format string verbatim so the user still sees *something*. -fn stderrPrint(comptime fmt: []const u8, args: anytype) void { +pub fn stderrPrint(comptime fmt: []const u8, args: anytype) void { const io = config.globalIo(); const stderr = std.Io.File.stderr(); var buf: [4096]u8 = undefined; @@ -116,36 +117,23 @@ const ALLOWED_TOP_LEVEL_KEYS: []const []const u8 = &.{ "meta", }; -/// True if a key's first byte is an ASCII upper-case letter — the -/// PascalCase convention promoted to a parser rule by RFC #596 axis 2. -/// Component keys live under PascalCase; structural keys are lowercase. -/// The assembler scan uses this to recognize flat-form component -/// references at both the file's top level and inside entity objects. -fn isPascalCase(key: []const u8) bool { - if (key.len == 0) return false; - const c = key[0]; - return c >= 'A' and c <= 'Z'; -} - -/// `"@"` target-override key (labelle-engine#801). Rides the flat -/// shape exactly like a PascalCase component key: it is patch CONTENT, so -/// it participates in flat-form detection and the hybrid-form gate. -fn isTargetKey(key: []const u8) bool { - return key.len > 1 and key[0] == '@'; -} +/// Key classification (PascalCase, pack-namespaced `__`, +/// `@` target) is the engine's, shared with every scene walker through +/// `scene_keys.zig` (labelle-assembler#651, #652). Keys here come out of +/// `std.json` already decoded; `parseSceneSource` first rejects any escape +/// the engine's parser would not decode the same way, so these decoded +/// keys are exactly the ones the engine classifies. +const isFlatComponentKey = scene_keys.isFlatComponentKey; fn isAllowedTopLevelKey(key: []const u8) bool { - // RFC #596 axis 2: PascalCase keys at the file's top level are - // component declarations on the flat-form root entity. The audit - // and the loader (engine #597) catch unknown PascalCase names at - // runtime — the assembler scan can't see the component registry - // here, so we accept any PascalCase key and rely on the loader's - // warn-once path. This matches the audit's option C resolution. - if (isPascalCase(key)) return true; - // `@` target-override keys on a flat-form reference root - // (labelle-engine#801) — content, not structure; the engine - // validates the ref names at load. - if (isTargetKey(key)) return true; + // Flat content keys at the file's top level are the flat-form root + // entity's patch: PascalCase components (RFC #596 axis 2), + // pack-namespaced `__` components (engine#806, + // labelle-assembler#652), and `@` target overrides (engine#801). The + // loader warn-onces unknown component names at runtime; the scan + // can't see the component registry here, so any component-SHAPED key + // is accepted (the audit's option C resolution). + if (isFlatComponentKey(key)) return true; for (ALLOWED_TOP_LEVEL_KEYS) |allowed| { if (std.mem.eql(u8, key, allowed)) return true; } @@ -278,8 +266,9 @@ pub const MAX_CHILDREN_DEPTH: u32 = 64; /// Covers two RFC generations: /// - RFC #594 phase 2 / engine #595: lowercase `components`, /// `children`, `prefab`, `overrides`. -/// - RFC #596 / engine #597 axis 2: any PascalCase key (a component -/// reference or declaration sitting as a sibling of `prefab`). +/// - RFC #596 / engine #597 axis 2: any flat content key: PascalCase +/// or pack-namespaced `__` component (engine#806), or +/// `@` target (engine#801), sitting as a sibling of `prefab`. /// /// File-level metadata keys (`name`, `assets`, `include`, `scripts`, /// `initial_state`, `meta`) deliberately don't count — a file that @@ -300,8 +289,7 @@ fn hasFlatEntityShapeKey(obj: std.json.ObjectMap) bool { var iter = obj.iterator(); while (iter.next()) |entry| { const key = entry.key_ptr.*; - if (isPascalCase(key)) return true; - if (isTargetKey(key)) return true; + if (isFlatComponentKey(key)) return true; if (std.mem.eql(u8, key, "components")) return true; if (std.mem.eql(u8, key, "children")) return true; if (std.mem.eql(u8, key, "prefab")) return true; @@ -316,7 +304,7 @@ fn hasFlatEntityShapeKey(obj: std.json.ObjectMap) bool { /// internally consistent. /// /// A file or entity that carries BOTH an `overrides:` / `components:` -/// wrapper AND PascalCase siblings is malformed: the walker can only +/// wrapper AND flat content siblings (component-shaped or `@` keys) is malformed: the walker can only /// descend one of the two, and silently dropping the other would lose /// data for users mid-migration (RFC #596 axis 2; engine #597 mirrors /// the same gate at every entity site). @@ -334,7 +322,7 @@ pub fn checkHybridForm(obj: std.json.ObjectMap) ?[]const u8 { var iter = obj.iterator(); while (iter.next()) |entry| { const key = entry.key_ptr.*; - if (isPascalCase(key) or isTargetKey(key)) { + if (isFlatComponentKey(key)) { has_pascal = true; } else if (std.mem.eql(u8, key, "overrides")) { has_overrides = true; @@ -632,6 +620,10 @@ pub fn scanTilemapAssets( allocator: std.mem.Allocator, source: []const u8, ) ![]const []const u8 { + // A file with a string escape the engine's JSONC parser rejects never + // loads, so it declares no tilemap. `std.json` would decode e.g. an + // escaped `Tilemap` key and fabricate a registration (codex on #798). + if (scene_keys.firstInvalidEscape(source) != null) return &.{}; const stripped = stripJsonc(allocator, source) catch return &.{}; defer allocator.free(stripped); var parsed = std.json.parseFromSlice(std.json.Value, allocator, stripped, .{}) catch return &.{}; @@ -663,6 +655,8 @@ pub fn scanScenePrefabRefs( const bytes = cwd.readFileAlloc(io, scene_path, allocator, .limited(8 * 1024 * 1024)) catch return &.{}; defer allocator.free(bytes); + // Unloadable to the engine = unparseable here (see `scanTilemapAssets`). + if (scene_keys.firstInvalidEscape(bytes) != null) return &.{}; const stripped = stripJsonc(allocator, bytes) catch return &.{}; defer allocator.free(stripped); var parsed = std.json.parseFromSlice(std.json.Value, allocator, stripped, .{}) catch return &.{}; @@ -719,6 +713,20 @@ pub fn parseSceneSource( display_path: []const u8, source: []const u8, ) ParseError!SceneManifest { + // The engine's JSONC parser decodes only `\n \t \r \b \f \\ \" \/` and + // fails the whole file on any other escape (`\uXXXX` included), while + // `std.json` below decodes `\u`. Without this check a key spelled + // `"\u0057orker"` would pass as a component here and then fail to load + // at runtime (labelle-assembler#651). + if (scene_keys.firstInvalidEscape(source)) |off| { + const loc = @import("scene_name_lint.zig").locOf(source, off); + stderrPrint( + "labelle-assembler: scene '{s}' line {d} col {d}: unsupported string escape. The engine's JSONC parser accepts only \\n \\t \\r \\b \\f \\\\ \\\" \\/ (no \\uXXXX) and would fail to load this file.\n", + .{ display_path, loc.line, loc.col }, + ); + return error.InvalidSceneJson; + } + const stripped = stripJsonc(allocator, source) catch return error.OutOfMemory; defer allocator.free(stripped); @@ -807,7 +815,7 @@ pub fn parseSceneSource( "labelle-assembler: unknown top-level key '{s}' in scene '{s}'.\n" ++ " Allowed lowercase keys: name, assets, include, entities, root, scripts, initial_state,\n" ++ " components, children, prefab, overrides (flat-form per RFC #594), meta (RFC #596).\n" ++ - " PascalCase keys are accepted as flat-form components (RFC #596 axis 2).\n" ++ + " PascalCase and __PascalCase keys are accepted as flat-form components (RFC #596 axis 2).\n" ++ " (Did-you-mean suggestions land in labelle-assembler#47.)\n", .{ entry.key_ptr.*, display_path }, ); @@ -938,120 +946,17 @@ pub fn freeManifests(allocator: std.mem.Allocator, manifests: []const SceneManif } // ── `@` target-override version gate (labelle-engine#801) ─────────────── - -/// True iff `src` uses `@` target-override syntax (labelle-engine#801). -/// Delegates to `scene_name_lint.sourceUsesTargetKeys` — the scope-aware -/// walker — so opaque component-payload keys (`{ "Config": { "@id": … } }`) -/// and `@ref` VALUES never count, while flat/wrapped `@` keys (including -/// the JSON-escaped `"\u0040…"` spelling) do. -pub fn sourceUsesTargetKeys(src: []const u8) bool { - return @import("scene_name_lint.zig").sourceUsesTargetKeys(src); -} - -/// First engine release that understands `@` target-override keys -/// (labelle-engine#801). Bump ONLY if the engine-side feature slips to a -/// later minor. -pub const MIN_ENGINE_FOR_TARGET_OVERRIDES = "2.11.0"; - -/// True iff the pinned engine version understands `@` target overrides. -/// PERMISSIVE on anything unparseable (`local:` overrides, branch pins): -/// the gate exists to catch the "new assembler, old engine pin" skew where -/// an old engine silently drops `@` keys — a pin we cannot parse is a -/// deliberate dev setup, not that trap. Compat checking is MAJOR-only -/// (labelle-cli#269), so this per-feature minimum is the only guard. The -/// comparison itself (release-vs-dev classification, `X.Y` normalization, -/// fail-closed on garbage) lives in `config.engineFeatureSupport`, shared -/// with the external-tileset gate in `tilemap_scan`. -pub fn engineSupportsTargetOverrides(engine_version: []const u8) bool { - return config.engineFeatureSupport(engine_version, MIN_ENGINE_FOR_TARGET_OVERRIDES) != .no; -} - -/// Scan every `.jsonc` under `dir` for `@` target-override keys; -/// returns the first file (allocator-owned name) that uses them, or null. -/// Missing/unreadable files are skipped — this is a version gate, not a -/// file validator (the real parse reports real errors) — EXCEPT an -/// oversized file (`error.StreamTooLong`): a file too big to scan may -/// contain the keys the gate exists to catch, so skipping it would bypass -/// the gate (CodeRabbit on #650). The 16 MiB ceiling is far above any -/// real scene/prefab; each buffer is freed before the next file is read -/// so peak memory stays one file, not the sum (codex P2 on #650). -pub fn findTargetKeyUsage( - allocator: std.mem.Allocator, - dir: []const u8, - names: []const []const u8, -) !?[]const u8 { - for (names) |name| { - const rel = try std.fmt.allocPrint(allocator, "{s}/{s}.jsonc", .{ dir, name }); - errdefer allocator.free(rel); - const source = std.Io.Dir.cwd().readFileAlloc(config.globalIo(), rel, allocator, .limited(16 * 1024 * 1024)) catch |err| switch (err) { - error.StreamTooLong => { - stderrPrint( - "labelle-assembler: '{s}' exceeds the 16 MiB scan ceiling for the target-override version gate — cannot verify it is free of `@` keys.\n", - .{rel}, - ); - return err; - }, - else => { - allocator.free(rel); - continue; - }, - }; - const used = sourceUsesTargetKeys(source); - allocator.free(source); - if (used) return rel; - allocator.free(rel); - } - return null; -} - -/// `findTargetKeyUsage` over every `.jsonc` in a directory TREE — used for -/// pack source dirs, whose file lists are not staged yet when the gate -/// runs (codex P1 / CodeRabbit on #650). A missing directory is fine -/// (packs need not ship prefabs or scenes). Returns the first offending -/// path (allocator-owned), or null. -pub fn findTargetKeyUsageInTree( - allocator: std.mem.Allocator, - dir_path: []const u8, -) !?[]const u8 { - const io = config.globalIo(); - var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return null; - defer dir.close(io); - - var iter = dir.iterate(); - while (iter.next(io) catch return null) |entry| { - switch (entry.kind) { - .directory => { - const sub = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); - defer allocator.free(sub); - if (try findTargetKeyUsageInTree(allocator, sub)) |hit| return hit; - }, - .file => { - if (!std.mem.endsWith(u8, entry.name, ".jsonc")) continue; - const rel = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); - errdefer allocator.free(rel); - const source = std.Io.Dir.cwd().readFileAlloc(io, rel, allocator, .limited(16 * 1024 * 1024)) catch |err| switch (err) { - error.StreamTooLong => { - stderrPrint( - "labelle-assembler: '{s}' exceeds the 16 MiB scan ceiling for the target-override version gate — cannot verify it is free of `@` keys.\n", - .{rel}, - ); - return err; - }, - else => { - allocator.free(rel); - continue; - }, - }; - const used = sourceUsesTargetKeys(source); - allocator.free(source); - if (used) return rel; - allocator.free(rel); - }, - else => {}, - } - } - return null; -} +// Lives in `scene_target_gate.zig` (split out to keep this file under the +// repo's 1000-line limit); re-exported so callers keep their paths. + +const target_gate = @import("scene_target_gate.zig"); +pub const sourceUsesTargetKeys = target_gate.sourceUsesTargetKeys; +pub const sourceNeedsV211Keys = target_gate.sourceNeedsV211Keys; +pub const MIN_ENGINE_FOR_TARGET_OVERRIDES = target_gate.MIN_ENGINE_FOR_TARGET_OVERRIDES; +pub const engineSupportsTargetOverrides = target_gate.engineSupportsTargetOverrides; +pub const findTargetKeyUsage = target_gate.findTargetKeyUsage; +pub const findTargetKeyUsageInTree = target_gate.findTargetKeyUsageInTree; +pub const findFlatNamespacedKeyUsageInTree = target_gate.findFlatNamespacedKeyUsageInTree; /// Read every `/.jsonc` (where `name` is one of `scene_names`, /// possibly with subfolder slashes), parse it, and return the manifest list in diff --git a/src/scene_manifest_test.zig b/src/scene_manifest_test.zig index 86a56947..123103f8 100644 --- a/src/scene_manifest_test.zig +++ b/src/scene_manifest_test.zig @@ -1182,19 +1182,6 @@ test "801: payload @ keys do not trip the gate (scope-aware detection)" { )); } -test "801: the JSON-escaped @ spelling still trips the gate" { - // `"@slot"` decodes to `@slot` at engine load — the gate must - // see through the escape or an old pin silently drops the override - // (CodeRabbit on #650). - try std.testing.expect(scene_manifest.sourceUsesTargetKeys( - \\{ "prefab": "m", "\u0040slot": { "Storage": {} } } - )); - // Escaped spelling inside an overrides wrapper too. - try std.testing.expect(scene_manifest.sourceUsesTargetKeys( - \\{ "prefab": "m", "overrides": { "\u0040slot": { "Storage": {} } } } - )); -} - test "801: digit-leading branch pins stay permissive; deep nesting fails closed" { const supports = scene_manifest.engineSupportsTargetOverrides; // `2.10.0-feature` is a PRE-RELEASE pin (isSemverVersion=false), not diff --git a/src/scene_name_lint.zig b/src/scene_name_lint.zig index c691428c..2cb4f2f4 100644 --- a/src/scene_name_lint.zig +++ b/src/scene_name_lint.zig @@ -41,6 +41,7 @@ const std = @import("std"); const check = @import("check.zig"); const scanner = @import("scanner.zig"); +const scene_keys = @import("scene_keys.zig"); // ── Inputs ──────────────────────────────────────────────────────────────── @@ -69,12 +70,14 @@ pub const builtin_component_names = [_][]const u8{ "VideoComponent", }; - // ── `@` target-override usage detection (labelle-engine#801) ────────────── /// True iff `src` uses `@` target-override syntax (labelle-engine#801): a -/// `"@"` (or JSON-escaped `"\u0040"`) object KEY at an entity or -/// component-map scope. Scope-tracked with the same `childScope` model as +/// `"@"` object KEY at an entity or component-map scope. The +/// JSON-escaped `"\u0040"` spelling does NOT count: the engine's JSONC +/// parser rejects `\u` escapes and fails the whole file on every version, so +/// there is no silent drop for the gate to catch (labelle-assembler#651, +/// `scene_keys.zig`). Scope-tracked with the same `childScope` model as /// `collectComponentRefs`, so `@`-keys inside opaque component PAYLOAD /// (`{ "Config": { "@id": "x" } }`) never count — payload keys are ordinary /// data, and a false positive here would spuriously hard-fail the @@ -89,6 +92,25 @@ pub const builtin_component_names = [_][]const u8{ /// positive here costs one loud generate error naming the file; 256 /// levels of nesting is not a real scene either way. pub fn sourceUsesTargetKeys(src: []const u8) bool { + return sourceUsesKeyFeature(src, .target); +} + +/// Key syntaxes that an older engine silently drops, so the version gate +/// looks for them. Both arrived in engine v2.11.0. +pub const KeyFeature = enum { + /// `@` target keys at entity / component-map scope (engine#801). + target, + /// Flat pack-namespaced `__` component keys at ENTITY + /// scope (engine#806, labelle-assembler#652). Inside a + /// `components`/`overrides` map they were always accepted, so only the + /// flat position counts. + flat_namespaced, +}; + +/// True iff `src` uses `feature` in a position where the engine gives it +/// meaning. Same scope walk and fail-closed depth rule as +/// `sourceUsesTargetKeys`. +pub fn sourceUsesKeyFeature(src: []const u8, comptime feature: KeyFeature) bool { var stack_buf: [256]Scope = undefined; var sp: usize = 0; var pending_key: ?[]const u8 = null; @@ -147,8 +169,13 @@ pub fn sourceUsesTargetKeys(src: []const u8) bool { const is_key = nextSignificantIsColon(src, j + 1); if (is_key) { const scope = if (sp > 0) stack_buf[sp - 1] else .entity; - if (scope == .entity or scope == .component_map) { - if (isTargetKey(content)) return true; + switch (feature) { + .target => if (scope == .entity or scope == .component_map) { + if (isTargetKey(content)) return true; + }, + .flat_namespaced => if (scope == .entity) { + if (isComponentKeyShape(content) and !scene_keys.isPascalCase(content)) return true; + }, } pending_key = content; } else { @@ -162,13 +189,6 @@ pub fn sourceUsesTargetKeys(src: []const u8) bool { return false; } -/// A `"\u0040"` key — the JSON-escaped spelling of `@` that decodes -/// to a target key at engine load (CodeRabbit on #650). Raw-byte check: -/// backslash, `u0040`, then at least one ref character. -fn isEscapedTargetKey(content: []const u8) bool { - return content.len > 6 and std.mem.startsWith(u8, content, "\\u0040"); -} - // ── Component-reference collection ───────────────────────────────────────── /// One component-declaration reference found in a scene/prefab source: the @@ -249,20 +269,11 @@ fn childArrayScope(parent: ?Scope, pending_key: ?[]const u8) Scope { return .array_other; } -/// True iff a key's first byte is an ASCII upper-case letter — the -/// PascalCase convention RFC #596 axis 2 uses to mark component keys. -fn isPascalCase(key: []const u8) bool { - return key.len > 0 and key[0] >= 'A' and key[0] <= 'Z'; -} - -/// `"@"` target-override key (labelle-engine#801) — patch structure, -/// not a component reference; its VALUE is a component map. -fn isTargetKey(key: []const u8) bool { - if (key.len > 1 and key[0] == '@') return true; - // Raw JSON-escaped spelling — same rule as - // `pack_refs/common.isTargetKey` (codex P2 on #650). - return key.len > 6 and std.mem.startsWith(u8, key, "\\u0040"); -} +/// Key classification: shared with the pack rewrite and the scene manifest +/// through `scene_keys.zig` (byte-parity with the engine, applied to raw +/// key spans; labelle-assembler#651, #652). +const isTargetKey = scene_keys.rawIsTargetKey; +const isComponentKeyShape = scene_keys.rawIsComponentKeyShape; /// True iff the next significant byte at/after `from` (skipping whitespace /// and JSONC comments) is a `:` — i.e. the preceding string literal was an @@ -375,8 +386,10 @@ pub fn collectComponentRefs(arena: std.mem.Allocator, src: []const u8) ![]CompRe // except a `@` target (labelle-engine#801), whose // value opens another component map instead. .component_map => !isTargetKey(content), - // A flat-form PascalCase key on an entity is a component. - .entity => isPascalCase(content), + // A flat-form component-shaped key on an entity is a + // component: PascalCase, or `__` since + // engine#806 (labelle-assembler#652). + .entity => isComponentKeyShape(content), else => false, }; if (is_ref) { diff --git a/src/scene_target_gate.zig b/src/scene_target_gate.zig new file mode 100644 index 00000000..40d45097 --- /dev/null +++ b/src/scene_target_gate.zig @@ -0,0 +1,155 @@ +//! `@` target-override engine-version gate (labelle-engine#801): finds +//! scene/prefab files that use `@` keys so `generate` can refuse an +//! engine pin too old to understand them. Split out of `scene_manifest.zig` +//! (which re-exports every public name here) to keep that file under the +//! 1000-line limit. + +const std = @import("std"); +const config = @import("config.zig"); +const stderrPrint = @import("scene_manifest.zig").stderrPrint; + +/// True iff `src` uses `@` target-override syntax (labelle-engine#801). +/// Delegates to `scene_name_lint.sourceUsesTargetKeys` — the scope-aware +/// walker — so opaque component-payload keys (`{ "Config": { "@id": … } }`) +/// and `@ref` VALUES never count, while flat/wrapped `@` keys do. (The +/// JSON-escaped `"\u0040…"` spelling does not: the engine's parser +/// rejects `\u` escapes outright, labelle-assembler#651.) +pub fn sourceUsesTargetKeys(src: []const u8) bool { + return @import("scene_name_lint.zig").sourceUsesTargetKeys(src); +} + +/// True iff `src` uses key syntax an engine older than +/// `MIN_ENGINE_FOR_TARGET_OVERRIDES` silently drops: `@` target keys +/// (engine#801) or flat pack-namespaced `__` component +/// keys at entity scope (engine#806, labelle-assembler#652). Both landed +/// in engine v2.11.0, so one gate covers both. +pub fn sourceNeedsV211Keys(src: []const u8) bool { + const lint = @import("scene_name_lint.zig"); + return lint.sourceUsesKeyFeature(src, .target) or lint.sourceUsesKeyFeature(src, .flat_namespaced); +} + +/// First engine release that understands `@` target-override keys +/// (labelle-engine#801); flat pack-namespaced keys (engine#806) shipped in +/// the same release, so the gate covers both. Bump ONLY if the engine-side +/// feature slips to a later minor. +pub const MIN_ENGINE_FOR_TARGET_OVERRIDES = "2.11.0"; + +/// True iff the pinned engine version understands `@` target overrides. +/// PERMISSIVE on anything unparseable (`local:` overrides, branch pins): +/// the gate exists to catch the "new assembler, old engine pin" skew where +/// an old engine silently drops `@` keys — a pin we cannot parse is a +/// deliberate dev setup, not that trap. Compat checking is MAJOR-only +/// (labelle-cli#269), so this per-feature minimum is the only guard. The +/// comparison itself (release-vs-dev classification, `X.Y` normalization, +/// fail-closed on garbage) lives in `config.engineFeatureSupport`, shared +/// with the external-tileset gate in `tilemap_scan`. +pub fn engineSupportsTargetOverrides(engine_version: []const u8) bool { + return config.engineFeatureSupport(engine_version, MIN_ENGINE_FOR_TARGET_OVERRIDES) != .no; +} + +/// Scan every `.jsonc` under `dir` for `@` target-override keys; +/// returns the first file (allocator-owned name) that uses them, or null. +/// Missing/unreadable files are skipped — this is a version gate, not a +/// file validator (the real parse reports real errors) — EXCEPT an +/// oversized file (`error.StreamTooLong`): a file too big to scan may +/// contain the keys the gate exists to catch, so skipping it would bypass +/// the gate (CodeRabbit on #650). The 16 MiB ceiling is far above any +/// real scene/prefab; each buffer is freed before the next file is read +/// so peak memory stays one file, not the sum (codex P2 on #650). +pub fn findTargetKeyUsage( + allocator: std.mem.Allocator, + dir: []const u8, + names: []const []const u8, +) !?[]const u8 { + for (names) |name| { + const rel = try std.fmt.allocPrint(allocator, "{s}/{s}.jsonc", .{ dir, name }); + errdefer allocator.free(rel); + const source = std.Io.Dir.cwd().readFileAlloc(config.globalIo(), rel, allocator, .limited(16 * 1024 * 1024)) catch |err| switch (err) { + error.StreamTooLong => { + stderrPrint( + "labelle-assembler: '{s}' exceeds the 16 MiB scan ceiling for the target-override version gate — cannot verify it is free of `@` keys.\n", + .{rel}, + ); + return err; + }, + else => { + allocator.free(rel); + continue; + }, + }; + const used = sourceNeedsV211Keys(source); + allocator.free(source); + if (used) return rel; + allocator.free(rel); + } + return null; +} + +/// `findTargetKeyUsage` over every `.jsonc` in a directory TREE — used for +/// pack SOURCE dirs, whose file lists are not staged yet when the gate +/// runs (codex P1 / CodeRabbit on #650). `@` keys only: a source prefab's +/// flat namespaced keys may still be wrapped by the pack rewrite (pass 1), +/// so those are checked on the staged copies instead +/// (`findFlatNamespacedKeyUsageInTree`, codex on #798). A missing +/// directory is fine (packs need not ship prefabs or scenes). Returns the +/// first offending path (allocator-owned), or null. +pub fn findTargetKeyUsageInTree(allocator: std.mem.Allocator, dir_path: []const u8) !?[]const u8 { + return findKeyUsageInTree(allocator, dir_path, sourceUsesTargetKeys); +} + +/// Flat pack-namespaced component keys (engine#806) in an already-STAGED, +/// rewritten pack tree (`/packs`). Run after `loadPackScans`, so +/// entities pass 1 wrapped (which load on old engines) no longer count. +pub fn findFlatNamespacedKeyUsageInTree(allocator: std.mem.Allocator, dir_path: []const u8) !?[]const u8 { + return findKeyUsageInTree(allocator, dir_path, sourceUsesFlatNamespacedKeys); +} + +/// True iff `src` has a flat `__` key at entity scope. +pub fn sourceUsesFlatNamespacedKeys(src: []const u8) bool { + return @import("scene_name_lint.zig").sourceUsesKeyFeature(src, .flat_namespaced); +} + +fn findKeyUsageInTree( + allocator: std.mem.Allocator, + dir_path: []const u8, + comptime uses: fn ([]const u8) bool, +) !?[]const u8 { + const io = config.globalIo(); + var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return null; + defer dir.close(io); + + var iter = dir.iterate(); + while (iter.next(io) catch return null) |entry| { + switch (entry.kind) { + .directory => { + const sub = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); + defer allocator.free(sub); + if (try findKeyUsageInTree(allocator, sub, uses)) |hit| return hit; + }, + .file => { + if (!std.mem.endsWith(u8, entry.name, ".jsonc")) continue; + const rel = try std.fs.path.join(allocator, &.{ dir_path, entry.name }); + errdefer allocator.free(rel); + const source = std.Io.Dir.cwd().readFileAlloc(io, rel, allocator, .limited(16 * 1024 * 1024)) catch |err| switch (err) { + error.StreamTooLong => { + stderrPrint( + "labelle-assembler: '{s}' exceeds the 16 MiB scan ceiling for the target-override version gate — cannot verify it is free of `@` keys.\n", + .{rel}, + ); + return err; + }, + else => { + allocator.free(rel); + continue; + }, + }; + const used = uses(source); + allocator.free(source); + if (used) return rel; + allocator.free(rel); + }, + else => {}, + } + } + return null; +}