Skip to content

feat(packs): per-pack module graph — pack files behind a real module boundary (#498 PR 2) - #538

Merged
apotema merged 2 commits into
mainfrom
feat/498-pack-modules
Jul 5, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/498-pack-modules

Conversation

@apotema

@apotema apotema commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

PR 2 of the #498 train ("wire the wall") — the atomic one: the per-pack Zig module graph.

What lands

  • packs/<name>/__pack_root.zig (new codegen/pack_root.zig renderer): generated module root re-exporting every scanned component/event/hook/script under pathToIdent accessors. Prefab .jsonc stays @embedFiled by path — data has no module membership, and embed path + <pack>__ key are the save contract.
  • pack__<prefix>_mod in the generated build.zig (emitPackModules/emitPackImports, mirroring the Game-script CustomNodes don't compile: shim missing PluginFlowNodes + named-module promotion needed (follows #238) #240 promoted-script wiring): createModule per pack + addImport on every artifact (desktop exe, wasm, android lib, ios exe, test_root).
  • All four main.zig emission sites converted atomically — component registry fields, event aliases, hook aliases, AllScripts — plus both flow-handler sites (GameHooks receiver types + hooks_init), which also handles the pre-existing seam where a pack script with has_event_handler emitted a broken @import("scripts/packs/…"). One file, one module: any remaining path import would be the dual-module compile error, so partial conversion was never an option.

The import table IS the wall

In: labelle-engine/labelle-core/labelle-gfx, the four backend modules, ecs_backend/gui_backend when wired, every decl-module plugin, and the implicit contracts pack (one-directional overrideImport). Out: the game shim and every sibling pack.

Deviation from the issue sketch, by design decision: the guide said "NOT other plugins", but FP's packs import plugins 50+ times as their sanctioned domain surface (the citizens pack's own boundary doc routes all worker access through worker_controller's surface), so plugins stay importable — the wall targets pack↔pack isolation and game-root reach, not the shared substrate.

Proven on a real build

Scratch two-pack project (null backend, zig_ecs, scene resolving citizens__Counter through the module path):

  • ✅ builds green, binary produced
  • ❌ sibling relative import → error: file exists in modules 'pack__citizens' and 'pack__production'
  • ❌ @import("game") → error: no module named 'game' available within module 'pack__production'

Invariants held

  • Registry FIELD names unchanged (<pack>__<Pascal> — the serde/save keys); PackView allow-lists untouched.
  • Pack-less projects emit byte-identical main.zig + build.zig (every writer gates on pack presence; goldens untouched, no-pack emission test unweakened).
  • Suite: 46/46 steps, 1264/1268 (4 skipped, 0 failed) — includes new PACK_ROOT_RENDER + PACK_MODULE_BUILD structs (renderer shape, restricted-table slice asserts, contracts direction check, no-pack zero-wiring) and the PACK_EMISSION/PACK_SCRIPTS asserts flipped to the module forms with negative old-form guards.

Consumer note

FP adopts this at its next assembler re-pin: plugin imports keep working (see above); its 5 known game-root escapes (../../../components/game_time.zig) become compile errors — that's the already-tracked .global-facets contracts migration, not a regression.

Next in train: PR 3 (@import("root") Registry bridge + @import("pack") self-import), PR 4 (exposes surface modules + depends_on), PR 5 (lint demotion + stale-comment truth-up), PR 6 (examples/packs-demo + CI e2e fixture). Also flagged: root.zig (~2050) and build_files.zig (~1160) exceed the 1000-line rule — split proposed alongside PR 5 rather than inside this atomic change.

Part of #498

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

Summary by CodeRabbit

  • New Features

    • Added pack-based module root generation and build wiring.
    • Pack scripts, hooks, events, and components are now re-exported via generated pack modules (with consistent naming across targets).
  • Bug Fixes

    • Fixed flow-event handler imports so promoted and unpromoted handlers resolve through the correct routing.
    • Improved import-table isolation so packs share only the intended “contracts” surface.
  • Tests

    • Extended pack-scan golden checks and new assertions for pack root rendering, build wiring, and flow-handler routing.

… module boundary (#498 PR 2)

Each light pack becomes a build-system module (pack__<prefix>) rooted at
a generated packs/<name>/__pack_root.zig that re-exports every scanned
component/event/hook/script. The generated main.zig reaches pack
contents EXCLUSIVELY through @import("pack__<prefix>") — all four
emission sites converted atomically (component registry fields, event
aliases, hook aliases, AllScripts), plus both flow-handler sites, which
also fixes the pre-existing broken @import("scripts/packs/…") a pack
flow-handler would have emitted.

The module's import table is the wall: engine/core/gfx, the backend
modules, ecs/gui when wired, and every decl-module plugin (plugins are
the sanctioned inter-domain surface — FP's packs route worker access
through worker_controller's citizens surface by design) — and nothing
else. No `game` shim, no sibling packs. An implicit `contracts` pack is
wired into every other pack module (one direction).

Proven on a real two-pack project (null backend): builds green with a
scene resolving citizens__Counter through the module path; a sibling
relative import fails with "file exists in modules 'pack__citizens' and
'pack__production'"; @import("game") fails with "no module named 'game'
available within module 'pack__production'".

Prefab .jsonc embeds stay path-based (data has no module membership;
embed path + <pack>__ key are the save contract). Registry FIELD names
are unchanged (serde/save keys). Pack-less projects emit byte-identical
output — every new writer gates on pack presence (goldens untouched).

PR 3 (root/Registry bridge + @import("pack") self-import), PR 4
(exposes surfaces + depends_on), PR 5 (lint demotion + doc truth-up),
PR 6 (example + e2e fixture) follow.

Part of #498

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds generated per-pack __pack_root.zig modules, wires them into build generation as pack__<name> imports, and updates codegen plus tests to resolve pack components, events, hooks, scripts, and flow handlers through those re-export modules.

Changes

Pack module generation and wiring

Layer / File(s) Summary
Pack root module generator
src/codegen/pack_root.zig
Defines PackModule, CONTRACTS_PACK_NAME, packRelScriptPath, and renderPackRoot for generating per-pack __pack_root.zig sources.
Assembler generate() wiring of pack roots
src/root.zig
Re-exports pack_root, generates each pack’s __pack_root.zig, and builds pack_modules for build generation.
build.zig pack module emission and import wiring
src/build_files.zig
Adds pack module emission/import wiring for all targets and extends BuildZigOptions with pack_modules.
Codegen blocks route imports through pack re-exports
src/codegen/blocks/hooks.zig, src/codegen/blocks/imports.zig, src/codegen/blocks/registries.zig
Routes hooks, events, components, scripts, and flow handlers through pack__<prefix> module re-exports.
Test updates for pack re-export wiring
test/pack_scan_tests.zig
Updates golden expectations and adds coverage for renderPackRoot, pack-module build wiring, and flow-handler routing.

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

Possibly related issues

Possibly related PRs

Poem

A packy bunny hops with glee,
Through pack__ roots and scripts so free.
No stray import can take a bite,
The walls are tidy, sealed just right.
🐇✨

🚥 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 change: introducing per-pack module boundaries for pack files.
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 feat/498-pack-modules

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

@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 introduces per-pack module root generation (__pack_root.zig) and build-system wiring to enforce pack isolation boundaries ("wire the wall"). Instead of direct path imports, pack components, events, hooks, and scripts are now re-exported by their respective pack modules and accessed via @import("pack__<prefix>"). This prevents dual-module membership errors and restricts import tables to exclude the game shim and sibling packs. Feedback on the changes highlights a potential memory leak in src/codegen/pack_root.zig where an errdefer is needed to deallocate the ArrayList if toOwnedSlice fails after calling toArrayList().

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.

Comment thread src/codegen/pack_root.zig
Comment on lines +136 to +137
var arr_list = alloc_writer.toArrayList();
return arr_list.toOwnedSlice(allocator);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When moving an allocating writer's buffer to an ArrayList via toArrayList(), the writer is reset to empty. To prevent memory leaks if a subsequent toOwnedSlice fails with OutOfMemory, an errdefer should be added to deinit the ArrayList. This is safe from double-free because the writer's deinit becomes a no-op on an empty buffer.

    var arr_list = alloc_writer.toArrayList();
    errdefer arr_list.deinit(allocator);
    return arr_list.toOwnedSlice(allocator);
References
  1. In Zig, when moving an allocating writer's buffer to an ArrayList via toArrayList(), the writer is reset to empty. To prevent memory leaks if a subsequent toOwnedSlice fails with OutOfMemory, add an errdefer to deinit the ArrayList. This is safe from double-free because the writer's deinit becomes a no-op on an empty buffer.
  2. In Zig 0.16, the deinit API for an unmanaged ArrayList requires passing the allocator. When adding an errdefer to free an unmanaged ArrayList on an allocation failure (such as before toOwnedSlice(allocator)), use errdefer list.deinit(allocator).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in ce3a5c5 — errdefer arr_list.deinit(allocator) between toArrayList and toOwnedSlice, with a comment pinning the reset-writer rationale. (The identical pre-existing pattern in root.zig's generateTestsRoot is left for the PR 5 cleanup sweep.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already applied in this very commit (ce3a5c5) — the errdefer sits at pack_root.zig:139; this looks like a re-post against the fix itself.

@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 37-48: The import logic in printFlowHandlerImport should account
for FlowNodes-promoted scripts instead of always falling back to the path-based
scripts/{s} import, which can recreate the duplicate-module issue. Thread the
promotion state into this helper and, when the entry is promoted, switch to the
script__<ident> import path using the existing rel_path/identifier plumbing
rather than the plain file import. Also replace the misleading error.NameTooLong
return for a missing plugin_name with the appropriate missing-plugin sentinel or
early return used elsewhere in this codepath.
🪄 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: b44e7fa5-fd69-4b22-94e8-b9dd85b467b1

📥 Commits

Reviewing files that changed from the base of the PR and between 85cd72e and bc12700.

📒 Files selected for processing (7)
  • src/build_files.zig
  • src/codegen/blocks/hooks.zig
  • src/codegen/blocks/imports.zig
  • src/codegen/blocks/registries.zig
  • src/codegen/pack_root.zig
  • src/root.zig
  • test/pack_scan_tests.zig

Comment thread src/codegen/blocks/hooks.zig Outdated
…he hook sites; errdefer the pack-root buffer

CodeRabbit: printFlowHandlerImport now mirrors AllScripts' THREE shapes
— a handler that also exports FlowNodes routes through
@import("script__<ident>") at both hook sites (this hole predates the
PR: the old inline path-imports had it too; the shared helper is where
it becomes fixable). Misleading error.NameTooLong sentinel replaced
with error.PackScriptMissingOwner at both pack-marker sites. Two
routing tests pin promoted + plain shapes.

Gemini: errdefer the toArrayList buffer in renderPackRoot (toArrayList
resets the writer, so an OOM inside toOwnedSlice leaked it).

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@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)
test/pack_scan_tests.zig (1)

1219-1225: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Unpromoted-handler test only checks one of the two hook sites.

The promoted-handler test (Line 1212-1213) verifies both the GameHooks receiver-type tuple and the hooks_init instantiation route through script__counter. The unpromoted counterpart only asserts the receiver-type shape (Line 1223) — it never checks that the hooks_init site also keeps the plain scripts/counter.zig path import (and doesn't leak script__counter) for this case. Given the PR explicitly routes "at both hook sites," the negative/unpromoted path deserves the same symmetric coverage to catch a regression at the hooks_init site specifically.

Suggested addition
     test "a plain (unpromoted) flow handler keeps the scripts/ path import" {
         const main_zig = try gen(empty_plugin_flow_nodes);
         defer std.testing.allocator.free(main_zig);

         try std.testing.expect(contains(main_zig, "*`@import`(\"scripts/counter.zig\").FlowEventHandler,"));
+        try std.testing.expect(contains(main_zig, "`@import`(\"scripts/counter.zig\").FlowEventHandler = .{};"));
         try std.testing.expect(!contains(main_zig, "script__counter"));
     }
🤖 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 `@test/pack_scan_tests.zig` around lines 1219 - 1225, Add symmetric coverage in
the unpromoted-handler test so it checks both hook sites, not just the
`GameHooks` receiver tuple. In `test "a plain (unpromoted) flow handler keeps
the scripts/ path import"`, extend the assertions around
`gen(empty_plugin_flow_nodes)` to also verify the `hooks_init` instantiation
still uses the plain `scripts/counter.zig` import and does not reference
`script__counter`, matching the promoted-handler test’s two-site coverage.
🤖 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 `@test/pack_scan_tests.zig`:
- Around line 1219-1225: Add symmetric coverage in the unpromoted-handler test
so it checks both hook sites, not just the `GameHooks` receiver tuple. In `test
"a plain (unpromoted) flow handler keeps the scripts/ path import"`, extend the
assertions around `gen(empty_plugin_flow_nodes)` to also verify the `hooks_init`
instantiation still uses the plain `scripts/counter.zig` import and does not
reference `script__counter`, matching the promoted-handler test’s two-site
coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 43cdfab1-3352-42fe-b0b5-677b4c5ef634

📥 Commits

Reviewing files that changed from the base of the PR and between bc12700 and ce3a5c5.

📒 Files selected for processing (4)
  • src/codegen/blocks/hooks.zig
  • src/codegen/blocks/registries.zig
  • src/codegen/pack_root.zig
  • test/pack_scan_tests.zig
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/codegen/blocks/hooks.zig
  • src/codegen/blocks/registries.zig
  • src/codegen/pack_root.zig

@apotema
apotema merged commit 7e5e3dd into main Jul 5, 2026
4 checks passed
@apotema
apotema deleted the feat/498-pack-modules branch July 5, 2026 14:34
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