Skip to content

Declare: script-declared events (events/*.rb|lua) — collection, codegen, pin gating - #618

Merged
apotema merged 2 commits into
mainfrom
feat/declare-events
Jul 12, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/declare-events

Conversation

@apotema

@apotema apotema commented Jul 12, 2026 •

Copy link
Copy Markdown
Contributor

Slice 2 of labelle-toolkit/labelle-engine#772 (slice 1 = labelle-scripting v0.10.0, the schema producer):

# events/hunger__feed.rb — one line, and the event exists
HungerFeed = Labelle.event "hunger__feed", entity: Labelle.id, amount: 0.5

What ships

  • Schema: "events":[{name, fields}] parses into DeclaredEvent (absent → empty; shared field parser with the component path, component-only save rule preserved). u64 joins the field-type map — Labelle.id is legal in component fields too, so v0.10.0 component schemas need it regardless.
  • Codegen: one generated scripting_events.zig at 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.
  • Why not per-event files in staged events/ (the ticket's original sketch): linkAndScan stages convention dirs as whole-directory symlinks into the game tree (scanner.zig:269–378) — writing "into" staged events/ 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 param anytype (the dispatcher's @field(Base, name)(receiver, data) never inspects param types; Zig-authored events keep typed imports as before).
  • Collection + registration: events/*.<ext> embeds and registers components → events → scripts (order pinned by test and e2e); extension-keyed like every convention dir.
  • Gating, all fail-up-front with pointed errors (all exercised via real generates, exit 1):
    • pin below events_min_pin (0.10.0, both runner rows) → names the floor and the files (an old runtime prelude lacks Labelle.event; late failure would be a boot NoMethodError). DeclareToolAbsent + events files upgrades from graceful skip to the same error.
    • events dir declaring zero events → error showing the declaration shape (aggregate over the dir — the schema has no file attribution, and per-file tool re-runs would false-fail chunks that share earlier declarations' constants; documented).
    • no-runner language (typescript) + events files → ScriptEventsUnsupported (previously those files were invisible to everything, so nothing existing breaks).
  • Sidecar manifest mirrors declared events into the game realm (the declared-components pattern).

Verification

  • E2E: scratch copy of scripting v0.10.0's examples/ruby-game with events/hunger__feed.zig replaced 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 with ZIG_FEED_SEEN_0.5 intact — a ruby-declared event consumed by a native Zig hook, payload whole.
  • Suite: zig build test exit 0 (clean-HOME convention; the two flow_catalog failures under a real HOME are pre-existing on origin/main — ~/.labelle cache leakage, unrelated).
  • ~15 new/extended unit tests incl. a real-exec fake-tool fixture; zig fmt per-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

  • New Features
    • Added support for declaring game events in scripts.
    • Script-declared events now support payload fields, including vectors and entity identifiers.
    • Declared events appear in generated event unions and manifest metadata.
    • Added validation for invalid names, duplicate fields, and conflicts with existing events.
    • Added compatibility checks and clear errors when the scripting tool lacks event support.
    • Improved cleanup of generated files when event declarations are added or removed.

…, 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
@coderabbitai

coderabbitai Bot commented Jul 12, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@apotema, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7192c0c1-39c9-47e2-993c-16ff5162902a

📥 Commits

Reviewing files that changed from the base of the PR and between 10d7898 and c092f27.

📒 Files selected for processing (3)
  • src/language_policy.zig
  • src/root.zig
  • src/scripting_declare.zig
📝 Walkthrough

Walkthrough

Script-declared events are parsed, validated, rendered into scripting_events.zig, collected alongside components and scripts, integrated into generated event unions and hook payloads, and emitted in the manifest sidecar.

Changes

Script-declared events

Layer / File(s) Summary
Declaration schema and generated event artifacts
src/scripting_declare.zig
Event schemas, field validation, collision checks, version gates, generated payload structs, stale-file cleanup, and phase tests are added.
Event embed and generation pipeline
src/scripting_splice.zig, src/root.zig
Event declaration embeds are collected and ordered between components and scripts, then passed through declaration, transpile, and manifest generation.
Event manifest and union integration
src/codegen/..., src/manifest/emit.zig
Declared events are exposed to codegen, added to game-event unions and hook payloads, and appended to manifest event metadata.

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
Loading

Possibly related issues

Possibly related PRs

Poem

A rabbit found events in a burrow of code,
Packed them in structs for the Zig road.
Components, scripts, and events in a line,
Unions and manifests now neatly combine.
“Hop!” cried the hare, “the pipeline is bright!”

🚥 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 main change: adding script-declared events and their collection, code generation, and pin gating.
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/declare-events

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.

❤️ Share

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 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.

Comment thread src/scripting_declare.zig
Comment on lines +1009 to +1035
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;
}
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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;
                }
            }
        }
    }
}

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 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.

@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/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

📥 Commits

Reviewing files that changed from the base of the PR and between ab14d95 and 10d7898.

📒 Files selected for processing (7)
  • src/codegen/blocks/events.zig
  • src/codegen/blocks/hooks.zig
  • src/codegen/context.zig
  • src/manifest/emit.zig
  • src/root.zig
  • src/scripting_declare.zig
  • src/scripting_splice.zig

Comment thread src/root.zig
Comment thread src/scripting_declare.zig

Copilot AI 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.

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[], generate scripting_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 so GameEvents/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.

@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: 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".

Comment thread src/root.zig
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.*);

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 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 👍 / 👎.

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 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
@apotema
apotema merged commit ca8c7e3 into main Jul 12, 2026
4 checks passed
@apotema
apotema deleted the feat/declare-events branch July 12, 2026 13:28

@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: 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".

Comment thread src/root.zig
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.*);

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 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 👍 / 👎.

apotema added a commit that referenced this pull request Jul 18, 2026
… 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
apotema added a commit that referenced this pull request Jul 18, 2026
* 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
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.

2 participants