Repository navigation
Declare: script-declared events (events/*.rb|lua) — collection, codegen, pin gating - #618
Conversation
…, gating (engine#772 slice 2) events/hunger__feed.rb (HungerFeed = Labelle.event "hunger__feed", entity: Labelle.id, amount: 0.5) now generates the event exactly as if events/hunger__feed.zig existed: the schema's "events" array (scripting v0.10.0) parses into DeclaredEvent, codegens one scripting_events.zig at the target root, and the generated main's union row imports it inline — same name, same routing, native hooks consume it off the same bus. Materialization is a single generated file, NOT per-event files in the staged events/ dir: linkAndScan stages convention dirs as whole-dir symlinks back into the game tree (scanner.zig linkDir), so writing "into" staged events/ would write through into the game's sources. Hooks consuming a DECLARED event spell the payload param anytype — the dispatcher never inspects param types. The field-type map gains u64 (Labelle.id — legal in component fields too, so a v0.10.0 component schema works). events/*.<ext> files embed and register between components and scripts (order pinned). Gating fails up front: pin below events_min_pin 0.10.0 names the floor and the files (an old runtime prelude lacks Labelle.event — late failure would be a boot NoMethodError); an events dir declaring zero events errors with the declaration shape; a no-runner language (typescript) with events files errors as unsupported rather than silently ignoring them. E2E: scratch ruby-game with the .zig event replaced by the one-line .rb, scripts untouched — exact 16-token transcript, ZIG_FEED_SEEN_0.5 intact (ruby-declared → native hook, payload crossed whole). Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
|
Warning Review limit reached
Next review available in: 33 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughScript-declared events are parsed, validated, rendered into ChangesScript-declared events
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Project
participant generate
participant runPhase
participant Codegen
participant Manifest
Project->>generate: provide events/* declarations
generate->>runPhase: pass event files and names
runPhase-->>generate: return declared events and scripting_events.zig
generate->>Codegen: provide declared event schemas
Codegen-->>Project: emit game-event unions and hook payloads
generate->>Manifest: pass declared events
Manifest-->>Project: emit event-inclusive manifest
Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces support for script-declared events (labelle-engine#772) across the codegen, manifest emission, and scripting splice pipelines. It enables the declaration of events in events/*.<ext> files, parses and validates them into a generated scripting_events.zig file, and integrates them into the GameEvents union and dispatcher. Feedback on the changes highlights a potential issue in checkEventCollisions where formatting errors with a fixed-size buffer could silently skip collision checks for long names; replacing this with allocation-free slice comparisons is recommended to ensure robustness.
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.
| pub fn checkEventCollisions( | ||
| declared: []const DeclaredEvent, | ||
| event_names: []const []const u8, | ||
| pack_scans: []const scan.PackScan, | ||
| ) !void { | ||
| var prefix_buf: [128]u8 = undefined; | ||
| var full_buf: [280]u8 = undefined; | ||
|
|
||
| for (declared) |ev| { | ||
| for (event_names) |stem| { | ||
| if (std.mem.eql(u8, idents.eventVariantName(stem), ev.name)) { | ||
| diag("script-declared event '{s}' collides with the game event events/{s}.zig — rename one (script declarations and events/ share one bus namespace)", .{ ev.name, stem }); | ||
| return error.ScriptEventCollision; | ||
| } | ||
| } | ||
| for (pack_scans) |pack| { | ||
| const prefix = scan.packNamespacePrefix(pack.name, &prefix_buf); | ||
| for (pack.event_names) |stem| { | ||
| const full = std.fmt.bufPrint(&full_buf, "{s}__{s}", .{ prefix, idents.eventVariantName(stem) }) catch continue; | ||
| if (std.mem.eql(u8, full, ev.name)) { | ||
| diag("script-declared event '{s}' collides with pack '{s}' event {s}.zig — rename one", .{ ev.name, pack.name, stem }); | ||
| return error.ScriptEventCollision; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
In checkEventCollisions, the collision check between declared events and pack events uses std.fmt.bufPrint with a fixed-size buffer (full_buf) and ignores formatting errors with catch continue. If a pack name or event name is extremely long, this could silently skip the collision check, potentially leading to downstream compilation failures.
We can completely avoid buffer formatting, fixed-size limits, and potential silent failures by performing allocation-free slice comparisons using std.mem.startsWith and std.mem.eql.
pub fn checkEventCollisions(
declared: []const DeclaredEvent,
event_names: []const []const u8,
pack_scans: []const scan.PackScan,
) !void {
var prefix_buf: [128]u8 = undefined;
for (declared) |ev| {
for (event_names) |stem| {
if (std.mem.eql(u8, idents.eventVariantName(stem), ev.name)) {
diag("script-declared event '{s}' collides with the game event events/{s}.zig — rename one (script declarations and events/ share one bus namespace)", .{ ev.name, stem });
return error.ScriptEventCollision;
}
}
for (pack_scans) |pack| {
const prefix = scan.packNamespacePrefix(pack.name, &prefix_buf);
for (pack.event_names) |stem| {
if (std.mem.startsWith(u8, ev.name, prefix) and
std.mem.startsWith(u8, ev.name[prefix.len..], "__") and
std.mem.eql(u8, ev.name[prefix.len + 2..], idents.eventVariantName(stem)))
{
diag("script-declared event '{s}' collides with pack '{s}' event {s}.zig — rename one", .{ ev.name, pack.name, stem });
return error.ScriptEventCollision;
}
}
}
}
}
There was a problem hiding this comment.
Applied in c092f27 — allocation-free slice comparison (startsWith + __ split + eql), no fixed buffer, no catch-continue. Worth noting: on THIS path the overflow was reachable (eventVariantName returns an unbounded stem slice — a 302-byte pack__stem name silently skipped the check; now a test). The component-side checkCollisions had the identical spelling with UNREACHABLE overflow (both operands 128-capped upstream) — converted to the same idiom anyway so the pattern is gone everywhere.
There was a problem hiding this comment.
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/root.zig`:
- Around line 947-958: Update the flow around scripting_declare.runPhase to
validate every declared event tag against the discovered PluginEvents, including
game and pack events, before MergeHookPayloads compilation. Either perform a
post-discovery collision check after plugin event discovery or move plugin-event
discovery before this phase, preserving existing collision handling for all
event sources.
In `@src/scripting_declare.zig`:
- Line 236: Update the u64 default parsing logic near the u64 type declaration
so JSON integer values are parsed as unsigned without first narrowing through
std.json.Value.integer (i64). Preserve support for the full u64 range, including
values above maxInt(i64) and maxInt(u64), and add tests covering upper-half u64
defaults and maxInt(u64).
🪄 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: fd6b3d50-41bb-40e0-9d14-7d3874e7bedf
📒 Files selected for processing (7)
src/codegen/blocks/events.zigsrc/codegen/blocks/hooks.zigsrc/codegen/context.zigsrc/manifest/emit.zigsrc/root.zigsrc/scripting_declare.zigsrc/scripting_splice.zig
There was a problem hiding this comment.
Pull request overview
Adds first-class support for script-declared events (from events/*.<ext> files) alongside the existing script-declared components flow, threading the resulting schema through codegen, collision gates, and the manifest sidecar so declared events behave like native events/*.zig union variants.
Changes:
- Extend declare-mode schema parsing/codegen to include top-level
events[], generatescripting_events.zig, and add pin/capability gating + collision checks for declared events. - Collect and embed
events/*.<ext>declaration files (components → events → scripts ordering), and thread declared events through codegen soGameEvents/dispatcher folding accounts for them. - Mirror declared events into the manifest sidecar’s game realm (struct name + field shapes) alongside AST-parsed native events.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/scripting_splice.zig | Adds declared_events to the splice and implements events/*.<ext> embed collection + 3-way concat ordering. |
| src/scripting_declare.zig | Parses "events" schema, adds DeclaredEvent, generates scripting_events.zig, adds pin/capability/none-declared gates, and event collision checks. |
| src/root.zig | Wires event embed collection and declare-phase inputs/outputs (event_files, event_names, declared_events) through generate(). |
| src/manifest/emit.zig | Appends declared events into the game-realm event list in the sidecar manifest. |
| src/codegen/context.zig | Exposes declaredEvents() from the codegen context. |
| src/codegen/blocks/hooks.zig | Updates AllHookPayloads folding gate to include declared events. |
| src/codegen/blocks/events.zig | Emits union variants for declared events referencing @import("scripting_events.zig").<Pascal>. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 10d78986d7
ℹ️ 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".
| if (maybe_scripting) |*s| { | ||
| if (s.family == .embed) { | ||
| component_embeds = try scripting_splice.collectComponentEmbeds(allocator, game_dir, s.*); | ||
| event_embeds = try scripting_splice.collectEventEmbeds(allocator, game_dir, s.*); |
There was a problem hiding this comment.
Add events/ to the language-policy scan
When this starts collecting events/*.<ext> declarations, the earlier language-policy gate still only calls the shared-dir scan for scripts/ and components/ (language_policy.zig lines 422-423). In a project with no scripting plugin, or a Lua project that accidentally has events/foo.rb, those files are never bucketed as script-language input; collectEventEmbeds only runs for the active splice/extension, so they are silently ignored instead of producing the existing MissingScriptingPlugin/ScriptLanguageMismatch diagnostics. Please include events/ in that policy scan before relying on this new convention.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Applied in c092f27 — events/ joined the shared-dir policy scan beside scripts/ and components/: mismatched-language events files now get ScriptLanguageMismatch and plugin-less projects MissingScriptingPlugin (tests mirror the components/ twins; a Zig-only events/ stays invisible to policy).
- checkEventCollisions drops the bufPrint-into-fixed-buffer + catch-continue spelling for allocation-free slice comparison — the event side's overflow was REACHABLE (eventVariantName returns an unbounded stem slice; a 302-byte pack__stem name silently SKIPPED the collision check). checkCollisions (components) had the identical pattern with unreachable overflow (both operands 128-capped); converted to the same idiom anyway. Long-name tests pin both. - new checkEventPluginCollisions right after discoverPluginEvents: a declared event duplicating a plugin event tag (box2d__collision_begin, sanitized hyphenated plugins, engine__*) now fails generate with a pointed error instead of exploding later in MergeHookPayloads comptime. - events/ joins the language-policy shared-dir scan: a lua project with events/foo.rb gets ScriptLanguageMismatch and a plugin-less project gets MissingScriptingPlugin, same as scripts/ and components/. Suite exit 0 (clean-HOME), per-file fmt only. Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c092f27e2c
ℹ️ 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".
| if (maybe_scripting) |*s| { | ||
| if (s.family == .embed) { | ||
| component_embeds = try scripting_splice.collectComponentEmbeds(allocator, game_dir, s.*); | ||
| event_embeds = try scripting_splice.collectEventEmbeds(allocator, game_dir, s.*); |
There was a problem hiding this comment.
Reject native-language event declaration files
When the project language is native (e.g. rust or crystal) and the game has events/foo.rs/.cr, the new events/ language-policy scan treats those as the declared language and skips them, but this collection only runs for .embed splices. The native splice then reaches runPhase with empty script_files/event_files, and stageNativeSources only stages scripts/, so the event file is silently ignored instead of hitting the unsupported-events gate that events/*.<ext> files are supposed to trigger.
Useful? React with 👍 / 👎.
… consumers excluded from production (#631 codex) Two codex P2s on a65cb16: 1. Collision diagnostic regression: a game `events/box2d__collision_begin.zig` (or a pack event whose namespaced variant spells the tag) colliding with a discovered plugin event always reached MergeHookPayloads' comptime duplicate-field check pre-#630 — but with consumption filtering, a tag referenced NOWHERE in scanned sources elides the plugin entry first, the collision silently vanishes, and the plugin's @Hasfield emit gate turns ON against the game's same-named payload (wrong payload / confusing missing-field error at the emit helper). New `scripting_declare.checkGameEventPluginCollisions` checks the FULL discovery list against game event stems (eventVariantName) and pack variants (<pack>__<ident>) with the declare-phase's pointed voice; root.zig runs it beside checkEventPluginCollisions, BEFORE the filter, so collision behavior stays consumption-independent. Pack-side comparison uses a virtual-concatenation comparator (no fixed-size format buffer — the PR #618 silent-skip trap — and exact even for __-misaligned equal joins). Narrow corner by construction: any REAL consumer of the colliding tag keeps the plugin entry and restores the old loud comptime error; the gate closes the dead-declaration case. 2. tests/ sources counted as production consumers: a test file naming a tag kept the variant in the PRODUCTION target even though tests/ only feeds the separate __tests_root.zig target. root.zig now excludes exactly `<game_dir>/tests` (canonical-root exclusion, NOT a blanket "tests" basename skip) — and only when `!is_tests_target`: the tests-target pass keeps scanning tests/ so a test referencing a variant gets it in its OWN GameEvents and compiles. Per-target divergence documented next to the exclusion (consistent with testsTargetConfig's backend/.platform overrides). Tests: collision gate unit tests (game stem, subdir stem, pack variant, misaligned __ join, near misses, empty discovery); tests/-only tag → elided with the exclusion, kept without (the tests-target shape). E2E on plugin-controllers: events/demo_plugin__demo_ping.zig fails generate with the collision error; a tests/-only reference is elided in .labelle/null_desktop/{main,game}.zig while .labelle/tests/game.zig keeps the variant. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
* feat: fold only consumed plugin events into PluginEvents (#630) Discovered plugin/engine events were folded into the generated PluginEvents union unconditionally, so a plugin's comptime @Hasfield(GameEvents, tag) emit gate was always true once the plugin was installed — chatty emitters (box2d contact events) constructed and buffered payloads every frame with zero subscribers. Filter the folded variants by consumption at generate time: - New scanner `codegen/scan/event_consumption.zig` (`filterConsumedEvents`): conservative text scan over the game dir + staged `<target>/scripts` (plugin-shipped scripts) + `<target>/packs` (staged pack hooks). An event is consumed when any scanned file contains the qualified tag (`box2d__collision_begin`) or the dotted form (`box2d.collision_begin` / `engine.tick`). False positives keep today's behavior; false negatives (silently broken delivery) are the only unacceptable failure, hence substring matching and no AST cleverness. Corpus = .zig/.zon/.jsonc/.json plus every language_policy scriptExtensions entry; skips .labelle/.zig-cache/ zig-out/.git/node_modules by basename; 2 MB per-file cap. - Wired in root.zig AFTER checkEventPluginCollisions (the collision gate keeps seeing the full discovery list) and BEFORE the game shim + main.zig emission, which both consume the same filtered list. The pack-manifest sidecar keeps the FULL list (discoverability catalog). - Debuggability: one `// elided (no consumer): <tag>` comment per dropped variant in the generated PluginEvents block (and in the v1 fallback block when everything was elided). - All-elided projects collapse to the pre-RFC v1 shape: `GameEvents = void`, event buffer elided by the engine's has_events gate. - Escape hatch: `.plugin_events = .all` in project.labelle restores unconditional folding (default `.consumed`). Verified on the plugin-controllers example: `.all` output is byte-identical to the pre-change assembler; `.consumed` elides the 20 unconsumed engine events (GameEvents = void); adding a script that names `engine__tick` keeps exactly that variant. Closes #630 Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: exclude in-tree dependency sources from the consumer scan (#631 review) codex P2: a `local:plugins/box2d` / `@libs/box2d` plugin (or a `local:` core/engine/gfx override) resolves UNDER the game dir, so the game_dir scan root recursed into the dependency's own source — whose emit sites name the qualified tags (`box2d__collision_begin` at every emitGameEvent call) — marking all its events consumed and voiding the filter for exactly the local-plugin-heavy projects. The staged deps tree was already excluded; the in-tree originals were not. Fix: root.zig resolves every `local:`/`@libs` plugin (cache.resolvePlugin) and every `local:` framework override (cache.resolveFrameworkPackage for core/engine/gfx) — the same resolution discovery uses — and passes the dirs to filterConsumedEvents as excluded roots. scanDir skips any directory whose CANONICAL path (realpath both sides — symlink- and path-separator-robust, no raw string compare of joined paths) equals an excluded root. Asymmetry that stays, by design: a local plugin's hooks/scripts staged into `<target>/scripts` / `<target>/packs` are still scanned, so cross-plugin consumption via shipped scripts keeps working — only the plugin's own module source is excluded, making local plugins symmetric with published ones (whose cache dirs were never scan roots). Tests: fixture game with `libs/box2d/src/root.zig` carrying the tag at an emit site → both events elided; same fixture plus a real consumer in game scripts → consumed event kept. Also verified e2e on the plugin-controllers example (`local:./plugin` + Events + emit-site text → elided; game-script consumer → kept). Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: no-false-negative scan hardening + output-dir exclusion + shim diagnostics (#631 CodeRabbit) Three findings on 6ed5fef: 1. MAJOR — incomplete scan treated as "no consumer": oversized files and mid-walk I/O errors were silently skipped, so a consumer living only in a skipped file got elided — silent event-delivery breakage. Oversized files are now STREAM-SCANNED instead of skipped: fixed 256 KB chunks with an overlap of (longest needle − 1) bytes carried between windows, so a tag straddling a chunk boundary still matches and no size cap exists at all (beats keep-everything-on-skip, which would void the filter for any game with a big generated .json). Mid-walk open/read/iterator errors now FAIL generate loudly with a pointed diagnostic naming the path and the `.plugin_events = .all` escape hatch; the only silent skips left are provably-consumer-free cases: a missing scan root, a dangling symlink, an entry deleted mid-walk. Docs updated to match. 2. MAJOR — output dir not excluded from the game-root scan: the `.labelle` basename skip only covers the DEFAULT output name; a caller-configured in-tree output dir was scanned, and stale generated files retain every previously known tag (a stale main.zig names each kept variant, and even the elision comments spell the qualified tag) — a self-perpetuating ratchet. root.zig now always adds the output dir to the excluded roots (canonical-path equality, same mechanism as the dependency exclusions); the basename skip stays as belt and suspenders. Excluding the output ROOT also covers deps/ and stale sibling target dirs under a non-default name. The explicit <target>/scripts / <target>/packs roots are unaffected (passed directly, not reached by descent). 3. MINOR — all-elided case lost the elided comments in game.zig: the shim's gate skipped the PluginEvents context entirely when every event was elided. The shim now emits the per-event `// elided (no consumer): <tag>` comments in that zero-kept path (without adding a PluginEvents decl, matching main.zig's v1 path). Tests: >chunk-size fixture with a tag straddling the window boundary and one at the file tail -> both kept; stale generated main.zig in an `out/` (non-.labelle) output dir -> events stay elided; all-elided shim output carries the comments and no PluginEvents decl. Verified e2e on the plugin-controllers example (21 elision comments in both main.zig and game.zig, no decl). Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: pre-filter collision gate for Zig/pack game events + tests/-only consumers excluded from production (#631 codex) Two codex P2s on a65cb16: 1. Collision diagnostic regression: a game `events/box2d__collision_begin.zig` (or a pack event whose namespaced variant spells the tag) colliding with a discovered plugin event always reached MergeHookPayloads' comptime duplicate-field check pre-#630 — but with consumption filtering, a tag referenced NOWHERE in scanned sources elides the plugin entry first, the collision silently vanishes, and the plugin's @Hasfield emit gate turns ON against the game's same-named payload (wrong payload / confusing missing-field error at the emit helper). New `scripting_declare.checkGameEventPluginCollisions` checks the FULL discovery list against game event stems (eventVariantName) and pack variants (<pack>__<ident>) with the declare-phase's pointed voice; root.zig runs it beside checkEventPluginCollisions, BEFORE the filter, so collision behavior stays consumption-independent. Pack-side comparison uses a virtual-concatenation comparator (no fixed-size format buffer — the PR #618 silent-skip trap — and exact even for __-misaligned equal joins). Narrow corner by construction: any REAL consumer of the colliding tag keeps the plugin entry and restores the old loud comptime error; the gate closes the dead-declaration case. 2. tests/ sources counted as production consumers: a test file naming a tag kept the variant in the PRODUCTION target even though tests/ only feeds the separate __tests_root.zig target. root.zig now excludes exactly `<game_dir>/tests` (canonical-root exclusion, NOT a blanket "tests" basename skip) — and only when `!is_tests_target`: the tests-target pass keeps scanning tests/ so a test referencing a variant gets it in its OWN GameEvents and compiles. Per-target divergence documented next to the exclusion (consistent with testsTargetConfig's backend/.platform overrides). Tests: collision gate unit tests (game stem, subdir stem, pack variant, misaligned __ join, near misses, empty discovery); tests/-only tag → elided with the exclusion, kept without (the tests-target shape). E2E on plugin-controllers: events/demo_plugin__demo_ping.zig fails generate with the collision error; a tests/-only reference is elided in .labelle/null_desktop/{main,game}.zig while .labelle/tests/game.zig keeps the variant. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: follow dir symlinks in the consumption scan + duplicate-tag gate across providers (#631 codex) Two codex P2s on cc29a3b: 1. Symlinked directories were scanned as FILES (false-negative, the dangerous direction): scanDir's `.sym_link` arm shared the file path, so a symlinked convention dir (`hooks/` -> real dir elsewhere -- which scanner.linkAndScan happily follows and compiles) was opened as a file and never descended into; consumers inside it were invisible and their events silently elided. The walk now stats each symlink's target: directories are followed, files stream-scanned, dangling/self-looping links skipped (provably no content). Followed dirs go through the new `enterDir` gatekeeper, which canonicalizes once and applies BOTH checks to the RESOLVED path: the excluded-roots equality check (a symlink pointing INTO an excluded dependency dir cannot smuggle it back into the corpus) and a visited set of canonical dir paths (symlink-CYCLE protection; also harmlessly dedupes the POSIX <target>/scripts-symlink double-scan). `enterDir` carries an explicit error set to break the enterDir <-> scanDir inferred-error-set loop. 2. Duplicate qualified tags across providers half-vanished under filtering: `foo-bar` + `foo_bar` both declaring `hit` produce the SAME `foo_bar__hit` tag. Pre-#630 the duplicate union field failed loudly at the decl; post-#630 a source naming only one dotted form kept one entry and elided the other -- no duplicate, no error, and the elided plugin's emit gate turns ON against the kept plugin's payload. New `scripting_declare.checkDuplicatePluginTags` walks the FULL discovery list for pairs with an identical `<plugin_sanitized>__<event_name>` (virtual-concat comparator, so `__`-misaligned equal joins are caught) and fails generate naming BOTH providers. Runs in every mode including `.all` -- the pointed diagnostic is strictly better than the raw duplicate-field compile error it replaces. Tests: symlinked dir holding the only consumer -> kept; dir-symlink loop -> terminates (and the sibling consumer is still found); symlink into an excluded root -> still elided (all three POSIX-gated with SkipZigTest on Windows); duplicate-tag pairs (sanitized collision + misaligned join) -> error, distinct prefixes with the same event -> pass. E2E on plugin-controllers: a consumer behind a dir symlink to OUTSIDE the game tree keeps the variant; removing the link re-elides. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: ancestor-aware exclusion — descendants of excluded roots reachable via symlink (#631 codex) enterDir's excluded-roots check was exact canonical-path EQUALITY. A symlink pointing at a CHILD of an excluded root (`vendored_src` -> `libs/box2d/src`, or `generated` -> `out/raylib`) canonicalizes to the child path, matches no excluded root, and got scanned — re-admitting plugin emit sites (marks that plugin's events consumed, re-voiding the filter) or stale generated output (re-opening the self-retention ratchet). The predicate is now the closed form "is or is under an excluded root" (`isUnderExcludedRoot`): canonical-vs-canonical prefix match with an explicit `std.fs.path.sep` boundary check, so `/a/libs/box2d2` does NOT match an excluded `/a/libs/box2d` — and the separator byte stays correct on Windows, where canonical paths use backslashes. Doc notes why descendants must be covered: the walk stops AT an excluded root (the equality half), so a descendant is only ever reachable via a symlink jumping into the middle of the excluded tree; the ancestor-aware form subsumes the equality check and closes that hole. Tests: symlink to a child dir of an excluded dependency root carrying an emit-site tag -> still elided (POSIX-gated like the other symlink tests); sibling name near-miss (`libs/box2d2` beside excluded `libs/box2d`) -> its genuine consumer is scanned and keeps the event. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: closed-form exclusion — longest-match allow/deny over canonical paths (#631 codex) codex P2 on 42fa3ed: the excluded-roots check was exact canonical-path EQUALITY, so a symlink pointing at a CHILD of an excluded root (`vendored_src` -> `libs/box2d/src`, `generated` -> `out/raylib`) canonicalized to the child path, matched nothing, and got scanned -- re-admitting plugin emit sites (re-voiding the filter) or stale generated output (re-opening the self-retention ratchet). The predicate is now closed-form and TWO-SIDED (`exclusionVerdict`): a canonical dir is excluded when its most specific (longest) matching EXCLUDED root is deeper than its most specific matching SCAN root. "Matching" = equals-or-is-under with an explicit `std.fs.path.sep` boundary check (`/a/libs/box2d2` does not match excluded `/a/libs/box2d`; correct on Windows where canonical paths use backslashes). Descendants only ever arise via symlink jumps -- the walk stops AT an excluded root -- so ancestor-awareness closes that hole and subsumes the old equality check. The ALLOW half exists because a plain is-or-under deny has a fatal interaction this round's first cut exposed (caught before pushing, via an e2e probe + new regression test): the explicit staged scan roots `<target>/scripts` / `<target>/packs` are themselves strict descendants of the excluded OUTPUT dir, and a one-sided deny swallowed the staged corpus -- published packs' hooks exist ONLY there (and on Windows the copied plugin scripts too). Deeper rule wins: the staged roots and their subtrees stay scanned while the rest of the output dir (stale main.zig, deps/, stale sibling targets) stays out, and an in-tree dependency root (deeper than the game-dir scan root) still denies its subtree. Tests: symlink to a CHILD of an excluded dependency root -> still elided (POSIX-gated); sibling near-miss `libs/box2d2` beside excluded `libs/box2d` -> scanned normally; explicit scan root INSIDE the excluded output dir -> its staged hook keeps the event while a stale main.zig beside it cannot ratchet the other back. E2E on plugin-controllers: a consumer under `<target>/packs` counts again; removing it re-elides. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM * fix: force-keep runtime-subscribed channels + neutralize orphaned flow sidecars (#631 codex) Two codex P2s on 407b964 — a new finding class: consumers the text scan structurally cannot see. 1. Runtime-subscribed engine events: `engine__editor_plugin_command` (labelle-engine#729, editor-contract v1.7 / bridge v1.8, script contract v1.2) is emitSync'd by the engine's editor_command_mixin when the studio dispatches a panel action or a script-language `labelle_plugin_call` wrapper fires; handlers register at RUNTIME (script-contract subscriptions) or via plugin hooks whose module source this scan deliberately excludes as a dependency emit-site — no game-authored text need ever name the tag, so the filter elided it and studio panel commands silently broke. Structural fix: an explicit `force_consumed` tag list threaded into filterConsumedEvents, pre-marked before the walk (and documented at the root.zig wiring site with WHY + where the runtime subscription lives). Survey of engine origin/main + assembler codegen seams found exactly this one runtime-wired channel; it is force-kept UNCONDITIONALLY (the plugin_call channel works on every platform, generate-time presence detection of handlers is unreliable, and one kept emitSync-only variant has zero steady-state cost vs dead panels). All other engine events are delivered to authored hooks/flows/scripts and stay under the text scan. 2. Orphaned generated flow sidecars: flow_scanner.scanAndEmit writes `<stem>.zig` beside `<stem>.flow.jsonc` and never prunes, so a removed/renamed Event-node flow left a stale sidecar whose qualified tags kept dead events alive forever. The walk now skips a `scripts/flows/**/<stem>.zig` iff no sibling `<stem>.flow.jsonc` exists (pairing convention matched from flow_scanner: rel_stem + ".zig"); paired sidecars stay in the corpus (harmless — redundant with the source's dotted OnEvent refs). Detection of the flows dir is by basename pair (`scripts`/`flows`), path-separator agnostic. Sidecar PRUNING stays a separate flow_scanner concern (noted in the PR body as a known adjacent gap, not built here). Also updated the PR description: the round-2 stream-scan replaced the "2 MB per-file cap" wording, and a review-evolution section now summarizes the six corpus-boundary rounds for the merge record. Tests: force-kept tag with zero textual mentions -> kept while its sibling elides; orphan sidecar with the only tag reference -> elided; paired sidecar -> kept. E2E on plugin-controllers: a stale scripts/flows/dead.zig naming demo_plugin__demo_ping no longer keeps the variant. Claude-Session: https://claude.ai/code/session_011szWvquoss1yNX7KWSKCaM
Slice 2 of labelle-toolkit/labelle-engine#772 (slice 1 = labelle-scripting v0.10.0, the schema producer):
What ships
"events":[{name, fields}]parses intoDeclaredEvent(absent → empty; shared field parser with the component path, component-onlysaverule preserved). u64 joins the field-type map —Labelle.idis legal in component fields too, so v0.10.0 component schemas need it regardless.scripting_events.zigat the target root (pub const HungerFeed = struct { amount: f32 = 0.5, entity: u64 = 0 };), union rows import it inline (hunger__feed: @import("scripting_events.zig").HungerFeed) — no module-scope alias to collide with game event aliases. PascalCase fold with post-transform collision detection; keyword/primitive/identifier gates.events/(the ticket's original sketch):linkAndScanstages convention dirs as whole-directory symlinks into the game tree (scanner.zig:269–378) — writing "into" stagedevents/would write through into the game's sources. The single-file route keeps the live-link property and the union rows come out equivalent. Consequence, documented: native hooks consuming a declared event spell the payload paramanytype(the dispatcher's@field(Base, name)(receiver, data)never inspects param types; Zig-authored events keep typed imports as before).events/*.<ext>embeds and registers components → events → scripts (order pinned by test and e2e); extension-keyed like every convention dir.events_min_pin(0.10.0, both runner rows) → names the floor and the files (an old runtime prelude lacksLabelle.event; late failure would be a boot NoMethodError).DeclareToolAbsent+ events files upgrades from graceful skip to the same error.ScriptEventsUnsupported(previously those files were invisible to everything, so nothing existing breaks).Verification
examples/ruby-gamewithevents/hunger__feed.zigreplaced by the one-line.rb, scripts untouched (they emit/subscribe by name string), hook param →anytype: generate (no skip/grace notes) → build → run → exact 16-token transcript withZIG_FEED_SEEN_0.5intact — a ruby-declared event consumed by a native Zig hook, payload whole.zig build testexit 0 (clean-HOME convention; the two flow_catalog failures under a real HOME are pre-existing on origin/main —~/.labellecache leakage, unrelated).zig fmtper-file only.Part of labelle-toolkit/labelle-engine#772. Slice 3 (the example repo migration) follows on the v0.87.0 release.
https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
Summary by CodeRabbit