Skip to content

feat(#584): .language policy — one script language per project - #589

Merged
apotema merged 3 commits into
mainfrom
feat/584-language-policy
Jul 10, 2026
Merged

apotema merged 3 commits into
mainfrom
feat/584-language-policy

Conversation

@apotema

@apotema apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

One script language per project — the .language policy from RFC-LANGUAGE-PLUGINS revs 8–9. Closes #584 (Phase 1 of the language-plugins epic labelle-toolkit/labelle-engine#237).

What it enforces

  • Singular .language on plugin declarations (mixing is unrepresentable in config); vocabulary {lua, typescript, ruby, rust, crystal, go, csharp}; at most one scripting plugin per project.
  • validateLanguagePolicy gate (beside validatePackGraph, before any target write — violations reject with no stale output): scans language convention dirs across the game root and all packs (game-local + plugin-bundled, riding P2's PackEntry/resolveSrcDir), hard-errors with a capped file listing on out-of-language scripts, errors on script dirs with no scripting plugin, warns on empty dirs.
  • requires_language on both plugin and pack manifests (P2 split the schemas — landed in both): vocab-checked at manifest load, project-match checked in the gate, mismatches name both sides.

Verification

Clean-HOME (CI-equivalent): 48/48 steps, 1512/1516 passed (4 skipped, 0 failed) — suite grew +65. Includes the load-bearing regression guard: a clean project generates byte-identically (real generate() output pinned against the committed pre-#584 golden). Real-HOME shows 2 failures that reproduce identically on clean origin/main (flow_catalog sidecar test resolving a populated ~/.labelle engine cache) — pre-existing and environment-dependent, not from this change.

Discovered along the way (follow-up to be filed, out of scope here)

A light pack shipping ONLY pack.labelle (no convention dirs) breaks full generate() at writePackModuleRoots (scanPack never creates the target pack dir → FileNotFound) — predates this PR.

Epic: labelle-toolkit/labelle-engine#237 · contract PR: labelle-toolkit/labelle-engine#749

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

The one-language-per-project policy from RFC-LANGUAGE-PLUGINS revs 8-9
(labelle-engine#237), enforced at generate time before any target
write:

- PluginDep gains singular .language (mixing unrepresentable); vocab =
  {lua, typescript, ruby, rust, crystal, go, csharp}, each name = its
  convention dir; at most ONE plugin may declare it.
- validateLanguagePolicy gate beside validatePackGraph: scans language
  convention dirs across the game root AND all packs (game-local +
  plugin-bundled, riding P2's PackEntry/resolveSrcDir), hard-errors
  with a 10-file listing on out-of-language scripts, errors on
  language dirs with no scripting plugin, warns on empty dirs.
- requires_language on plugin AND pack manifests (P2 split both
  schemas): vocab-checked at manifest load, project-match checked in
  the gate, mismatch names both sides.
- Errors follow the P2 house style with actionable fixes; violations
  reject before target-dir creation (no stale output).
- Tests: +65 (18 unit in-module, 7 integration over real pack loading,
  zspec e2e through real generate() incl. no-target-dir-on-failure and
  the clean-project BYTE-IDENTITY guard pinned to the committed
  golden). Clean-HOME suite: 48/48 steps, 1512/1516 (4 skipped).

Discovered (out of scope, follow-up to be filed): a light pack shipping
only pack.labelle breaks writePackModuleRoots at generate — predates
this change.

Epic: labelle-engine#237

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
@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 10, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 21 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: 10b3d827-4ffe-4b2b-b1be-a4a82c96d63b

📥 Commits

Reviewing files that changed from the base of the PR and between c620e53 and 4958e0b.

📒 Files selected for processing (8)
  • build.zig
  • src/config.zig
  • src/language_policy.zig
  • src/plugin_manifest/pack.zig
  • src/plugin_manifest/plugin.zig
  • src/root.zig
  • src/root/generate_phases.zig
  • test/language_policy_tests.zig
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/584-language-policy

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.

Design pivot before merge (RFC rev 13): plugin options must never
become bespoke PluginDep fields. The declaration is now
.params = .{ .language = "lua" } — PluginDep.params is the v1 slice
of the generic plugin-params mechanism (schema-declared params =
follow-up #591). Policy semantics unchanged (vocabulary, one
scripting plugin, dir scan, requires_language); diagnostics spell
.params.language.

Unknown params are hard parse errors by construction (std.zon
ignore_unknown_fields=false), pinned by a test asserting the
'unexpected field' diagnostic — which surfaced a std quirk: a null
Diagnostics pointer leaks the parser's owned note; the test passes a
real Diagnostics (+deinit).

Clean-HOME: 48/48 steps, 1518/1522 (4 skipped), +6 tests;
byte-identity guard green.

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

apotema commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Design pivot applied before merge (RFC rev 13 + labelle-toolkit/labelle-engine#237 decision): the declaration is now .params = .{ .language = "lua" } — a generic per-plugin params bag (full schema-driven mechanism tracked in #591) instead of a bespoke PluginDep.language field. Policy semantics unchanged; unknown params hard-fail at parse by construction (pinned by test). Clean-HOME: 48/48 steps, 1518/1522 (+6).

https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j

@apotema
apotema merged commit df415a0 into main Jul 10, 2026
4 checks passed
apotema added a commit that referenced this pull request Jul 10, 2026
…, drain touchpoint (#596)

Makes .params.language projects RUN their scripts (epic
labelle-engine#237; contract labelle-engine#749; plugin
labelle-scripting#1):

- scripting_splice.detect(): the #589-parsed language + manifest name
  'scripting' gate; EMBED_LANGUAGES table (lua -> .lua; native
  languages deliberately absent -> warn, no splice).
- Copy <lang>/ via the prefabs/scenes scanner machinery (sorted stems,
  /-joined subdirs); generated main registers each script
  (scripting.registerScript(stem, @embedfile(...))) BEFORE
  PluginControllers.setup boots the VM — both lifecycle shapes.
- module_vars emit the scripting import alias + scripting_enabled
  flag; build.zig passes .language enum to the plugin dependency.
- drainEvents rides tick_code between the plugin ticks and
  g.dispatchEvents (engine-gated, outside the scaled_dt gate) — NOT
  the backend template: dispatch is emitted inside the tick_code
  hole, so a template drain would tap an already-swapped buffer;
  riding tick_code also lands the drain on every backend at once.
  Templates carry only the bind touchpoint (bgfx PR).
- Tests: 6 zspec (registrations sorted/pre-setup, drain placement,
  whole-main expectAstGenOk, empty-set, no-splice zero-markers,
  build.zig .language) + 7 unit; all byte-identity goldens unchanged
  (script-less projects byte-identical). Clean-HOME: 50/50 steps,
  1576/1580 (4 skipped).

Closes #593

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
apotema added a commit that referenced this pull request Jul 11, 2026
…597)

* feat(#593): scripting codegen splice — embed scripts, wire the plugin, drain touchpoint

Makes .params.language projects RUN their scripts (epic
labelle-engine#237; contract labelle-engine#749; plugin
labelle-scripting#1):

- scripting_splice.detect(): the #589-parsed language + manifest name
  'scripting' gate; EMBED_LANGUAGES table (lua -> .lua; native
  languages deliberately absent -> warn, no splice).
- Copy <lang>/ via the prefabs/scenes scanner machinery (sorted stems,
  /-joined subdirs); generated main registers each script
  (scripting.registerScript(stem, @embedfile(...))) BEFORE
  PluginControllers.setup boots the VM — both lifecycle shapes.
- module_vars emit the scripting import alias + scripting_enabled
  flag; build.zig passes .language enum to the plugin dependency.
- drainEvents rides tick_code between the plugin ticks and
  g.dispatchEvents (engine-gated, outside the scaled_dt gate) — NOT
  the backend template: dispatch is emitted inside the tick_code
  hole, so a template drain would tap an already-swapped buffer;
  riding tick_code also lands the drain on every backend at once.
  Templates carry only the bind touchpoint (bgfx PR).
- Tests: 6 zspec (registrations sorted/pre-setup, drain placement,
  whole-main expectAstGenOk, empty-set, no-splice zero-markers,
  build.zig .language) + 7 unit; all byte-identity goldens unchanged
  (script-less projects byte-identical). Clean-HOME: 50/50 steps,
  1576/1580 (4 skipped).

Closes #593

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

* feat(#739): scripting-smoke example — Lua vs the real engine, in CI

The Phase-1 proof (labelle-engine#739, epic #237): a null-backend
example whose behavior runs in Lua against the real engine through
the real generated main —

  LUA_INIT id=1 -> ticks 1..5 -> LUA_EMIT_OK (tick 2) ->
  LUA_ENGINE_TICK_SEEN + LUA_EVENT_SEEN_N_2 (tick 3, real bus) ->
  LUA_MOVED_X_30 (Position via the contract)

- examples/scripting-smoke: null-example shape, backend_package
  local:../../../labelle-null (branch-fallback sibling clone in CI),
  scripting plugin via the REAL consumer path (github v0.1.0 +
  .params.language = lua), custom lua_ping event, unified-format
  scene.
- CI: 6 codegen greps + build + LABELLE_NULL_FRAMES=5 run + exact
  ordered 10-token transcript diff.

Two chain bugs the demo flushed out, fixed here:
- plugin_registries: generated PluginControllers.deinit now arity-
  dispatches (v0.1.0 ships zero-arg deinit()).
- render lifecycle (SILENT FREEZE): nothing dispatched the scripting
  Controller's per-frame tick (no Systems -> PluginSystems skips it;
  PluginControllers wires only setup/deinit) — lua update() would
  never run. The splice now emits scripting.Controller.tick(&g,
  scaled_dt) inside the scaled_dt gate, before the drain. Tests
  extended (ordering, empty-set, no-splice absence, arity emission).

Clean-HOME suite: all steps green (4 skipped). plugin-controllers
example regression-checked (canonical 13-event sequence intact).

Closes labelle-engine#739

Claude-Session: https://claude.ai/code/session_01P7YLw4hXFCCaY2LAUt4G1j
apotema added a commit that referenced this pull request Jul 11, 2026
…livered comptime (#606)

* Schema-declared plugin params: validated at generate, delivered comptime

plugin.labelle gains .params_schema (name, str/i64/f64/bool/enum
types, enum vocab, defaults, required); the project's .params bag is
validated against it at generate (unknown/mistyped/out-of-vocabulary/
missing-required -> pointed errors naming plugin + param) and
delivered as a generated plugin_<name>_params.zig injected via
overrideImport under the fixed import name plugin_params.

Heterogeneous .params parsing: std.zon has no dynamic Value, so a
Zoir side-walk extracts each plugin's .params into tagged-scalar
bags, blanks the value spans (newline-preserving — every other
diagnostic keeps exact byte offsets AND line numbers) and re-runs the
unchanged strict typed parse. No-.params sources take a single-parse
fast path (byte-identical).

Supersede semantics: schema-less + exactly {language} keeps the #589
native fast-path (published scripting pins keep working — the rival
draft removed it and broke Examples CI); schema-less + any other key
errors (nothing silently drops); a present schema is the sole
authority, language included.

Manifest key is .params_schema, strict-parsed even though the
manifest stays tolerant overall; .params stays free for future
presets. Additive beside #586's .build block.

+70 tests (31 unit + 8 e2e + threading), #589 policy tests green
UNMODIFIED, params-less plugins byte-identical (golden). Negative-
verified x3: unconditional emitter -> 3 byte-identity pins;
fast-path removed -> 11+ failures across 4 suites; corrupted
renderer -> unit + both delivery goldens.

Closes #591.

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

* OOM-path errdefers in plugin_params (review findings)

Two toArrayList->toOwnedSlice moves gain errdefer arr.deinit (safe:
the writer's deinit no-ops on the emptied buffer) and the resolved-
param append gains errdefer freeParamValue on the duped value — an
OutOfMemory mid-resolution no longer leaks the owned strings.

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

* Fix params review findings: enum-literal language, enum vocab/type collisions

- declaredLanguage() resolves .enum_tag bags too: the policy gate and
  scripting_splice.detect read this accessor BEFORE param resolution,
  so the natural enum-literal spelling .params = .{ .language = .lua }
  against an enum schema silently skipped the one-language checks and
  the entire splice (generate succeeded WITHOUT the scripting wiring).
  Audit: both pre-resolution consumers route through the accessor;
  the schema-less fast path stays string-only deliberately — it errors
  loudly at resolution with the pointed fix (documented).
- Schema load rejects duplicate enum vocabulary (each entry becomes a
  Zig enum tag) and PascalCase type-name collisions across enum params
  (mode/mode_ both render pub const Mode) — manifest errors at load
  instead of uncompilable generated projects. Param-decl vs type-decl
  collisions impossible by construction (documented).

+8 tests incl. an e2e where the enum-spelled language drives the real
splice (negative control: str-only accessor -> generate succeeds
silently without the splice arg — the exact reported gap). 1794/1798
clean-HOME.

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

Language plugins P1: .language policy + script-dir validation (requires_language)

1 participant