Skip to content

feat(packs): scan a light pack's scripts/ into the per-state dispatch (#487) - #496

Merged
apotema merged 1 commit into
mainfrom
feat/487-pack-scripts
Jul 1, 2026
Merged

feat(packs): scan a light pack's scripts/ into the per-state dispatch (#487)#496
apotema merged 1 commit into
mainfrom
feat/487-pack-scripts

Conversation

@apotema

@apotema apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

What

Extends light-pack scanning so a pack's per-frame system can live INSIDE the pack. Before this, scanPack scanned a pack's components/ events/ prefabs/ hooks/ but not scripts/, so a pack's per-frame logic had to leak into the game root — the #1 gap the pack-colony-demo eval found.

Now a pack's scripts/<state>/*.zig is copied into packs/<name>/scripts/ and registered into the same per-state script dispatch as game-root + plugin scripts. A pack script's pub fn tick(game, dt) runs in its declared state alongside the game's own scripts.

How / key design decisions

  • Placement under the pack dir (packs/<name>/scripts/…), NOT scripts/.plugin_<name>/ (the decl-module-plugin layout). A light pack has no importable Zig module, so its script reaches its own components by relative import (@import("../../components/foo.zig")) — exactly how a game-root script reaches components/. Copying the scripts subtree under the pack, beside the components/ that scanPack already copies, preserves that relative offset so the import resolves unchanged. Placing scripts under scripts/.plugin_<name>/ would break those imports.
  • ScriptEntry.import_base"scripts/" for game + plugin scripts (rel_path relative to the generated scripts/ dir, behavior unchanged); "" for pack scripts, whose rel_path is already a full packs/<name>/scripts/… target-relative path. The AllScripts emitter now emits @import(import_base ++ rel_path).
  • Namespacing / ordering — pack scripts carry plugin_name = <pack>, so each pack forms its own numeric-prefix scope (per-pack duplicate-order validation), sorts into the plugin block after game scripts by declaration order (plugin_index), and is skipped by the game-script FlowNode walk.
  • No double-scan — packs are skipped in the plugin-shipped-scripts loop (a pack is also a .plugins entry) so a pack that ships scripts/ isn't scanned twice.
  • Scaffold (feat(#271): add add pack / add feature scaffold subcommand #485) — re-adds scripts to add pack's pack_convention_dirs.

Limitation

Pack scripts are lifecycle-only (tick/setup/State/drawGui). FlowNode/PinStyle discovery inside a pack script is intentionally out of scope here (the game-script FlowNode walk skips plugin/pack-scoped entries) and can be a follow-up.

Tests

Adds 4 tests in test/pack_scan_tests.zig:

  • scanPackScriptsDir registers a pack script into the per-state dispatch, and the ../../components import target lands beside it (layout invariant).
  • no-op on a pack with no scripts/ dir.
  • AllScripts imports a pack script verbatim from packs/<name>/scripts/…, state-scoped.
  • regression guard: a plain game-root script still imports from scripts/….

zig build and zig build test both pass (1098 tests, +4).

Closes #487. Unblocks assembler#491.

https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog

Summary by CodeRabbit

  • New Features

    • Pack scaffolding now includes a scripts/ subdirectory by default.
    • Pack scripts are now copied and registered as part of generated output, using the pack’s own path structure.
    • Generated script imports now correctly reflect whether code comes from the main app or a pack.
  • Bug Fixes

    • Prevented pack scripts from being scanned twice when pack and plugin content overlap.
    • Improved handling for packs without a scripts/ directory.

…atch (#487)

A light pack scanned components/events/prefabs/hooks but NOT scripts/, so a
pack's per-frame SYSTEM had to leak into the game root (the #1 gap the
pack-colony-demo eval found). This copies a pack's scripts/<state>/*.zig into
`packs/<name>/scripts/` and registers them into the SAME per-state script
dispatch the game root + plugins use, so a pack script's `pub fn tick(game, dt)`
runs in its declared state.

Key decisions:
- Placement UNDER the pack dir (packs/<name>/scripts/…), NOT scripts/.plugin_<name>/.
  A pack has no importable module, so its script reaches its own components by
  relative import (../../components/foo.zig) exactly as a game-root script does.
  Copying the subtree under the pack — beside the components/ scanPack already
  copies — preserves that relative offset so the import resolves unchanged.
- ScriptEntry.import_base: "scripts/" for game+plugin scripts (unchanged),
  "" for pack scripts whose rel_path is already a full packs/<name>/scripts/…
  target-relative path. AllScripts emits @import(import_base ++ rel_path).
- Pack scripts carry plugin_name = pack name, so they form their own
  numeric-prefix scope, sort into the plugin block (after game scripts) by
  declaration order, and are skipped by the game-script FlowNode walk. Pack
  scripts are therefore lifecycle-only (tick/setup/State/drawGui); FlowNode
  discovery inside pack scripts is a deliberate follow-up.
- Packs are skipped in the plugin-scripts loop to avoid double-scanning.
- Re-add scripts/ to the `add pack` scaffold's convention dirs (#485).

Existing game-root-only projects are unaffected (regression test guards the
"scripts/" prefix). Adds 4 tests. Unblocks assembler#491.

Claude-Session: https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9ca3c9ad-43aa-4ca7-a2aa-5ef357ce188a

📥 Commits

Reviewing files that changed from the base of the PR and between 6cf9f92 and e7b9038.

📒 Files selected for processing (5)
  • src/add_cmd.zig
  • src/codegen/blocks/registries.zig
  • src/root.zig
  • src/script_scanner.zig
  • test/pack_scan_tests.zig

📝 Walkthrough

Walkthrough

This PR extends pack support to scan and register a light pack's scripts/ directory. It adds scanPackScriptsDir to ScriptScanner, introduces an import_base field on ScriptEntry, wires pack script copying/scanning into generate(), updates codegen import-prefix logic, re-adds scripts/ to the pack scaffold, and adds tests.

Changes

Pack scripts scanning support

Layer / File(s) Summary
ScriptEntry import_base and scanPackScriptsDir
src/script_scanner.zig
Adds import_base field to ScriptEntry and current_import_base scratch field to ScriptScanner; new scanPackScriptsDir scans a pack's copied scripts subtree, assigns plugin namespace/order, and builds rel_path via an import prefix.
Pack scripts copy and registration in generate()
src/root.zig
Skips packs when processing decl-module plugin scripts; copies pack source scripts/ into <target>/packs/<pack>/scripts/ and registers it via scanPackScriptsDir with a packs/<name>/scripts import prefix.
Codegen import prefix uses entry.import_base
src/codegen/blocks/registries.zig
writeAllScriptsBlock uses entry.import_base (or empty for promoted scripts) instead of a hardcoded "scripts/" prefix.
Pack scaffold includes scripts/
src/add_cmd.zig
Re-adds scripts to pack_convention_dirs and usage text; updates scaffoldPack test to assert scripts/.gitkeep creation.
Pack script scan and codegen regression tests
test/pack_scan_tests.zig
New PACK_SCRIPTS suite verifies pack script copying, scan registration, no-op on missing dir, generated import paths, and default prefix regression for game-root scripts.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related issues

Possibly related PRs

Poem

A rabbit hops through scripts/ anew,
Packing prefixes, namespace too,
No longer dead, no longer bare,
Each state-scoped script now scanned with care,
🐇 Thump-thump — the pack pipeline's complete!

🚥 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 clearly summarizes the pack-scripts scanning change and matches the main intent of the PR.
Linked Issues check ✅ Passed The changes add pack scripts scanning, namespaced registration, and restore scripts in the pack scaffold as requested in #487.
Out of Scope Changes check ✅ Passed The modified files all support pack script scanning and related scaffolding, with no obvious unrelated changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/487-pack-scripts

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

@apotema

apotema commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

@codex 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: e7b9038220

ℹ️ 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
}
break :blk false;
};
if (is_pack) continue;

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 .plugins order for pack scripts

When a pack appears before a regular plugin in project.labelle and both ship scripts, this continue removes the pack from the declaration-order scan, while the later pack_entries loop registers all pack scripts only after every non-pack plugin script. Since ScriptScanner assigns plugin_index in scan-call order and sorts plugin/pack entries by that index, the later regular plugin will run before the earlier pack, breaking the existing per-state script ordering contract tied to .plugins order.

Useful? React with 👍 / 👎.

Comment thread src/root.zig
// `PackScan.import_prefix` (`packs/<name>`) plus `/scripts`.
const pack_scripts_import_prefix = try std.fmt.allocPrint(allocator, "packs/{s}/scripts", .{e.plugin.name});
defer allocator.free(pack_scripts_import_prefix);
try script_scan.scanPackScriptsDir(pack_scripts_dst, pack_scripts_import_prefix, 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 Avoid scanning stale pack scripts after removal

If a pack used to have scripts/ and a later version removes that directory entirely, copyAndScanAbs returns without touching the existing destination, but this still scans <target>/packs/<name>/scripts. Because generate only createDirPaths the target rather than recreating it, leftover copied scripts from the previous run are registered and compiled even though the source pack no longer ships them; skip the scan when the source directory is absent or prune the destination in that case.

Useful? React with 👍 / 👎.

Comment thread src/script_scanner.zig
errdefer self.allocator.free(name_copy);
const rel_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ import_prefix, entry.name });
errdefer self.allocator.free(rel_path);
try self.addEntryWithPath(name_copy, null, &.{}, rel_path);

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 context scripts from selecting GameContext

For a pack that ships a root scripts/context.zig, adding it as a normal script entry makes the existing hasContextEntry predicate see a context script even when the game has no scripts/context.zig; the template then emits the hard-coded @import("scripts/context.zig") while AllScripts skips the pack entry by name. That makes such a pack either break builds without a game context file or silently drop the pack script, so pack entries named context need to be excluded from the game-context sentinel path.

Useful? React with 👍 / 👎.

@apotema
apotema merged commit c42e43b into main Jul 1, 2026
4 checks passed
apotema added a commit that referenced this pull request Jul 1, 2026
…ins, pack-script order/staleness/context) (#500)

* fix(packs): address codex findings from #494/#496 — scene-lint builtins exemption, pack-script order/staleness/context

Four codex-review findings merged with the packs batch (#494, #496):

1. scene_name_lint.zig — exempt engine/gfx built-in component names (e.g.
   `VideoComponent`, always registered by `writeComponentRegistryBlock`) from
   the bare-pack-component lint. Previously a bare built-in was falsely flagged
   and mis-suggested to `pack__VideoComponent` when a pack shipped a same-named
   component. Adds `builtin_component_names` + exemption in `lintSource`.

2. root.zig — scan each light pack's `scripts/` at its `.plugins`
   declaration-order position (interleaved with plugins) so `ScriptScanner`'s
   `plugin_index` reflects `.plugins` order. The old two-phase scan (all
   plugins, then all packs) pushed a pack declared before a plugin behind it,
   breaking the per-state script ordering contract.

3. root.zig — when a pack ships no `scripts/` source, prune any stale dest a
   prior `generate` copied and register nothing. `copyAndScanAbs` no-ops on a
   missing source without touching the dest, so leftover copied scripts were
   otherwise scanned + compiled. Extracted `scanPackScriptsAt` (guards source
   existence) for both #2 and #3.

4. validate.zig / registries.zig — exclude pack/plugin `context` scripts
   (`plugin_name != null`) from the GameContext sentinel. A pack's
   `scripts/context.zig` no longer flips `hasContextEntry`, and `AllScripts`
   keeps importing it (instead of silently dropping it).

Tests: builtin-exemption lint test; pack-before-plugin ordering test; stale-dest
prune + present-script tests; pack-context emission test; hasContextEntry
game-vs-pack tests. Also wired `codegen/validate.zig` into root.zig's test
aggregator so its tests run under `zig build test`.

`zig build` + `zig build test` green (1134 tests pass).

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

* fix(packs): propagate non-FileNotFound errors when probing pack scripts/ (codex P2 on #500)

`scanPackScriptsAt`'s source-`scripts/` existence probe used a catch-all that
treated ANY openDir failure — AccessDenied (permissions / broken mount), NotDir
(`scripts` is a file), etc. — the same as a missing directory: it pruned the
generated copy and silently dropped the pack's scripts, producing an incomplete
build with no error.

Mirror `copyAndScanAbs`'s source-root open (scanner.copyAndScanRecursive)
EXACTLY: tolerate ONLY `error.FileNotFound` (prune stale dest + skip) and
PROPAGATE every other error.

Test: `scanPackScriptsAt propagates a non-FileNotFound probe error and does NOT
prune (#500 codex)` — a pack whose `scripts` path is a file yields error.NotDir,
which must propagate while the pre-existing generated copy stays intact.

`zig build` + `zig build test` green (1135 tests pass).

Claude-Session: https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog
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.

packs: scan a light pack's scripts/ dir (deferred from #440/#485)

1 participant