Skip to content

packs: invisible <pack>__ namespacing + hooks scan + local→prefixed .jsonc rewrite (#440) - #480

Merged
apotema merged 4 commits into
mainfrom
packs/namespacing
Jul 1, 2026
Merged

apotema merged 4 commits into
mainfrom
packs/namespacing

Conversation

@apotema

@apotema apotema commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Implements #440 (Packs Phase 4, RFC §4 + §10 Q3). Part of #651.

Builds on #439's pack dir-scan. Pack-contributed items are now registered in the generated global registry under an invisible <pack>__<Name> prefix, so a pack and the game root (or another pack) that both define e.g. Worker can no longer collide — while authors keep writing bare local names.

Prefix scheme

scan.packNamespacePrefix(pack_name) derives the ident (sanitized like plugin idents, mirroring the existing <plugin>__event convention). Every registry ident a pack contributes is prefixed <pfx>__:

item before (#439) after (#440)
component field .Worker = @import("packs/citizens/components/Worker.zig").Worker .citizens__Worker = @import(…).Worker
event import alias + variant worker_died: worker_died.WorkerDied citizens__worker_died: citizens__worker_died.WorkerDied
prefab registration key addEmbeddedPrefab(&g, "worker", …) addEmbeddedPrefab(&g, "citizens__worker", …)
hook import alias / instance (not scanned) const citizens__overlay = @import(…), *citizens__overlay.Overlay, &citizens__overlay_inst

The imported decl name stays bare — it's the component type's own pub const Worker inside the pack file. Only the registry-facing name is prefixed.

Local → prefixed ref rewrite

This is what keeps the prefix invisible. During scanPack, after copying a pack's prefabs/, rewritePackPrefabRefs → scan.rewritePackComponentKeys rewrites the copied JSONC so a pack author's local component key ("Worker": {…}) becomes "citizens__Worker", byte-matching the emitted registry field. Only JSON object keys that exactly equal one of the pack's own scanned components are rewritten — built-in/engine names (Position, Sprite), string values, and JSONC comment text are left untouched. Rewrite runs against the copied (destination) files, so the source pack tree is never mutated.

hooks/ scanning

#439 deferred hooks/ "wanting the prefix first". scanPack now scans hooks/*.zig and registers them into the same GameHooks receiver pipeline as the game root's hooks — import + receiver-type tuple + per-instance init — all under the <pack>__ ident prefix, with consistent ordering (game hooks → pack hooks → flow handlers) across the type tuple and the .receivers literal.

Save-stable name

Documented near the prefix derivation (scan.packNamespacePrefix): the prefixed component name is the on-disk save key (serde.componentName, engine-side), so a pack's name is save-stable — renaming a shipped pack changes every component's save key and is therefore a save migration, not a cosmetic rename.

Tests

test/pack_scan_tests.zig:

  • existing emission tests updated to the prefixed forms (component / event / prefab)
  • new: scanPack rewrites a prefab's local component ref to citizens__Worker while leaving Position and value/"Worker" alone — and the same pack drives the registry to .citizens__Worker (end-to-end)
  • new: pack hook is imported, wired into GameHooks, and instantiated under the prefix
  • SCAN_PACK now asserts hook_names is populated

src/codegen/scan.zig: unit tests for packNamespacePrefix and rewritePackComponentKeys (keys-only, comment-safe, value-safe, no-match dupe).

zig build + zig build test pass.

Deferred (clean seams, unchanged)

Summary by CodeRabbit

  • New Features
    • Pack hooks are now scanned and included in generated hook wiring, including namespaced/aliased hook imports, receiver wiring, and pack-owned hook handler renaming.
  • Bug Fixes
    • Prevented collisions between pack and game-root components/events/prefabs by using namespaced aliases/keys (including embedded prefab registrations and prefab local JSONC references).
    • Added generation-time collision validation for pack prefixes and emitted identifiers.
  • Tests
    • Expanded pack scanning and emission tests to cover hooks, namespaced registry fields/variants, and embedded prefab key/reference rewriting.

…ewrite

Packs Phase 4 (RFC §4): pack-contributed component / prefab / pack-local
event / hook names are registered in the generated global registry under
the invisible `<pack>__<Name>` prefix (derived from the pack manifest name,
mirroring `<plugin>__event`). Authors never type the prefix.

- Prefix derivation `scan.packNamespacePrefix` with the save-stability note:
  the prefixed name is the save key (serde.componentName), so a pack `name`
  is save-stable (rename = migration).
- Local→prefixed ref rewrite: `scanPack` rewrites a pack's own copied prefab
  JSONC so local component keys (`"Worker"`) become `"citizens__Worker"`,
  matching the emitted registry field. Only pack-owned component KEYS are
  rewritten; built-ins (`Position`), string values, and comments are left
  alone (`scan.rewritePackComponentKeys`).
- hooks/ scanning (#439 deferred this): `scanPack` now scans `hooks/*.zig`;
  pack hooks are imported, added to the `GameHooks` receiver tuple, and
  instantiated under the `<pack>__` ident prefix.
- Component registry field, event import alias + union variant, prefab
  registration key, and hook idents are all prefixed consistently.

Tests: pack_scan_tests emission tests updated to the prefixed forms; new
tests cover the prefab ref rewrite (end-to-end with the registry), pack hook
emission, and the pure rewrite helper (keys-only, comment/value safety).

Part of #651
@coderabbitai

coderabbitai Bot commented Jul 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds pack-scoped <pack>__ namespacing across scan-time rewrites, generated imports and registries, hook wiring, event variants, embedded prefab registration, and prefix-collision validation, with tests updated to match the new emitted forms.

Changes

Pack Namespacing Implementation

Layer / File(s) Summary
Namespacing utilities and source rewrites
src/codegen/scan.zig
Adds hook_names to PackScan, packNamespacePrefix, JSONC local-reference rewriting, Zig hook-handler renaming, and tests for the new helpers.
Prefix collision validation
src/pack_validate.zig
Adds prefix-collision checking based on sanitized pack names and tests for collision and non-collision cases.
scanPack hook and prefab rewrites
src/root.zig
Scans hooks/, rewrites copied prefab references, rewrites copied hook handlers, and returns the expanded pack scan data.
Prefixed codegen emitters
src/codegen/context.zig, src/codegen/blocks/events.zig, src/codegen/blocks/hooks.zig, src/codegen/blocks/imports.zig, src/codegen/blocks/registries.zig
Emits pack-prefixed event variants, hook imports and receivers, and component registry fields, and adds the pack-hook predicate.
Embedded prefab registration namespacing
src/codegen/lifecycle/callback.zig, src/codegen/lifecycle/loop.zig
Registers embedded prefabs under prefixed runtime keys and updates the related documentation comments.
Pack scan and emission test coverage
test/pack_scan_tests.zig
Extends scan and emission assertions for hooks, prefixed rewrites, and prefixed generated outputs.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Poem

A rabbit hopped through packy streams,
With <pack>__ names in tidy beams.
Hooks and prefabs found their place,
No collisions in the race.
Little paws made names agree,
Hop-hop, now everything is free. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: pack namespacing, hook scanning, and JSONC rewrite.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch packs/namespacing

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/codegen/blocks/hooks.zig`:
- Around line 131-143: Pack names can collide after sanitization in the hooks
codegen path, causing duplicate `<pack>__...` symbols and broken generated
imports/tuples. Add a cross-pack uniqueness check during pack scanning, before
`writeHooksInitBlock`/the `self.pack_scans` loop in `hooks.zig`, using
`scan.packNamespacePrefix`-compatible sanitized prefixes so names like `my-pack`
and `my_pack` are rejected or surfaced as conflicts. Keep the check near the
pack collection/build step so `pack.name` collisions are caught before
`packNamespacePrefix`, `pathToIdent`, and `w.print` generate output.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81a1a3a6-cf1e-4bdd-88c5-64b677f4f93b

📥 Commits

Reviewing files that changed from the base of the PR and between 2118b04 and 040aa19.

📒 Files selected for processing (10)
  • src/codegen/blocks/events.zig
  • src/codegen/blocks/hooks.zig
  • src/codegen/blocks/imports.zig
  • src/codegen/blocks/registries.zig
  • src/codegen/context.zig
  • src/codegen/lifecycle/callback.zig
  • src/codegen/lifecycle/loop.zig
  • src/codegen/scan.zig
  • src/root.zig
  • test/pack_scan_tests.zig

Comment thread src/codegen/blocks/hooks.zig

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 040aa19abe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

for (pack.prefab_names) |name| {
const display = std.fs.path.basename(name);
try w.print(" try JsoncBridge.addEmbeddedPrefab(&g, \"{s}\", @embedFile(\"{s}/prefabs/{s}.jsonc\"), \"{s}/prefabs\");\n", .{ display, pack.import_prefix, name, pack.import_prefix });
try w.print(" try JsoncBridge.addEmbeddedPrefab(&g, \"{s}__{s}\", @embedFile(\"{s}/prefabs/{s}.jsonc\"), \"{s}/prefabs\");\n", .{ prefix, display, pack.import_prefix, name, pack.import_prefix });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rewrite local pack prefab references too

When a pack prefab refers to another prefab from the same pack with a normal JSONC reference like { "prefab": "worker" }, this registration no longer creates a worker entry: only citizens__worker is added. The new rewritePackPrefabRefs pass only rewrites component keys, not prefab string values, so pack-local prefab composition will still look up the old bare name and fail at runtime; either rewrite pack-owned prefab references alongside component keys or keep a bare alias for local refs.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig Outdated
Comment on lines +180 to +181
const is_key = nextSignificantIsColon(src, j + 1);
if (is_key and containsKey(local_keys, content)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit prefab rewrites to component-reference positions

This rewrites any JSON object key whose text matches a pack-owned component, even when that key is part of component payload data rather than a component declaration. For example, if the pack owns Worker and a prefab contains { "components": { "Spawner": { "counts": { "Worker": 3 } } } }, the nested data key becomes citizens__Worker, changing the serialized payload before the component deserializer sees it. The rewrite needs to track JSONC context and only touch component-reference maps/flat entity component sites.

Useful? React with 👍 / 👎.

Comment on lines 162 to +164
const ident = eventVariantName(name);
const pascal = pathToPascal(name, pascal_buf);
try w.print(" {s}: {s}.{s},\n", .{ ident, ident, pascal });
try w.print(" {s}__{s}: {s}__{s}.{s},\n", .{ prefix, ident, prefix, ident, pascal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve local handler names for pack events

Pack event tags are now emitted as citizens__worker_died, but pack hooks are imported as their original structs with no adapter or method-name rewrite. The existing hook contract requires handler function names to match GameEvents variant tags, so a pack that naturally defines pub fn worker_died(...) for its own events/worker_died.zig will not receive the event unless the author manually types the supposedly invisible prefix. Either keep pack-local hook dispatch names local or generate a wrapper that maps local handler names to the prefixed tags.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig Outdated
Comment on lines +180 to +181
const is_key = nextSignificantIsColon(src, j + 1);
if (is_key and containsKey(local_keys, content)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit prefab rewrites to component-reference positions

This rewrites any JSON object key whose text matches a pack-owned component, even when that key is part of component payload data rather than a component declaration. For example, if the pack owns Worker and a prefab contains { "components": { "Spawner": { "counts": { "Worker": 3 } } }, the nested data key becomes citizens__Worker, changing the serialized payload before the component deserializer sees it. The rewrite needs to track JSONC context and only touch component-reference maps/flat entity component sites.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig
Comment on lines +110 to +111
pub fn packNamespacePrefix(pack_name: []const u8, buf: *[128]u8) []const u8 {
return sanitizePluginIdent(pack_name, buf);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject colliding sanitized pack prefixes

Different pack names can sanitize to the same namespace prefix here, for example my-pack and my_pack both become my_pack. If those packs both ship a local Worker component, worker prefab, or worker_died event, the generated supposedly namespaced identifiers/registry keys collide again (my_pack__Worker, my_pack__worker, etc.), defeating the cross-pack collision fix. Add a generate-time uniqueness check or use an injective escape for pack prefixes.

Useful? React with 👍 / 👎.

…ck names

Sync past #479 (pack manifest early-gate + PackEntry/pack_entries reuse).
Conflict in src/root.zig resolved keeping BOTH: #479's early
load+dependency-validation gate and pack_entries reuse in the pack-scan
loop, AND #480's namespacing (scanPack hooks/ scan, <pack>__ prefix
derivation, .jsonc key rewrite).

CodeRabbit (Major): add generate-time pack-name collision gate. Two packs
whose names sanitize to the same <pack>__ prefix (e.g. my-pack / my_pack)
would emit duplicate namespaced symbols and break the generated
imports/registries/hook tuples. New pure pack_validate.checkPrefixCollisions
(uses scan.packNamespacePrefix) runs alongside the depends_on gate in
root.zig over pack_entries' plugin names; fails with
error.PackNamePrefixCollision naming both packs. Tests added.
@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Synced to current main and addressed the CodeRabbit finding.

Sync past #479. git merge origin/main — one conflict, in src/root.zig, and it was confined to a comment block in the pack-scan header. Git auto-merged the code correctly, keeping both sides: #479's early pack-manifest load + depends_on validation gate with the PackEntry/pack_entries parse-once-and-reuse structure, and #480's namespacing (scanPack's hooks/ scan, <pack>__ prefix derivation, .jsonc local-ref rewrite). The pack-scan loop still iterates pack_entries (no re-parse) and applies prefixing/hooks. Resolved the comment to describe both gates; zig build clean.

CodeRabbit (Major) — reject colliding pack names before codegen. Added a generate-time gate: two packs whose names sanitize to the same <pack>__ prefix (e.g. my-pack and my_pack → my_pack) would emit duplicate namespaced symbols and break the generated imports/registries/hook tuples.

  • New pure, unit-testable pack_validate.checkPrefixCollisions([]const []const u8) (src/pack_validate.zig) computes each pack's prefix via the same scan.packNamespacePrefix helper and fails with error.PackNamePrefixCollision, naming both offending packs.
  • Wired into generate() in src/root.zig right after the pack_validate.validate depends_on gate, over pack_entries' plugin names — the same value fed to scanPack → PackScan.name → the codegen prefix, so the gate matches the symbols actually emitted. Runs before any target dir is written.

Verification. zig build + zig build test both pass (exit 0). Added tests: names that sanitize to the same prefix (my-pack/my_pack) and exact duplicates are rejected with the collision error; distinct prefixes and empty/single sets accepted. Standalone zig test src/pack_validate.zig → all 30 pass.

Current status: mergeable: MERGEABLE, mergeStateStatus: UNSTABLE (CI in progress).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/pack_validate.zig (1)

196-221: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

reportCycle bypasses the threaded allocator.

checkAcyclic/visit explicitly thread an allocator param, but reportCycle hardcodes std.heap.page_allocator for the diagnostic string instead of accepting the same allocator. It's error-path-only code so impact is minor, but it's an inconsistent allocator contract, and the catch {} on appendSlice silently swallows OOM, potentially producing a truncated cycle message.

♻️ Thread the allocator through
-fn reportCycle(packs: []const PackDep, path: []const usize, back_to: usize) void {
+fn reportCycle(allocator: std.mem.Allocator, packs: []const PackDep, path: []const usize, back_to: usize) void {
     ...
-    var buf: std.ArrayList(u8) = .empty;
-    defer buf.deinit(std.heap.page_allocator);
+    var buf: std.ArrayList(u8) = .empty;
+    defer buf.deinit(allocator);
     for (path[start..]) |node| {
-        buf.appendSlice(std.heap.page_allocator, packs[node].name) catch {};
-        buf.appendSlice(std.heap.page_allocator, " -> ") catch {};
+        buf.appendSlice(allocator, packs[node].name) catch {};
+        buf.appendSlice(allocator, " -> ") catch {};
     }
-    buf.appendSlice(std.heap.page_allocator, packs[back_to].name) catch {};
+    buf.appendSlice(allocator, packs[back_to].name) catch {};

(and pass allocator at the call site in visit)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pack_validate.zig` around lines 196 - 221, `reportCycle` is hardcoding
`std.heap.page_allocator` and swallowing allocation failures, which breaks the
allocator flow used by `checkAcyclic` and `visit`. Update `reportCycle` to
accept the same `allocator` parameter, pass it from the `visit` call site, and
use it for the `std.ArrayList` appends/deinit so the diagnostic string is built
consistently. Also avoid silent `catch {}` on `appendSlice`; handle allocation
errors explicitly so cycle reporting does not truncate without notice.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/pack_validate.zig`:
- Around line 196-221: `reportCycle` is hardcoding `std.heap.page_allocator` and
swallowing allocation failures, which breaks the allocator flow used by
`checkAcyclic` and `visit`. Update `reportCycle` to accept the same `allocator`
parameter, pass it from the `visit` call site, and use it for the
`std.ArrayList` appends/deinit so the diagnostic string is built consistently.
Also avoid silent `catch {}` on `appendSlice`; handle allocation errors
explicitly so cycle reporting does not truncate without notice.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a3804867-b6aa-44b9-866b-8c7456eded1e

📥 Commits

Reviewing files that changed from the base of the PR and between 040aa19 and 0adf345.

📒 Files selected for processing (2)
  • src/pack_validate.zig
  • src/root.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/root.zig

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0adf34572c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const ident = eventVariantName(name);
const pascal = pathToPascal(name, pascal_buf);
try w.print(" {s}: {s}.{s},\n", .{ ident, ident, pascal });
try w.print(" {s}__{s}: {s}__{s}.{s},\n", .{ prefix, ident, prefix, ident, pascal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep pack event tags out of plugin namespace

In projects that declare a pack whose sanitized name matches an existing plugin/engine event prefix, this emits pack events into the same tag namespace as PluginEvents. For example, discoverPluginEvents reserves engine tags like engine__tick, so a pack named engine with events/tick.zig now also emits engine__tick; when GameEventsRaw is merged with PluginEvents, the duplicate union tag breaks the build. The new prefix-collision check only compares packs to other packs, so either reserve/check pack prefixes against plugin/engine event prefixes or use a pack-specific namespace.

Useful? React with 👍 / 👎.

const prefix = scan.packNamespacePrefix(pack.name, &pack_prefix_buf);
for (pack.hook_names) |name| {
const ident = pathToIdent(name, ident_buf);
try w.print("const {s}__{s} = @import(\"{s}/hooks/{s}.zig\");\n", .{ prefix, ident, pack.import_prefix, name });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid reusing event aliases for pack hooks

When a pack contains both an event and a hook with the same plain stem, such as events/hit.zig and hooks/hit.zig, this hook import emits const citizens__hit = ..., and the pack event import later emits the same top-level alias for the event module. That makes an otherwise natural pack layout fail to compile as soon as hooks are scanned; use a disjoint alias namespace for hooks or reject these collisions during pack scan.

Useful? React with 👍 / 👎.

Comment thread src/root.zig
var pack_names: std.ArrayList([]const u8) = .empty;
defer pack_names.deinit(allocator);
try pack_names.ensureTotalCapacity(allocator, pack_entries.items.len);
for (pack_entries.items) |e| pack_names.appendAssumeCapacity(e.plugin.name);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reserve pack prefixes against root names too

This validation only compares pack names with other pack names, so a game root can still define an already-prefixed name that collides with the generated pack key, e.g. prefabs/citizens__worker.jsonc with pack citizens/prefabs/worker.jsonc, or events/citizens__hit.zig with pack citizens/events/hit.zig. Generation then emits the same prefab registration key or event tag twice; include the scanned root event/prefab names in the reserved-prefix check before emitting.

Useful? React with 👍 / 👎.

Fix three correctness bugs found by chatgpt-codex in the <pack>__
namespacing feature (PR #480):

1. Pack-local prefab references were not rewritten. A pack prefab that
   composes another same-pack prefab via `{ "prefab": "worker" }` broke:
   only `citizens__worker` is registered while the reference stayed bare
   `worker`. rewritePackPrefabRefs now rewrites `"prefab"` value sites
   that name a pack-owned prefab (leaving foreign refs alone), and its
   guard is gated on prefab count — not component count — so a
   component-less pack with prefab-to-prefab refs is still rewritten.

2. Component-key rewrite corrupted payload data. The old rewrite matched
   ANY object key equal to a pack component name, including payload keys
   nested inside a component value (e.g. Spawner.counts.Worker). The
   rewriter is now context-aware: it tracks object nesting and only
   rewrites keys that are direct members of a `components`/`overrides`
   map. Documented boundary: the flat RFC #596 shape is intentionally
   not rewritten (conservative-but-correct, never corrupts payloads).

3. Pack hook handlers no longer matched prefixed event tags. Pack events
   fold into GameEvents as `<pack>__<event>`, but a pack hook's natural
   `pub fn worker_died` would (a) never receive the event and (b) hard
   @CompileError in the dispatcher's handler-name check. The copied hook
   source is now rewritten so a qualifying handler (pub, 2 params, name ==
   a pack event) is renamed to the prefixed tag, keeping the prefix
   invisible while engine/plugin-event handlers keep their bare names.

Consolidated the JSONC rewrite into a single context-aware
rewritePackLocalRefs pass (rewritePackComponentKeys kept as a shim).
Extends test/pack_scan_tests.zig + scan.zig unit tests for all three.
@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

codex findings addressed (3/3) — 8ddda4f

Fixed all three pack-namespacing correctness bugs. zig build + zig build test pass.

1. Pack-local prefab references not rewritten — FIXED

rewritePackPrefabRefs only rewrote component keys, never "prefab" values. A pack prefab composing a same-pack prefab ({ "prefab": "worker" }) referenced the bare worker, but only citizens__worker is registered → broken lookup.

  • scan.zig: new context-aware rewritePackLocalRefs also rewrites a "prefab" value naming a pack-owned prefab → citizens__worker; foreign/game-root refs left bare.
  • root.zig: guard changed from component_names.len == 0 or prefab_names.len == 0 → prefab_names.len == 0, so a component-less pack with prefab-to-prefab refs is still rewritten.

2. Component-key rewrite corrupts payload data — FIXED

The old rewrite namespaced any object key equal to a pack component name, including payload keys nested inside a component value ({ "Spawner": { "counts": { "Worker": 3 } } } → corrupted). Now context-aware: tracks object nesting and only rewrites keys that are direct members of a "components"/"overrides" map (the wrapped shape the engine's entityPatch/prefabComponents treat as the component map).

3. Pack hook handlers don't match prefixed event tags — FIXED

Pack events fold into GameEvents as <pack>__<event>, but a pack hook's natural pub fn worker_died (a) never receives the event and (b) hard @compileErrors in labelle-core/src/dispatcher.zig's handler-name check (a 2-param decl matching no variant is a compile error, not a silent no-op). Fix mirrors the JSONC rewrite: the copied hook source is rewritten so a qualifying handler (pub, exactly 2 params, name == a pack event) is renamed worker_died → citizens__worker_died. Engine/plugin-event handlers (e.g. tick) keep their bare names, so those still dispatch. AST-based rename (only decl sites) keeps the prefix invisible to the author.

Tests

  • scan.zig unit tests: payload-key-not-rewritten vs real-key-rewritten; overrides wrapper; pack-local prefab ref rewritten + foreign ref untouched; hook handler rename (bare pack event renamed, engine tick untouched, private helper untouched).
  • test/pack_scan_tests.zig end-to-end via scanPack: same-pack prefab ref rewritten in copied squad.jsonc; hook pub fn worker_died → pub fn citizens__worker_died in copied overlay.zig.

Note: consolidated the JSONC rewrite into one context-aware rewritePackLocalRefs pass; rewritePackComponentKeys kept as a thin shim for existing callers/tests.

@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements namespacing for pack-contributed components, events, prefabs, and hooks under an invisible <pack>__ prefix to prevent naming collisions between different packs and the game root. It introduces automated rewriting of local references in copied prefab JSONC files and hook handler names in Zig source files during the scan phase. Additionally, a validation gate is added to detect and reject packs with colliding sanitized namespace prefixes. As there are no review comments provided, I have no feedback to evaluate.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/codegen/scan.zig`:
- Around line 428-435: The hook-name collection in the members scan is too broad
and is matching any public two-parameter function, including top-level helpers
and nested helper structs. Update the logic around the members loop and
ast.fullFnProto to scope renames to the actual hook receiver type by passing the
hook stem/Pascal receiver into the rewrite and only collecting names within that
container. Keep using event_names and fnProtoParamCount, but add a containment
check tied to the receiver type before treating a function as an event handler.
- Around line 222-224: The scan logic in the object handling path is incorrectly
treating nested payload “overrides” fields as component maps, so payload data
can be rewritten as component names. Update the object-state tracking in
scan.zig around the component-map detection in the code that uses pending_key,
and gate “overrides” so it only opens a component map when the current object is
a prefab/entity patch scope, not when the current value is already a component
payload. Apply the same fix in the related nested-object handling section
referenced by the second occurrence so that component payload values like
Spawner.overrides are preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ba49ef4a-9d36-4925-9803-66e59741fff5

📥 Commits

Reviewing files that changed from the base of the PR and between 0adf345 and 8ddda4f.

📒 Files selected for processing (3)
  • src/codegen/scan.zig
  • src/root.zig
  • test/pack_scan_tests.zig
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/pack_scan_tests.zig

Comment thread src/codegen/scan.zig Outdated
Comment thread src/codegen/scan.zig Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ddda4f7d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/root.zig
};
defer allocator.free(src);

const rewritten = try scan.rewritePackLocalRefs(allocator, src, keys.items, prefab_names, prefix);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use registered basenames when rewriting pack prefab refs

For pack prefabs in subdirectories, scanner records names like enemies/goblin, but the lifecycle registration uses std.fs.path.basename(name) and registers only citizens__goblin. Passing the raw prefab_names here means a normal reference { "prefab": "goblin" } is left bare and no longer resolves after namespacing, while { "prefab": "enemies/goblin" } is rewritten to citizens__enemies/goblin, which is not the registered key either. This breaks same-pack composition for nested pack prefabs; rewrite against the same basename keys used by addEmbeddedPrefab or register the full names.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig Outdated
// Value position: rewrite a pack-owned prefab reference
// (`"prefab": "worker"` → `"prefab": "citizens__worker"`).
if (pending_key) |k| {
if (std.mem.eql(u8, k, "prefab") and containsKey(prefab_names, content)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope prefab-value rewrites to entity references

When a component payload contains its own field named prefab, for example { "components": { "Spawner": { "prefab": "worker" } } }, this condition rewrites the string to citizens__worker whenever the pack also owns a worker prefab. That field is component data, not an entity reference site, so the component deserializer now receives changed payload; track the JSONC entity/reference context before rewriting prefab values, similar to the component-key context guard.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig Outdated
if (fp.visib_token != null) {
if (fp.name_token) |nt| {
const name = ast.tokenSlice(nt);
if (containsKey(event_names, name) and fnProtoParamCount(ast, fp) == 2) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match hook rewrites against event variant basenames

For pack events under subdirectories, event_names contains the path such as combat/worker_died, but the emitted event tag uses eventVariantName(name) and becomes citizens__worker_died. A natural pack hook pub fn worker_died(...) will not satisfy this raw containsKey(event_names, name) check, so it stays unprefixed and fails to receive the pack event; compare against the same basename variant names used during event emission.

Useful? React with 👍 / 👎.

const ident = eventVariantName(name);
const pascal = pathToPascal(name, pascal_buf);
try w.print(" {s}: {s}.{s},\n", .{ ident, ident, pascal });
try w.print(" {s}__{s}: {s}__{s}.{s},\n", .{ prefix, ident, prefix, ident, pascal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make pack event tags injective across delimiters

When pack names or event stems contain the __ separator, distinct pack-local events can still collapse to the same generated tag: pack a with events/b__hit.zig and pack a__b with events/hit.zig both emit a__b__hit here. The new prefix-collision gate allows those prefixes because a and a__b differ, so generation proceeds until duplicate event aliases/union tags break the build; escape one side injectively or validate the full generated event names after scanning.

Useful? React with 👍 / 👎.

Root cause 1 (JSONC rewrite context, CR L224 / codex L288):
rewritePackLocalRefs now tracks a precise object Scope (entity /
component_map / payload / array_*) instead of a bool component-map
flag. A `components`/`overrides` object only opens a component map when
its parent is an ENTITY scope, and a `prefab` value is only an entity
reference at ENTITY scope — so nested `overrides`/`prefab` inside a
component payload (`{ "Spawner": { "overrides": { "Worker": 3 } } }`,
`{ "Spawner": { "prefab": "worker" } }`) is no longer corrupted.

Root cause 2 (subdir pack items use basenames, codex L704 / L435):
prefab refs are matched + rewritten by BASENAME (`enemies/goblin` and
bare `goblin` both -> `<pack>__goblin`), matching addEmbeddedPrefab's
`std.fs.path.basename` key; the hook-rename event match compares
handler names against `eventVariantName(name)` (basename), so a subdir
event `combat/worker_died` matches `pub fn worker_died`.

Root cause 3 (hook rename too broad, CR L435):
rewritePackHookHandlerNames takes the hook stem and scopes the rename
to the DIRECT members of the receiver container (`pathToPascal(stem)`
struct) — top-level helpers / unrelated helper structs are never
renamed.

Root cause 4 (`<pack>__<event>` not injective, codex events L164):
new generate-time gate pack_validate.checkEmittedNameCollisions
validates the fully-qualified component/event/prefab names emitted
across the game root + every pack, rejecting duplicates (e.g. pack `a`
+`b__hit` vs pack `a__b`+`hit` both -> `a__b__hit`, and subdir prefab
basename clashes). Chose validation over delimiter-escaping to keep
save keys / emitted symbols stable — noted in the fn doc.

Tests: nested overrides/prefab-in-payload not rewritten; subdir prefab
ref + subdir event hook resolve to basename-prefixed names; top-level
helper pub fn not renamed; a/a__b full-name collision rejected.
@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Namespacing edge-case fixes (CodeRabbit + codex) — pushed in db8a3ca

Confirmed each finding against the code, fixed the root causes so they converge, and extended the test suite. zig build + zig build test green.

Root cause 1 — JSONC rewrite context (CR L224 / codex L288)

rewritePackLocalRefs now tracks a precise object Scope stack (entity / component_map / payload / array_entities / array_other) instead of a single "is component map" bool (src/codegen/scan.zig). A components/overrides object opens a component map only when its parent is an entity scope, and a "prefab" value is treated as an entity reference only at entity scope. So:

  • { "Spawner": { "overrides": { "Worker": 3 } } } — nested overrides is payload, Worker left alone.
  • { "components": { "Spawner": { "prefab": "worker" } } } — payload field prefab left bare.

Matches the engine's unified_format.zig (components/overrides/prefab/children on entities/patches).

Root cause 2 — subdir pack items (codex L704 / L435)

  • Prefabs: matched + rewritten by basename (prefabBasenameMatch), so both { "prefab": "goblin" } and { "prefab": "enemies/goblin" } resolve to citizens__goblin — the exact key addEmbeddedPrefab registers via std.fs.path.basename (src/codegen/scan.zig).
  • Events: the hook-rename event match now compares handler names against eventVariantName(name) (basename) via matchesEventBasename, so a pack event scanned as combat/worker_died recognizes pub fn worker_died.

Root cause 3 — hook rename too broad (CR L435)

rewritePackHookHandlerNames now takes the hook stem and confines the rename to the direct members of the receiver container (pathToPascal(stem) struct — the exact type GameHooks references). Top-level pub fns and unrelated helper structs are no longer renamed, and internal calls stay valid (src/codegen/scan.zig, caller in src/root.zig).

Root cause 4 — <pack>__<event> not injective (codex events L164)

Chose a generate-time validation over escaping the __ delimiter (delimiter-escaping would churn every save key + emitted symbol; noted in the fn doc). New pack_validate.checkEmittedNameCollisions validates the fully-qualified component / event / prefab identifiers emitted across the game root + every pack and rejects duplicates before any target is written (src/pack_validate.zig, wired in src/root.zig after the pack scan). Catches a+b__hit vs a__b+hit → a__b__hit, and also subdir-prefab basename clashes (enemies/goblin + allies/goblin → pack__goblin).

Tests added

  • nested overrides and prefab in a component payload NOT rewritten
  • subdir pack prefab ref (bare + path) → basename-prefixed key
  • subdir pack event hook matches a bare handler
  • top-level helper pub fn NOT renamed (internal call preserved)
  • a/a__b full-name collision rejected; subdir-prefab basename clash rejected; distinct names pass

@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

/gemini review

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/codegen/scan.zig`:
- Around line 400-404: The basename-only check in prefabBasenameMatch is too
broad and can rewrite foreign prefab paths that merely share the same final
segment. Update prefabBasenameMatch in scan.zig so bare refs still use basename
matching, but any source value with a path must only match an exact prefab path
from prefab_names instead of comparing std.fs.path.basename values; use the
existing prefabBasenameMatch helper and its callers to distinguish pathless vs
pathful refs before rewriting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: dc9cde55-b15a-4588-9821-0e56fcecdcc0

📥 Commits

Reviewing files that changed from the base of the PR and between 8ddda4f and db8a3ca.

📒 Files selected for processing (3)
  • src/codegen/scan.zig
  • src/pack_validate.zig
  • src/root.zig

Comment thread src/codegen/scan.zig
Comment on lines +400 to +404
fn prefabBasenameMatch(prefab_names: []const []const u8, content: []const u8) ?[]const u8 {
const content_base = std.fs.path.basename(content);
for (prefab_names) |p| {
if (std.mem.eql(u8, std.fs.path.basename(p), content_base)) return content_base;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Avoid rewriting foreign prefab paths that only share a basename.

"shared/goblin" will match a pack prefab like "enemies/goblin" because only basenames are compared, so a foreign ref can be rewritten to citizens__goblin. Keep basename matching for bare refs, but require exact path equality when the source value includes a path.

🐛 Proposed fix
 fn prefabBasenameMatch(prefab_names: []const []const u8, content: []const u8) ?[]const u8 {
     const content_base = std.fs.path.basename(content);
+    const content_is_bare = std.mem.eql(u8, content, content_base);
     for (prefab_names) |p| {
-        if (std.mem.eql(u8, std.fs.path.basename(p), content_base)) return content_base;
+        const prefab_base = std.fs.path.basename(p);
+        if (std.mem.eql(u8, content, p) or
+            (content_is_bare and std.mem.eql(u8, prefab_base, content_base)))
+        {
+            return prefab_base;
+        }
     }
     return null;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn prefabBasenameMatch(prefab_names: []const []const u8, content: []const u8) ?[]const u8 {
const content_base = std.fs.path.basename(content);
for (prefab_names) |p| {
if (std.mem.eql(u8, std.fs.path.basename(p), content_base)) return content_base;
}
fn prefabBasenameMatch(prefab_names: []const []const u8, content: []const u8) ?[]const u8 {
const content_base = std.fs.path.basename(content);
const content_is_bare = std.mem.eql(u8, content, content_base);
for (prefab_names) |p| {
const prefab_base = std.fs.path.basename(p);
if (std.mem.eql(u8, content, p) or
(content_is_bare and std.mem.eql(u8, prefab_base, content_base)))
{
return prefab_base;
}
}
return null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/codegen/scan.zig` around lines 400 - 404, The basename-only check in
prefabBasenameMatch is too broad and can rewrite foreign prefab paths that
merely share the same final segment. Update prefabBasenameMatch in scan.zig so
bare refs still use basename matching, but any source value with a path must
only match an exact prefab path from prefab_names instead of comparing
std.fs.path.basename values; use the existing prefabBasenameMatch helper and its
callers to distinguish pathless vs pathful refs before rewriting.

@gemini-code-assist

Copy link
Copy Markdown

Warning

Gemini encountered an error creating the review. You can try again by commenting /gemini review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: db8a3ca640

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/codegen/scan.zig
Comment on lines +291 to +292
if (topScope(scope_stack.items) == .component_map and
containsKey(component_keys, content))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Rewrite flat pack component keys

When a pack prefab uses the RFC #596 flat form, e.g. { "prefab": "base", "Worker": {...} } or a child with a Worker sibling key, the enclosing scope is .entity, not .component_map, so this gate leaves the pack-owned component name bare. The registry now emits only .citizens__Worker for that component, and this repo explicitly accepts PascalCase flat component keys in prefab files (src/scene_manifest_test.zig has flat prefab cases), so valid flat pack prefabs fail to resolve their own components after namespacing. The fresh evidence is the new .component_map-only condition, which excludes the flat entity component sites.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig
Comment on lines +400 to +403
fn prefabBasenameMatch(prefab_names: []const []const u8, content: []const u8) ?[]const u8 {
const content_base = std.fs.path.basename(content);
for (prefab_names) |p| {
if (std.mem.eql(u8, std.fs.path.basename(p), content_base)) return content_base;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid rewriting foreign prefab paths by basename

When a pack owns prefabs/worker.jsonc, any entity reference whose path merely ends in worker (for example a game/dependency reference like "prefab": "shared/worker") is treated as pack-owned because this drops the directory before matching. That changes a non-pack path into "citizens__worker", contrary to the rewrite contract that foreign prefab references stay bare; path-spelled values should only rewrite on an exact pack prefab path, while basename matching should be reserved for bare local references.

Useful? React with 👍 / 👎.

Comment thread src/codegen/scan.zig
Comment on lines +371 to +372
// Any other object field on an entity (`meta`, etc.) is opaque.
break :blk .payload;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat root blocks as entity scope

When a pack prefab uses the still-supported unified wrapper shape, e.g. { "root": { "prefab": "worker", "overrides": { "Worker": {} } } }, this default classifies the root object as .payload, so neither the same-pack prefab value nor the wrapped component/override keys inside it are rewritten. Those local names then remain bare even though the pack registers only citizens__worker and .citizens__Worker, so root-wrapped pack prefabs fail after namespacing.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant