Repository navigation
feat: constants phases 1–3 + i18n phase 1 — the shared scan-and-codegen pass, complete - #656
Conversation
RFC-CONSTANTS (labelle-engine#811, tracking #810), phase 1 of 4: scan constants/ at the project root, parse the strict YAML subset, and emit constants.zig into the target dir. Game code reads @import("constants").C.<domain>.<name> and a misspelled name does not compile. Two new files and two wirings. constants_yaml.zig is the hand-written strict-subset parser -- Open Question 1, resolved: the subset is small enough that a parser is less code than a dependency, and it makes §2 enforceable by construction rather than by post-validation. Nothing in it implements YAML 1.1 implicit typing, so nothing can silently apply it: `no` is an error naming the fix, `12:30` and `0755` are refused as numeric literals, anchors/tags/flow/sequences/multi-doc are rejected by name. Scalars keep their SOURCE TEXT -- `5.0` emits as 5.0 and `5` as 5, which is what makes the generated declarations comptime_float vs comptime_int, the distinction §1.1's scalar-kind rule needs. Insertion order is kept so a diff of the YAML reads as a diff of the output. constants_phase.zig scans, parses, and emits: one namespace per file (filename is the namespace, so it must be a Zig identifier), values verbatim, strings escaped. Errors print file:line and the phase fails without writing. A project with no constants/ emits nothing, keeps a byte-identical build.zig, and a stale constants.zig from a previous run is deleted -- verified against the committed examples/null output, which regenerates unchanged. The wiring follows the promoted-scripts pattern: a `constants` module rooted at the generated file, overrideImport into game_mod, addImport on every artifact (wasm, exe, lib, test_root) and on the tests target. Verified end to end: examples/null with a constants/ dir generates, the emitted file ast-parses clean in the unit tests, and a probe compiled against the real generated output confirms reachability, string equality, f32/usize coercion, and integer division on the comptime_int -- the kind distinction is observable, not asserted. The 17 golden-test failures in the suite pre-exist on a clean checkout on this machine (CRLF vs byte-compared goldens); the failure set with this change is IDENTICAL to the clean tree's. Phase 2 (usage warnings, with the conservative alias-widening ruling) and phase 3 (pack constants, game-over-pack precedence) are deliberately absent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe assembler now parses constants YAML and locale JSONC files, generates deterministic ChangesGenerated constants and internationalization
Estimated code review effort: 5 (Critical) | ~90 minutes Sequence Diagram(s)sequenceDiagram
participant Root as root.zig
participant Constants as constants_phase.runPhase
participant I18n as i18n_phase.runPhase
participant Build as build_zig.zig
participant Artifacts as artifact root modules
Root->>Constants: generate constants.zig from game and pack YAML
Root->>I18n: generate i18n.zig from locale JSONC
Constants-->>Root: return constants generation state
I18n-->>Root: return i18n generation state
Root->>Build: pass generation states
Build->>Artifacts: attach enabled generated modules
Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (4)
src/constants_yaml.zig (2)
468-478: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a tab-indentation case to the rejected-constructs test.
No test covers a tab in the indentation. That gap is why the unused
has_tabflag reported at Lines 196-205 stayed hidden. Add the case so the intended message is pinned.💚 Proposed test addition
try testing.expect(std.mem.indexOf(u8, (try parseErr(a, "---\na: 1\n")).msg, "multi-document") != null); + try testing.expect(std.mem.indexOf(u8, (try parseErr(a, "a:\n\tb: 1\n")).msg, "tab") != null);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/constants_yaml.zig` around lines 468 - 478, Extend the “rejected constructs are named” test with an input containing tab indentation, and assert that parseErr reports the intended tab-related error message. Use the existing arena allocator and message-matching pattern so the has_tab detection path is covered.
243-248: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winExtend the ambiguity list to
nulland the single-letter YAML 1.1 booleans.
isForbiddenBoolcoversyes/no/on/offand the mixed-casetrue/falsespellings. It does not covery,Y,n,N, which YAML 1.1 also resolves as booleans. It also does not cover the null spellingsnull,Null,NULL,~, which YAML resolves to null. Todayowner: nullandflag: nbecome the Zig strings"null"and"n". That is a silent typing surprise of the same class the module header says it rejects by name.♻️ Proposed change
fn isForbiddenBool(s: []const u8) bool { - const forbidden = [_][]const u8{ "no", "No", "NO", "yes", "Yes", "YES", "on", "On", "ON", "off", "Off", "OFF", "True", "TRUE", "False", "FALSE" }; + const forbidden = [_][]const u8{ "no", "No", "NO", "yes", "Yes", "YES", "on", "On", "ON", "off", "Off", "OFF", "True", "TRUE", "False", "FALSE", "y", "Y", "n", "N" }; for (forbidden) |f| { if (std.mem.eql(u8, s, f)) return true; } return false; } + +/// YAML resolves these to null; a constants file has no null, so name the +/// trap instead of emitting the string "null". +fn isForbiddenNull(s: []const u8) bool { + const forbidden = [_][]const u8{ "null", "Null", "NULL", "~" }; + for (forbidden) |f| { + if (std.mem.eql(u8, s, f)) return true; + } + return false; +}Then reject it in
classifybeside the bool check:if (isForbiddenBool(text)) { return failc(self.arena, line, "'{s}' is ambiguous in YAML (1.1 reads it as a boolean). Write true/false, or quote it: \"{s}\"", .{ text, text }); } + if (isForbiddenNull(text)) { + return failc(self.arena, line, "'{s}' reads as null in YAML, and constants have no null. Quote it if it is meant as a string: \"{s}\"", .{ text, text }); + }Also applies to: 299-305
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/constants_yaml.zig` around lines 243 - 248, Extend isForbiddenBool to recognize YAML 1.1 single-letter booleans y, Y, n, and N, plus null spellings null, Null, NULL, and ~; ensure classify rejects these ambiguous scalars alongside the existing boolean check instead of treating them as strings.src/constants_phase.zig (2)
206-213: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExtract the tmp-path setup and cover the empty-
constants/branch.The four tests repeat the same eight lines that rebuild
.zig-cache/tmp/<sub_path>and joingameandtarget. The layout assumption is duplicated four times, so a change in thestd.testing.tmpDirlayout breaks all four in the same way. Extract one helper that returns both paths.No test covers the branch at Lines 85-89: a
constants/directory that exists but holds no*.yaml. That branch also deletes the stale file and returnsfalse. Please add a case for it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/constants_phase.zig` around lines 206 - 213, Extract the repeated `.zig-cache/tmp/<sub_path>` path construction from the four tests into a shared helper returning the `game` and `target` paths, and update those tests to use it. Add a test for an existing empty `constants/` directory, verifying the stale file is removed and `runPhase` returns false.
24-35: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low valueSynchronize lazy
std.Ioinitialization.
runPhaseaccesses_io_ready,_threaded, and_iowithout synchronization. Concurrent first calls can create multiplestd.Io.Threadedinstances and race on_io. Protect initialization with a once mechanism supported by the targeted Zig 0.16 toolchain. Do not assume thatstd.onceis available.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/constants_phase.zig` around lines 24 - 35, Synchronize lazy initialization in phaseIo by replacing the unsynchronized _io_ready check and shared-state writes with a once mechanism supported by the targeted Zig 0.16 toolchain, such as std.Io.Once if available. Ensure the initialization of _threaded and _io occurs exactly once before returning _io, and do not rely on std.once.
🤖 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/build_files/build_zig.zig`:
- Line 1128: Update the build flow around emitConstantsModule and
emitPromotedScriptModules so promoted modules receive opts.constants, emit
constants_mod before generating them, and conditionally add the constants import
when constants are configured. Preserve behavior when no constants or promoted
scripts are enabled.
- Around line 146-168: Update the iOS and Android alias conditions that declare
`target` to also include `opts.constants`, ensuring constants-only builds define
the target consumed by emitConstantsModule. Add regression cases covering
minimal constants-only iOS and Android projects.
In `@src/constants_phase.zig`:
- Around line 73-78: Update the directory iteration loop around
src_dir.iterate() and iter.next(io) to propagate iteration errors instead of
converting them to null; preserve normal loop termination and the existing file
and YAML filtering behavior.
- Around line 170-179: Update writeEscaped so remaining control bytes are
emitted as \xNN escapes instead of raw bytes, while preserving the existing
handling for backslash, quote, newline, tab, and carriage return. Classify bytes
in the control range and format each as a two-digit hexadecimal escape before
writing.
- Around line 141-163: Update identifier validation and formatting used by
generated filenames and mapping keys: reject invalid names, Zig keywords, and
“_” with source-line diagnostics, or emit them using quoted Zig identifiers.
Ensure emitMapping uses this formatter for declaration names rather than writing
keys directly. Extend writeEscaped to escape all control bytes, not only
newline, tab, and carriage return.
In `@src/constants_yaml.zig`:
- Around line 196-205: Update nextLine to set has_tab = true on the sentinel
Line returned for tab-indented input, so the existing Line.has_tab guard emits
the intended tab error before indentation matching or block-state updates.
Shorten the surrounding stale comment to describe the flag-based rejection path
rather than the old sentinel-only approach.
- Around line 270-288: Update stripComment so an unquoted '#' is treated as a
comment marker only at the start of the input or when immediately preceded by
whitespace; preserve '#' characters within bare scalars such as C#. Keep the
existing quoted-string handling and return behavior unchanged.
---
Nitpick comments:
In `@src/constants_phase.zig`:
- Around line 206-213: Extract the repeated `.zig-cache/tmp/<sub_path>` path
construction from the four tests into a shared helper returning the `game` and
`target` paths, and update those tests to use it. Add a test for an existing
empty `constants/` directory, verifying the stale file is removed and `runPhase`
returns false.
- Around line 24-35: Synchronize lazy initialization in phaseIo by replacing the
unsynchronized _io_ready check and shared-state writes with a once mechanism
supported by the targeted Zig 0.16 toolchain, such as std.Io.Once if available.
Ensure the initialization of _threaded and _io occurs exactly once before
returning _io, and do not rely on std.once.
In `@src/constants_yaml.zig`:
- Around line 468-478: Extend the “rejected constructs are named” test with an
input containing tab indentation, and assert that parseErr reports the intended
tab-related error message. Use the existing arena allocator and message-matching
pattern so the has_tab detection path is covered.
- Around line 243-248: Extend isForbiddenBool to recognize YAML 1.1
single-letter booleans y, Y, n, and N, plus null spellings null, Null, NULL, and
~; ensure classify rejects these ambiguous scalars alongside the existing
boolean check instead of treating them as strings.
🪄 Autofix
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: 3e835472-15f4-4484-8630-ce7e7e33802e
📒 Files selected for processing (4)
src/build_files/build_zig.zigsrc/constants_phase.zigsrc/constants_yaml.zigsrc/root.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6dfd91de1c
ℹ️ 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".
| // / `@import("<plugin>")` resolve exactly as they do when the file | ||
| // is path-imported by the root module. | ||
| try emitPromotedScriptModules(w, cfg, opts.promoted_scripts); | ||
| try emitConstantsModule(w, opts.constants); |
There was a problem hiding this comment.
Wire constants into promoted script modules
When a FlowNodes-bearing game script uses @import("constants"), it is compiled as its own named module, but that module's import table is created before this call and never receives constants_mod. Adding the module only to game_mod and artifact roots does not make it visible to the standalone promoted module, so otherwise valid constants usage in these scripts fails with an unresolved module import.
Useful? React with 👍 / 👎.
| 'r' => '\r', | ||
| '\\' => '\\', | ||
| '"' => '"', | ||
| else => s[i], // unknown escape: keep the char, drop the slash |
There was a problem hiding this comment.
Preserve or reject unsupported double-quoted escapes
For valid YAML escape sequences outside this small switch, the parser silently drops the backslash; for example, label: "\u00e9" is emitted as "u00e9" rather than "é". This silently corrupts constant values, so unsupported escapes should produce ConstantsInvalid or be decoded according to YAML rather than normalized by removing the slash.
Useful? React with 👍 / 👎.
| try w.writeAll(" .target = target,\n"); | ||
| try w.writeAll(" .optimize = optimize,\n"); | ||
| try w.writeAll(" });\n"); | ||
| try w.writeAll(" overrideImport(game_mod, \"constants\", constants_mod);\n"); |
There was a problem hiding this comment.
Reject constants imports that collide with plugins
When a project has a plugin named constants and also adds a constants/ directory, the plugin was already installed in game_mod under this same key, and this overrideImport replaces it with the generated data module. The artifact-level addImport("constants", ...) similarly replaces the plugin import, so generated registries that call @import("constants") receive the wrong module and fail; reserve this name for the feature or diagnose the collision.
Useful? React with 👍 / 👎.
| if (!std.ascii.isAlphabetic(s[0]) and s[0] != '_') return false; | ||
| for (s[1..]) |c| { | ||
| if (!std.ascii.isAlphanumeric(c) and c != '_') return false; | ||
| } |
There was a problem hiding this comment.
Reject Zig-reserved constant names
This character-class check accepts Zig keywords and primitives, so YAML such as fn: 1 or type: 1 passes generation but produces an invalid declaration such as pub const fn = 1;. Validate against Zig's keyword/primitive tables or emit names through std.zig.fmtId; the analogous filename-stem check in constants_phase.zig needs the same treatment.
Useful? React with 👍 / 👎.
| // directory-iteration order differences between filesystems. | ||
| var names: std.ArrayList([]const u8) = .empty; | ||
| var iter = src_dir.iterate(); | ||
| while (iter.next(io) catch null) |entry| { |
There was a problem hiding this comment.
Propagate constants directory iteration failures
If directory iteration fails after yielding some entries—for example on a transient or permission-related filesystem error—catch null treats the failure as normal EOF and generates constants.zig from only the files seen so far. This can silently omit constants or even disable the module entirely; propagate the iterator error with try as the repository's other generation scans do.
Useful? React with 👍 / 👎.
| } else switch (c) { | ||
| '"' => in_double = true, | ||
| '\'' => in_single = true, | ||
| '#' => return s[0..i], |
There was a problem hiding this comment.
Preserve hashes inside bare scalar values
When a bare scalar contains # without preceding whitespace, YAML treats it as part of the scalar, but this lexer always starts a comment; for example, id: icon#selected is silently emitted as "icon". Preserve such hashes or reject the scalar explicitly instead of generating a truncated constant value.
Useful? React with 👍 / 👎.
| if (std.mem.indexOfAny(u8, text, ":#") != null) { | ||
| return failc(self.arena, line, "'{s}' contains YAML syntax characters; quote it if it is meant as a string", .{text}); | ||
| } | ||
| return .{ .ok = .{ .kind = .string, .text = try self.arena.dupe(u8, text), .line = line } }; |
There was a problem hiding this comment.
Reject unsupported implicit null scalars
Bare YAML null spellings such as null, Null, NULL, and ~ fall through here and become ordinary strings even though the constants type model has no null kind. Thus target: null succeeds and emits target = "null", silently changing the authored value; reject these ambiguous spellings and require quotes when the literal text is intended.
Useful? React with 👍 / 👎.
| var t = s; | ||
| if (t.len > 0 and (t[0] == '-' or t[0] == '+' or t[0] == '.')) t = t[1..]; | ||
| return t.len > 0 and std.ascii.isDigit(t[0]); |
There was a problem hiding this comment.
Reject signed leading-dot numerics before string fallback
For number-shaped values such as -.5 or +.5, this removes only the sign and then requires the next character to be a digit, so the value falls through as an ordinary string instead of triggering the numeric-literal error used for .5. These inputs should be rejected under the documented numeric grammar rather than silently changing from a numeric value to a string.
Useful? React with 👍 / 👎.
RFC-CONSTANTS §5, with the ruling recorded in both RFCs implemented as
designed: conservative alias widening. The insight that keeps it simple is
that widening needs no dataflow at all -- a path chain that stops at an
interior node marks the whole subtree used, so
const cfg = C.decay.hunger;
... cfg.rate ...
is covered at the binding site and cfg itself never needs tracking.
usage_scan.zig is the shared pass RFC-CONSTANTS §6 calls for, parameterised by
module name and root symbol so i18n's K reuses it unchanged. It recognises the
ordinary shapes -- inline chains, a renamed root (const K2 = @import(...).C),
a re-rooted alias (const R = K2), expression-position interior use -- and
widens on every escape it cannot follow: the root passed as a value, a bare
module binding, an identifier between `=` and the root breaking the binding
shape (which is what makes `const x = f(C);` an escape rather than a false
alias). Mentions inside comments and string literals never count; `foo.C.x`
is somebody else's C and never matches.
The one direction that matters: the scanner may under-report dead constants,
it can never fire on a constant that is actually read. A warning that can be
wrong teaches people to ignore the lint, and the lint is the point.
Wired into runPhase after a successful emit. Warnings name the yaml source:
warning: constants/decay.yaml:4: C.decay.health.drain_rate is never read
Never fails the build. The walk skips .labelle, deps, caches and VCS dirs, so
the generator's own emissions cannot mark their constants used.
Verified end to end on examples/null: a script using C.construction.build_time
directly and C.decay.hunger.rate only through an alias produced warnings for
exactly the two constants nothing read, and nothing else. 22 unit tests; the
suite's failure set is byte-identical to main's (the 33 pre-existing
environmental E2E failures).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fbef4dfaf4
ℹ️ 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".
| try w.writeAll(" // Game constants (RFC-CONSTANTS phase 1): generated from constants/*.yaml.\n"); | ||
| try w.writeAll(" const constants_mod = b.createModule(.{\n"); | ||
| try w.writeAll(" .root_source_file = b.path(\"constants.zig\"),\n"); | ||
| try w.writeAll(" .target = target,\n"); |
There was a problem hiding this comment.
Emit the cross-target alias when constants are enabled
For iOS or Android projects that enable constants but have no plugins, non-mock ECS, GUI, or promoted scripts, the alias guards at lines 937 and 951 do not emit const target = ios_target/android_target; this newly generated module nevertheless references target. Since .mock is the ECS default, a minimal mobile game with constants/*.yaml produces a build.zig with an undefined identifier. Include opts.constants in both alias conditions or use the platform-specific target here.
Useful? React with 👍 / 👎.
| if (text.len >= 2 and text[0] == '\'' and text[text.len - 1] == '\'') { | ||
| return .{ .ok = .{ .kind = .string, .text = try self.arena.dupe(u8, text[1 .. text.len - 1]), .line = line } }; |
There was a problem hiding this comment.
Decode doubled quotes in single-quoted YAML
When a single-quoted YAML value contains an apostrophe, YAML escapes it by doubling the quote; for example, label: 'it''s ready' represents it's ready. Copying the interior verbatim instead emits it''s ready, silently changing the constant's value. Decode doubled single quotes according to YAML or reject this syntax explicitly.
Useful? React with 👍 / 👎.
| fn matchImport(self: *Tokenizer) ?[]const u8 { | ||
| const rest = self.source[self.i..]; | ||
| const prefix = "@import(\""; | ||
| if (!std.mem.startsWith(u8, rest, prefix)) return null; |
There was a problem hiding this comment.
Recognize whitespace around import arguments
When valid Zig source writes an import with trivia, such as @import ("constants").C, this exact-prefix matcher misses the import and never registers the C root. Subsequent real constant accesses are therefore unmarked, causing warnUnused to report constants that are actually read. Tokenize the builtin call rather than requiring the source bytes to be formatted exactly as @import("...").
Useful? React with 👍 / 👎.
| .file => { | ||
| if (!std.mem.endsWith(u8, entry.name, ".zig")) continue; | ||
| const source = dir.readFileAlloc(io, entry.name, arena, .limited(4 * 1024 * 1024)) catch continue; | ||
| try usage.scanSource(marks, .{}, source); |
There was a problem hiding this comment.
Suppress warnings when source files cannot be scanned
When a game Zig file cannot be read—for example because it exceeds the 4 MiB limit or encounters a transient I/O error—this catch continue omits all uses from that file but still completes collectMarks successfully. warnUnused then emits false warnings for constants that the skipped file actually reads, contrary to the stated fail-silent behavior. Propagate the scan failure so the outer catch {} suppresses the warning pass, or conservatively mark everything used.
Useful? React with 👍 / 👎.
| // Phase 2: usage warnings. Never fails the build -- a warning that can be | ||
| // wrong teaches people to ignore it, so the scan errs toward "used" and | ||
| // this errs toward silence on any I/O trouble. | ||
| warnUnused(arena, game_dir, leaves.items) catch {}; |
There was a problem hiding this comment.
Run the unused-constant scan only once per generation
The normal generate command first generates the game target and then calls generateTestsTarget, and both paths invoke runPhase, so every unused-constant diagnostic is printed twice and the entire game source tree is scanned twice. Gate this warning pass off for the tests target, or perform it once outside the per-target generation path.
Useful? React with 👍 / 👎.
| // Names bound to the root namespace ("C-roots"). Discovered top-down, | ||
| // which matches how Zig files read. | ||
| var roots = std.StringHashMap(void).init(arena); | ||
|
|
||
| var it = Tokenizer{ .source = source }; | ||
| while (it.next()) |tok| { |
There was a problem hiding this comment.
Discover constant aliases before scanning their uses
Zig declarations are not order-dependent, so valid code can use C.decay.rate in a function before a later file-level const C = @import("constants").C;. This single top-down pass ignores the earlier access because C has not yet been added to roots, and it never revisits it after discovering the alias, producing a false unused warning. Discover root aliases in a preliminary pass or rescan after the alias table is complete.
Useful? React with 👍 / 👎.
RFC-CONSTANTS §1.1, as ruled on labelle-engine#811: the game takes precedence
over the pack, with no visibility gate.
A pack's constants/<file>.yaml surfaces as C.<pack>__<file>.*. The game
overrides by carrying the prefixed name as a filename -- "filename is the
namespace" stays one rule with no override-specific carve-out:
packs/citizens/constants/hunger.yaml rate: 0.02 limit: 4
constants/citizens__hunger.yaml rate: 0.05
-> C.citizens__hunger.rate = 0.05 (the game's)
C.citizens__hunger.limit = 4 (the pack's, riding through)
The merge is deep, per leaf, and enforces both rules from the RFC:
- every write under a pack's namespace must match a key the pack defines.
"rte: 0.05" errors naming the path -- a silent create is exactly the
tuning-reverts-on-upgrade failure the rule exists to prevent, and this
check is the only thing that turns a pack renaming a constant in v2 into
a build failure rather than a game's tuning quietly reverting.
- an override keeps the scalar kind it replaces. "rate: 5" over 0.02 errors
telling the author to write 5.0 -- values are untyped and coerce at the
use site, so int-for-float changes integer division into rounding without
changing the number.
An override file naming a namespace no pack defines is an error for the same
reason. Emission is sorted by namespace, so pack and game domains interleave
deterministically.
The usage scan walks pack source dirs that live outside the game tree: a
cached pack's own scripts are the likeliest readers of its own constants, and
not scanning them would fire false "unused" warnings -- the direction the
phase-2 design forbids.
root.zig resolves each pack entry's source dir (the same resolveSrcDir the
pack scans use) and threads name+dir pairs into the phase. Projects with no
packs and no constants/ keep the byte-identical no-op, re-verified against
examples/null.
27 unit tests; suite failure set unchanged from main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
Phase 3 pushed ( Pack constants surface as The usage scan (phase 2) now also walks pack source dirs outside the game tree, since a cached pack's own scripts are the likeliest readers of its own constants — not scanning them would fire false "unused" warnings, the forbidden direction. 27 unit tests; the suite's failure set remains byte-identical to main's pre-existing 33. |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/constants_phase.zig (1)
355-359: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRemove the duplicated
constants/prefix in the warning.
leaf.filealready holds the display path. Line 138 sets it toconstants/<name>.yaml, and line 90 sets it topacks/<pack>/constants/<name>.yaml. Line 357 prependsconstants/again, so the warning printsconstants/constants/decay.yaml:3orconstants/packs/citizens/constants/hunger.yaml:2. Neither path exists.🐛 Proposed fix
- std.debug.print("warning: constants/{s}:{d}: C.{s} is never read\n", .{ leaf.file, leaf.line, leaf.path }); + std.debug.print("warning: {s}:{d}: C.{s} is never read\n", .{ leaf.file, leaf.line, leaf.path });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/constants_phase.zig` around lines 355 - 359, Remove the hardcoded `constants/` prefix from the warning format in the `leaves` iteration, and use `leaf.file` directly so paths set by the leaf construction logic display exactly once.
🤖 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/constants_phase.zig`:
- Around line 84-86: Update the openDir error handling near pdir_path and pdir
to continue only for error.FileNotFound and error.NotDir, matching the existing
game-path handling; propagate all other errors instead of silently skipping the
pack.
- Around line 110-113: Add a uniqueness check for every namespace before
appending to reg.entries, including plain game-domain entries and the
pack-derived namespace in the shown registration flow. When a duplicate is
found, reject it with an error identifying both defining files; otherwise
preserve the existing append behavior.
- Around line 349-353: Update the filtering loop around collectMarks so the
“inside game tree” check is path-aware rather than using std.mem.startsWith on
raw strings. Normalize or compare paths with a component-boundary-aware
relation, handling relative and absolute game_dir/src_dir consistently, so
siblings such as game_extra are scanned; alternatively remove the filter and
always collect pack marks as permitted by the surrounding logic.
---
Outside diff comments:
In `@src/constants_phase.zig`:
- Around line 355-359: Remove the hardcoded `constants/` prefix from the warning
format in the `leaves` iteration, and use `leaf.file` directly so paths set by
the leaf construction logic display exactly once.
🪄 Autofix
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: e3942522-e9de-4740-962f-052b9a08b856
📒 Files selected for processing (4)
src/build_files/build_zig.zigsrc/constants_phase.zigsrc/constants_yaml.zigsrc/root.zig
🚧 Files skipped from review as they are similar to previous changes (3)
- src/root.zig
- src/constants_yaml.zig
- src/build_files/build_zig.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 770b21e79a
ℹ️ 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".
| try w.writeAll(" .target = target,\n"); | ||
| try w.writeAll(" .optimize = optimize,\n"); | ||
| try w.writeAll(" });\n"); | ||
| try w.writeAll(" overrideImport(game_mod, \"constants\", constants_mod);\n"); |
There was a problem hiding this comment.
Wire constants into isolated pack modules
When a pack script reads its own generated namespace through @import("constants"), it is compiled under the isolated module created by emitPackModules, whose import table does not inherit either game_mod or the artifact root imports. Consequently, pack code—the source tree that warnUnused explicitly scans as a likely constants consumer—fails with an unresolved constants import; inject constants_mod into each pack module when constants are enabled.
Useful? React with 👍 / 👎.
| if (std.mem.indexOfAny(u8, text, ":#") != null) { | ||
| return failc(self.arena, line, "'{s}' contains YAML syntax characters; quote it if it is meant as a string", .{text}); | ||
| } | ||
| return .{ .ok = .{ .kind = .string, .text = try self.arena.dupe(u8, text), .line = line } }; |
There was a problem hiding this comment.
Reject unterminated quoted scalars
When a value starts with a quote but does not close it, such as label: "ready, neither quoted branch matches and this fallback accepts the malformed YAML as a bare string. Generation then succeeds with a constant whose value includes the leading quote, silently changing an authoring typo into data; reject quote-prefixed scalars unless their quoted form is complete.
Useful? React with 👍 / 👎.
| std.debug.print("{s}:{d}: '{s}' changes kind from {s} to {s} -- values are untyped and coerce at the use site, so an int-for-float override can change behaviour without changing the number. Keep the kind: write {s}\n", .{ display, os.line, path, @tagName(bs.kind), @tagName(os.kind), exampleOfKind(bs.kind) }); | ||
| ok = false; | ||
| } else { | ||
| be.node = .{ .scalar = os }; |
There was a problem hiding this comment.
Preserve override source locations in unused warnings
When a game override replaces a pack scalar and the resulting constant is unused, this copies the override's line number into the pack tree but leaves the registry entry's file set to the original pack YAML. collectLeaves therefore reports the game line against the pack file, potentially directing the user to an unrelated line or even beyond that file's end; scalar provenance needs to carry the override file as well as its line.
Useful? React with 👍 / 👎.
|
|
||
| for (leaves) |leaf| { | ||
| if (!marks.covers(leaf.path)) { | ||
| std.debug.print("warning: constants/{s}:{d}: C.{s} is never read\n", .{ leaf.file, leaf.line, leaf.path }); |
There was a problem hiding this comment.
Avoid duplicating the constants path in warnings
For every unused game constant, leaf.file already contains a path such as constants/decay.yaml (and pack leaves contain packs/<pack>/constants/...), so this additional constants/ prefix produces nonexistent locations such as constants/constants/decay.yaml and constants/packs/.... Print the stored path directly so diagnostics point to the authored file.
Useful? React with 👍 / 👎.
| if (!isIdentifier(stem) or !isIdentifier(pk.name)) { | ||
| std.debug.print("{s}: pack and file names must be Zig identifiers (they become the C.{s}__{s} namespace)\n", .{ display, pk.name, stem }); |
There was a problem hiding this comment.
Sanitize valid pack names before forming namespaces
When a light pack has a valid project name containing punctuation, such as my-pack, the rest of the pack pipeline supports it by deriving my_pack through packNamespacePrefix, but this check rejects the same pack as soon as it ships constants. Use the established pack-name sanitizer when forming C.<pack>__<stem> so otherwise supported packs can participate in the constants feature and game override filenames can use the canonical prefix.
Useful? React with 👍 / 👎.
| if (!std.mem.startsWith(u8, pk.src_dir, game_dir)) { | ||
| try collectMarks(arena, &marks, pk.src_dir); |
There was a problem hiding this comment.
Check pack ancestry on a path boundary
When an external pack directory merely shares the game directory's byte prefix—for example, game /work/game and pack /work/game-pack—this test treats the pack as if it were already inside the scanned game tree and skips it. Uses from that pack are then omitted and valid pack constants receive false unused warnings; compare normalized path components or require a separator boundary after game_dir.
Useful? React with 👍 / 👎.
| const ns = try std.fmt.allocPrint(arena, "{s}__{s}", .{ pk.name, stem }); | ||
| const stored = try arena.create(yaml.Mapping); | ||
| stored.* = root; | ||
| try reg.entries.append(arena, .{ .ns = ns, .file = display, .tree = stored }); |
There was a problem hiding this comment.
Reject colliding composite pack namespaces
When two otherwise valid packs and stems compose to the same identifier—for example pack a with b__c.yaml and pack a__b with c.yaml—both entries become C.a__b__c. The existing pack-prefix collision gate does not catch this because the pack prefixes themselves differ, so generation emits duplicate declarations and any override lookup is ambiguous; diagnose duplicate ns values before appending them.
Useful? React with 👍 / 👎.
| if (std.mem.indexOfAny(u8, text, ":#") != null) { | ||
| return failc(self.arena, line, "'{s}' contains YAML syntax characters; quote it if it is meant as a string", .{text}); | ||
| } | ||
| return .{ .ok = .{ .kind = .string, .text = try self.arena.dupe(u8, text), .line = line } }; |
There was a problem hiding this comment.
Reject unsupported block-scalar indicators
When a YAML value is an empty literal or folded block scalar, such as message: | or message: > at EOF, this fallback accepts the indicator itself as an ordinary string and emits "|" or ">" rather than the YAML value (an empty string). Non-empty block scalars happen to fail later due to indentation, but the empty form is silently corrupted; explicitly reject |/> and their chomping variants as unsupported syntax.
Useful? React with 👍 / 👎.
| fn matchFloat(s: []const u8) bool { | ||
| var t = s; | ||
| if (t.len > 0 and t[0] == '-') t = t[1..]; | ||
| const dot = std.mem.indexOfScalar(u8, t, '.') orelse return false; |
There was a problem hiding this comment.
Accept exponent-only floating literals
When a constant uses scientific notation without a decimal point, such as rate: 1e3, this mandatory-dot check rejects it even though it is a valid numeric spelling and the parser's own diagnostic advertises an optional exponent after the digit form. Recognize digits[eE][+-]?digits as a float so exponent notation does not fail generation solely because it omits a dot.
Useful? React with 👍 / 👎.
RFC-I18N (labelle-engine#811, tracking #809), phase 1: locales/*.jsonc scan, key codegen, static t(), usage-aware coverage diagnostics, setLocale. The second consumer of the scan-and-codegen shape constants proved, and the payoff of building usage_scan.zig parameterised: the i18n coverage pass is the same scanner with module "i18n" and root "K", zero new analysis code. What the RFC promises, held and verified on a generated module: - K.menu.new_gme does not compile, naming the missing declaration - the table is rectangular: pt-BR missing hud.frame_label gets en's string in that slot at build time, so no runtime path can fail and no fallback code exists - coverage is usage-aware: a key RENDERED somewhere and missing from a locale warns, naming every locale missing it; an untranslated key nothing draws is silent. .strict promotes the warning to a build error - a key present in pt but absent from the reference is a build error -- the rename-updated-one-file catch - locales/ without .i18n.default is a build error suggesting the block; a .default naming no file lists the scanned tags. No implicit "en" - setLocale refuses unknown tags unchanged; initFromEnvValue is the LABELLE_LOCALE hook and ignores garbage, so a leaked dev var cannot break a player's run i18n_locales.zig parses the JSONC (comments and trailing commas stripped outside strings, two passes so a comment between a trailing comma and its brace cannot hide it), flattens nested objects to dotted keys, and rejects non-identifier segments and non-string leaves -- the latter pointing at constants/, where numbers belong. Keys sort once, deterministically: index order is stable regardless of file layout (Open Question 3's concern). K declarations are @""-quoted so a key named like a Zig keyword still generates. config.zig gains the .i18n block (default required, reference falls back to it, strict). Wiring mirrors constants exactly: an i18n module rooted at the generated file, overrideImport into game_mod, addImport on every artifact. No locales/ -> nothing emitted, byte-identical build.zig, stale file deleted. Deliberately absent, per the phasing table: tf()/interpolation with per-key Args and the per-(key, locale) segment walk (phase 2), pack locales with the game-over-pack precedence and per-pack reference (phase 3), plurals (4). The generated main does not yet read LABELLE_LOCALE to call initFromEnvValue -- that is main-template wiring and rides with phase 2. 20 unit tests here; suite failure set identical to main's pre-existing 33. Verified end to end on examples/null: generate, probe test against the real generated module (default locale, backfill, switching, refusal, env hook), coverage warning firing for a used untranslated key, and the typo compile error -- then the example restored to byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
i18n phase 1 pushed ( // locales/pt-BR.jsonc
{ "menu": { "new_game": "Novo Jogo" } }const i18n = @import("i18n");
i18n.t(i18n.K.menu.new_game) // "Novo Jogo"; K.menu.new_gme does not compile
i18n.setLocale("en"); // instant, no I/O, no failure pathThe payoff of building Every §3 validation row for phase 1 is implemented and tested with the RFC's own examples: mandatory Deliberately absent per the phasing tables: i18n 20 new unit tests (55 total across the branch); suite failure set still byte-identical to main's pre-existing 33. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9a626be339
ℹ️ 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".
| for (reference.entries) |re| { | ||
| if (!marks.covers(re.key)) continue; |
There was a problem hiding this comment.
Keep conservative scan widening out of strict errors
When code uses the ordinary module-alias form const i18n = @import("i18n");, usage_scan.consumeAfterModule deliberately sets marks.all, so this loop treats every reference key as used. With .strict = true, any untranslated key then fails generation even if the game never accesses it, contradicting the usage-aware coverage contract; track module aliases precisely or avoid promoting conservatively widened marks to errors.
Useful? React with 👍 / 👎.
| fn writeEscaped(w: *std.Io.Writer, s: []const u8) !void { | ||
| for (s) |c| switch (c) { | ||
| '\\' => try w.writeAll("\\\\"), | ||
| '"' => try w.writeAll("\\\""), | ||
| '\n' => try w.writeAll("\\n"), | ||
| '\t' => try w.writeAll("\\t"), | ||
| '\r' => try w.writeAll("\\r"), | ||
| else => try w.writeByte(c), | ||
| }; |
There was a problem hiding this comment.
Escape decoded control bytes before emitting Zig
A valid JSON locale string such as "\u0000" is decoded by std.json to a literal NUL byte, but this fallback writes that byte directly into i18n.zig. Zig source cannot contain a raw NUL, so generation succeeds while the subsequently generated module fails to parse; emit \xNN escapes for control bytes rather than copying them verbatim.
Useful? React with 👍 / 👎.
| var it = std.mem.splitScalar(u8, e.key, '.'); | ||
| while (it.next()) |seg| { | ||
| segs[n] = seg; | ||
| n += 1; |
There was a problem hiding this comment.
Validate locale key depth before filling fixed arrays
The locale parser accepts arbitrarily nested objects, but a key with 17 or more segments writes past segs[16] here before any depth check. Such an otherwise structurally valid locale makes the assembler trap during code generation (and can become unchecked memory corruption in unsafe builds); either allocate the segment/namespace stacks dynamically or reject excessive depth with I18nInvalid.
Useful? React with 👍 / 👎.
| \\/// Startup hook for the LABELLE_LOCALE dev/CI override (RFC-I18N | ||
| \\/// section 8): pass the env var's value, or null. An unknown tag is | ||
| \\/// ignored -- it must not be able to break a player's run if it leaks | ||
| \\/// into a shipped environment. | ||
| \\pub fn initFromEnvValue(v: ?[]const u8) void { | ||
| \\ if (v) |tag| _ = setLocale(tag); | ||
| \\} |
There was a problem hiding this comment.
Invoke the locale environment override during startup
When a developer or CI job sets LABELLE_LOCALE, nothing in the generated roots reads that variable or calls this hook—the only repository occurrence of initFromEnvValue is its declaration here. The promised override therefore has no effect unless every game manually implements environment lookup and invokes this function; wire it into generated startup code or make the module perform the lookup itself.
Useful? React with 👍 / 👎.
| } else if (i + 1 < out.len and out[i + 1] == '*') { | ||
| while (i + 1 < out.len and !(out[i] == '*' and out[i + 1] == '/')) : (i += 1) { | ||
| if (out[i] != '\n') out[i] = ' '; | ||
| } | ||
| if (i + 1 < out.len) { |
There was a problem hiding this comment.
Reject unterminated JSONC block comments
When a /* comment reaches EOF without */ and the file ends in a newline, this loop blanks all comment content and exits because i + 1 == out.len, leaving valid JSON behind. The locale parser then accepts the malformed file instead of reporting the unterminated comment, so an authoring error can pass generation silently; track whether a closing delimiter was found and return a parse failure otherwise.
Useful? React with 👍 / 👎.
| while (iter.next(io) catch null) |entry| { | ||
| if (entry.kind != .file) continue; | ||
| if (!std.mem.endsWith(u8, entry.name, ".jsonc")) continue; | ||
| try tags.append(arena, try arena.dupe(u8, entry.name[0 .. entry.name.len - ".jsonc".len])); | ||
| } |
There was a problem hiding this comment.
Validate locale filenames as BCP-47 tags
Every .jsonc filename stem is accepted as a locale even though this module defines filenames as BCP-47 declarations. A project can therefore generate and ship tags such as en_US, english, or an empty tag, which will not interoperate with callers or LABELLE_LOCALE values using standard language tags; validate each stem before adding it to tags and report invalid locale filenames.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/build_files/build_zig.zig (1)
1151-1157: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winWire generated modules into isolated module roots.
The
constantsandi18nimports on artifact roots do not propagate to promoted or pack modules. Emitconstants_modandi18n_modbefore those modules. Add conditional imports to promoted modules. Passopts.constantstoemitPackModulesand override each pack module'sconstantsimport withconstants_mod.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/build_files/build_zig.zig` around lines 1151 - 1157, The module emission flow around emitPromotedScriptModules and emitPackModules must wire shared modules into isolated roots: emit constants_mod and i18n_mod before promoted and pack modules, add conditional constants_mod/i18n_mod imports to promoted modules, and change emitPackModules to receive opts.constants while overriding each pack module’s constants import with constants_mod.
🧹 Nitpick comments (3)
src/i18n_phase.zig (2)
217-220: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDrop the unused
reference_idxparameter.
emitModulediscardsreference_idxat Line 220. Remove the parameter and the argument at Line 190 instead of keeping the discard.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 217 - 220, Remove the unused reference_idx parameter from emitModule and delete its discard assignment. Update the emitModule call at the referenced call site to stop passing reference_idx, while preserving the remaining arguments and behavior.
445-462: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueAdd a case for an unresolvable
.reference.This test covers
.defaultnaming no file. The parallel branch at Line 109, where.referencenames no locale file, has no test. Add.{ .default = "en", .reference = "de" }and expecterror.I18nInvalid.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 445 - 462, Add a test case in the existing naming-validation test that runs runPhase with .default = "en" and .reference = "de", where no de locale file exists, and assert that it returns error.I18nInvalid. Keep the current default-name and missing-key assertions unchanged.src/i18n_locales.zig (1)
22-29: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winConsider binary search in
Locale.get.
entriesis sorted by key, butgetscans linearly.i18n_phase.emitModulecallsgetonce per (locale, key) pair while building the table, and the coverage loop calls it again per (locale, used key). That makes table emission O(locales × keys²). A large key set makes this noticeable at build time.♻️ Proposed binary search
pub fn get(self: *const Locale, key: []const u8) ?[]const u8 { - // Sorted, so this could bisect; locale files are small enough that - // linear is clearer and the assembler runs this once per build. - for (self.entries) |e| { - if (std.mem.eql(u8, e.key, key)) return e.value; - } - return null; + // Sorted by key, so bisect: the phase calls this once per + // (locale, key) pair when emitting the table. + var lo: usize = 0; + var hi: usize = self.entries.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + switch (std.mem.order(u8, self.entries[mid].key, key)) { + .lt => lo = mid + 1, + .gt => hi = mid, + .eq => return self.entries[mid].value, + } + } + return null; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_locales.zig` around lines 22 - 29, Update Locale.get to use binary search over the sorted entries instead of scanning linearly, comparing each entry key with key and narrowing the search range until a match is found or returning null when absent. Preserve the existing return values and key ordering assumptions.
🤖 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/build_files/build_zig.zig`:
- Around line 169-181: Extend both iOS and Android target-alias guards to
activate when any of opts.constants, opts.i18n, or opts.pack_modules.len > 0 is
enabled, even without other guard conditions. Ensure target is defined before
generated modules such as emitI18nModule use .target = target, and add
regression coverage for each of the six platform/configuration combinations.
In `@src/i18n_phase.zig`:
- Around line 80-85: Update the directory iteration loop around
src_dir.iterate() and iter.next(io) so iteration errors are propagated instead
of converted to null. Preserve normal loop termination when iteration completes
successfully, while allowing I/O failures to return from the enclosing function
via its existing error mechanism.
- Around line 309-321: Bound segment parsing in emitKeyTree before assigning
segs[n], and reject keys exceeding the 16-level capacity with a diagnostic. Add
the corresponding depth validation in i18n_locales.flatten so the locale-file
error includes the offending key path, then add coverage for a 17-level key
while preserving existing behavior for keys within the limit.
- Around line 356-365: Update writeEscaped to escape every remaining control
byte: preserve the existing named escapes for backslash, quote, newline, tab,
and carriage return, and emit \xNN escapes for bytes below 0x20 or equal to 0x7F
instead of writing them directly. Continue passing through other bytes
unchanged.
---
Outside diff comments:
In `@src/build_files/build_zig.zig`:
- Around line 1151-1157: The module emission flow around
emitPromotedScriptModules and emitPackModules must wire shared modules into
isolated roots: emit constants_mod and i18n_mod before promoted and pack
modules, add conditional constants_mod/i18n_mod imports to promoted modules, and
change emitPackModules to receive opts.constants while overriding each pack
module’s constants import with constants_mod.
---
Nitpick comments:
In `@src/i18n_locales.zig`:
- Around line 22-29: Update Locale.get to use binary search over the sorted
entries instead of scanning linearly, comparing each entry key with key and
narrowing the search range until a match is found or returning null when absent.
Preserve the existing return values and key ordering assumptions.
In `@src/i18n_phase.zig`:
- Around line 217-220: Remove the unused reference_idx parameter from emitModule
and delete its discard assignment. Update the emitModule call at the referenced
call site to stop passing reference_idx, while preserving the remaining
arguments and behavior.
- Around line 445-462: Add a test case in the existing naming-validation test
that runs runPhase with .default = "en" and .reference = "de", where no de
locale file exists, and assert that it returns error.I18nInvalid. Keep the
current default-name and missing-key assertions unchanged.
🪄 Autofix
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: 9ba9aec0-3591-4fc3-ab07-3a259975becf
📒 Files selected for processing (5)
src/build_files/build_zig.zigsrc/config.zigsrc/i18n_locales.zigsrc/i18n_phase.zigsrc/root.zig
| fn emitKeyTree(w: *std.Io.Writer, entries: []const locales_mod.Entry, base_depth: usize) !void { | ||
| var stack: [16][]const u8 = undefined; | ||
| var depth: usize = 0; | ||
|
|
||
| for (entries, 0..) |e, idx| { | ||
| // Split the key into segments. | ||
| var segs: [16][]const u8 = undefined; | ||
| var n: usize = 0; | ||
| var it = std.mem.splitScalar(u8, e.key, '.'); | ||
| while (it.next()) |seg| { | ||
| segs[n] = seg; | ||
| n += 1; | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Bound the segment count before writing into the fixed [16] buffers.
segs and stack are fixed at 16 entries, and the split loop writes segs[n] without a bounds check. i18n_locales.flatten places no limit on object nesting, so a locale file with 17 or more nested levels produces a key with 17 segments. That writes past the end of segs. A safe build panics; a release build corrupts the stack.
Reject over-deep keys with a diagnostic. The parser is the better place, because it can name the key path; a guard here is the minimum.
🛡️ Minimum guard in `emitKeyTree`
+const max_key_depth = 16;
+
fn emitKeyTree(w: *std.Io.Writer, entries: []const locales_mod.Entry, base_depth: usize) !void {
- var stack: [16][]const u8 = undefined;
+ var stack: [max_key_depth][]const u8 = undefined;
var depth: usize = 0;
for (entries, 0..) |e, idx| {
// Split the key into segments.
- var segs: [16][]const u8 = undefined;
+ var segs: [max_key_depth][]const u8 = undefined;
var n: usize = 0;
var it = std.mem.splitScalar(u8, e.key, '.');
while (it.next()) |seg| {
+ if (n == max_key_depth) return error.KeyTooDeep;
segs[n] = seg;
n += 1;
}Add the matching check in i18n_locales.flatten so the failure surfaces as a locale-file error that names the key, and add a test for a 17-level key.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| fn emitKeyTree(w: *std.Io.Writer, entries: []const locales_mod.Entry, base_depth: usize) !void { | |
| var stack: [16][]const u8 = undefined; | |
| var depth: usize = 0; | |
| for (entries, 0..) |e, idx| { | |
| // Split the key into segments. | |
| var segs: [16][]const u8 = undefined; | |
| var n: usize = 0; | |
| var it = std.mem.splitScalar(u8, e.key, '.'); | |
| while (it.next()) |seg| { | |
| segs[n] = seg; | |
| n += 1; | |
| } | |
| const max_key_depth = 16; | |
| fn emitKeyTree(w: *std.Io.Writer, entries: []const locales_mod.Entry, base_depth: usize) !void { | |
| var stack: [max_key_depth][]const u8 = undefined; | |
| var depth: usize = 0; | |
| for (entries, 0..) |e, idx| { | |
| // Split the key into segments. | |
| var segs: [max_key_depth][]const u8 = undefined; | |
| var n: usize = 0; | |
| var it = std.mem.splitScalar(u8, e.key, '.'); | |
| while (it.next()) |seg| { | |
| if (n == max_key_depth) return error.KeyTooDeep; | |
| segs[n] = seg; | |
| n += 1; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/i18n_phase.zig` around lines 309 - 321, Bound segment parsing in
emitKeyTree before assigning segs[n], and reject keys exceeding the 16-level
capacity with a diagnostic. Add the corresponding depth validation in
i18n_locales.flatten so the locale-file error includes the offending key path,
then add coverage for a 17-level key while preserving existing behavior for keys
within the limit.
…t walk
RFC-I18N §4, including the part the evaluation added to the RFC after finding
it unspecified: formatting is a per-(key, locale) segment walk, not a comptime
format string. The string is chosen at runtime by the active locale while the
arguments are comptime, and word order is the thing translation changes --
"{count} of {max} items" against "Von {max} Artikeln: {count}". A comptime
format string from the reference renders every reordering locale wrong, and
nothing but this design note forbade it.
i18n_interp.zig parses "{name}" placeholders ({{ and }} escape, matching
std.fmt so nobody learns two escape rules), yielding segment lists and sorted
name sets. Parity (§3 row 4) compares SETS between the reference and each
locale that defines the key: reordering passes, a dropped or added placeholder
is a build error showing both sets. A backfilled slot reuses the reference's
segments, so parity holds there by construction. Brace-syntax errors name the
locale and key and teach the escape.
Codegen emits one segment list per (key, locale), plus two comptime tables
(names and segments, null for plain keys) that drive the guards:
tf(K.hud.stock, .{ .count = 3 }) error: tf: missing argument 'max'
tf(K.hud.stock, .{ .cnt = 3, .max = 10 }) error: tf: missing argument 'count'
t(K.hud.stock) error: this key has placeholders; use tf
-- the RFC's three examples, verified as actual compile errors against a
generated module, alongside the run that matters: the same tf call renders
"3 of 10 items" under en and "Von 10 Artikeln: 3" under de.
Args are anytype with an exhaustive comptime field check both directions,
which is what makes the RFC's per-key Args signature unnecessary: the check
is the contract. Values may be strings, ints, floats, bools or enums; anything
else names its type in a compile error.
The frame arena (Open Question 1) resolves as module-owned: a 16 KiB ring the
result lives in until the buffer wraps, so tf needs NO engine wiring to be
usable today. resetFrameArena() is exported for the frame loop to make the
lifetime exact when the main-template wiring lands. Exhaustion truncates,
never fails -- a cut label beats a crashed game, and t()'s zero-failure path
is untouched.
28 unit tests; suite failure set identical to main's pre-existing 33.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
i18n phase 2 pushed ( The design note the RFC evaluation added — formatting is a segment walk, not a comptime format string — is now load-bearing code, proven on a generated module: i18n.tf(K.hud.stock, .{ .count = 3, .max = 10 })
// en: "3 of 10 items"
// de: "Von 10 Artikeln: 3" ← same call, reordered by the active localeThe RFC §4's three examples verified as actual compile errors: Parity compares placeholder sets (§3 row 4): German's reorder passes; a dropped Open Question 1 resolved pragmatically: the frame arena is module-owned — a 16 KiB ring, results valid until wrap, 28 unit tests here (83 on the branch); suite failure set still identical to main's 33. Remaining per the phasing tables: i18n phase 3 (pack locales, game-over-pack, per-pack |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
src/i18n_interp.zig (1)
136-198: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider adding two edge cases to the parser tests.
The current tests do not cover an empty placeholder
{}or adjacent placeholders{a}{b}. Both exercise branches that are easy to break later: theisIdentifierempty-string guard and the "flush pending literal" branch at Line 53.♻️ Suggested additional test
test "empty and adjacent placeholders" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const a = arena_state.allocator(); switch (try parse(a, "{}")) { .ok => return error.TestUnexpectedResult, .fail => |m| try testing.expect(std.mem.indexOf(u8, m, "identifier") != null), } const segs = try parseOk(a, "{a}{b}"); try testing.expectEqual(`@as`(usize, 2), segs.len); try testing.expectEqualStrings("a", segs[0].arg); try testing.expectEqualStrings("b", segs[1].arg); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_interp.zig` around lines 136 - 198, Add a parser test covering both edge cases: verify parse rejects the empty placeholder "{}" with an error mentioning "identifier", and verify parsing adjacent placeholders "{a}{b}" returns exactly two argument segments in order. Place the coverage alongside the existing parser tests.src/i18n_phase.zig (1)
751-766: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd the inverse parity case: the reference has no placeholders and a locale adds one.
The current test only covers a locale that drops a placeholder. The opposite direction reaches a different branch, because
ref_namesis empty andinterps[ki]would becomenullif the parity check did not fire first at Line 219. A test locks that ordering in.♻️ Suggested additional test
test "phase 2: a locale adding a placeholder the reference lacks is an error" { const io = phaseIo(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.createDirPath(io, "game/locales"); try tmp.dir.createDirPath(io, "target"); try tmp.dir.writeFile(io, .{ .sub_path = "game/locales/en.jsonc", .data = "{ \"hud\": { \"stock\": \"Stock\" } }" }); try tmp.dir.writeFile(io, .{ .sub_path = "game/locales/pt.jsonc", .data = "{ \"hud\": { \"stock\": \"{count} itens\" } }" }); const p = try tmpPaths(&tmp, testing.allocator); defer testing.allocator.free(p.game); defer testing.allocator.free(p.target); try testing.expectError(error.I18nInvalid, runPhase(testing.allocator, p.game, p.target, .{ .default = "en" })); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/i18n_phase.zig` around lines 751 - 766, Add the inverse placeholder-parity test alongside the existing phase 2 test: in the reference locale’s `hud.stock`, use text with no placeholder, and in `pt`, add a placeholder. Keep the same temporary-directory setup, `runPhase` invocation, and `error.I18nInvalid` expectation to verify parity is checked before interpolation handling.
🤖 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/i18n_phase.zig`:
- Around line 457-476: Update the generated appendValue helper’s integer and
float branches to use buffers large enough for the widest supported numeric
values, including i128/u128 and the supported float type. Replace both silent
catch return paths around std.fmt.bufPrint with visible fallback-marker output
so a formatting failure cannot remove the placeholder silently.
- Around line 419-455: Update the generated tf function and appendBytes flow so
each result reserves its required buffer region before walking segments,
truncates content within that reservation instead of wrapping mid-result, and
writes the terminating NUL directly with guaranteed space. Preserve valid
start/end slice bounds and sentinel termination across boundary crossings, then
add a generated-code test that invokes tf enough times to cross the frame buffer
boundary.
---
Nitpick comments:
In `@src/i18n_interp.zig`:
- Around line 136-198: Add a parser test covering both edge cases: verify parse
rejects the empty placeholder "{}" with an error mentioning "identifier", and
verify parsing adjacent placeholders "{a}{b}" returns exactly two argument
segments in order. Place the coverage alongside the existing parser tests.
In `@src/i18n_phase.zig`:
- Around line 751-766: Add the inverse placeholder-parity test alongside the
existing phase 2 test: in the reference locale’s `hud.stock`, use text with no
placeholder, and in `pt`, add a placeholder. Keep the same temporary-directory
setup, `runPhase` invocation, and `error.I18nInvalid` expectation to verify
parity is checked before interpolation handling.
🪄 Autofix
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: a4cad70f-b3cd-4042-af21-654e02ed4d27
📒 Files selected for processing (2)
src/i18n_interp.zigsrc/i18n_phase.zig
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d1c6564563
ℹ️ 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 (frame_len + bytes.len > frame_buf.len) { | ||
| \\ // Wrap: older results are sacrificed for the new one. | ||
| \\ frame_len = 0; |
There was a problem hiding this comment.
Rebase the result slice when the frame buffer wraps
When a tf result crosses the remaining 16 KiB capacity, this resets frame_len to zero after start was captured from the old buffer position. The return at line 430 then uses that stale start, which can form a reversed slice and trap or return unrelated bytes; earlier segments of the same result are also discarded. Wrap or reserve space for the entire result before capturing its start.
Useful? React with 👍 / 👎.
| } | ||
| per_locale[li] = segs; | ||
| } | ||
| interps[ki] = if (ref_names.len == 0) null else .{ .arg_names = ref_names, .segs = per_locale }; |
There was a problem hiding this comment.
Decode escaped braces for non-interpolated keys
When a translation contains escaped braces but no placeholders, such as "Set {{name}}", parsing correctly produces the literal Set {name}, but this discards those decoded segments because the placeholder-name set is empty. The generated API consequently requires t(), which reads the original table string and displays the doubled braces; preserve the decoded text in the emitted table or otherwise retain this escape-only representation.
Useful? React with 👍 / 👎.
| \\ var buf: [24]u8 = undefined; | ||
| \\ appendBytes(std.fmt.bufPrint(&buf, "{d}", .{v}) catch return); |
There was a problem hiding this comment.
Allocate enough space for every supported integer
When a placeholder argument is a u128, i128, or sufficiently large comptime_int, its decimal representation can exceed this 24-byte buffer. bufPrint then returns error.NoSpaceLeft, and catch return silently omits the argument from the translated text even though appendValue advertises integer support; use formatting storage sized for the integer type or explicitly reject unsupported widths.
Useful? React with 👍 / 👎.
…pack reference RFC-I18N §2.1/§2.2, closing the pack story symmetrically with constants. A pack's locales/*.jsonc surfaces prefixed: the pack writes hunger.starving, the world sees K.citizens__hunger.starving. The game overrides a pack's string by writing the prefixed key in its own locale file, and -- the case §2.1 added beyond the original open question -- ADDS locales the pack never shipped: a pack shipping en+fr in a game selling to Brazil gets its pt from the game's own pt.jsonc, no fork. Writing under a pack's namespace asserts the key exists in the pack's key space. "starvng" errors naming pack and key; silently keeping the pack's string is exactly the translations-break-on-upgrade failure the rule prevents. The per-pack reference (§2.2) is the piece that keeps the rectangular-table guarantee honest: .i18n_reference in pack.labelle names the locale the pack's keys are authored in, falling back to the project's. Backfill resolves locale -> pack reference -> project reference by construction: the merged reference slot carries the pack reference's string for every pack key the game did not override, and the existing table backfill does the rest. A declared reference with no file errors -- it defines the pack's key space. A pack locale key its own reference lacks is the rename catch, per realm. The game's tag set stays authoritative: a locale only a pack ships is warned about and skipped, never silently added to the Options menu -- what languages a game offers is a product decision. Implementation shape mirrors constants phase 3: everything folds into one merged key space and merged per-tag locales BEFORE the existing pipeline, so the rename catch, coverage, placeholder parity (against the pack's reference, via the merge), interpolation segments and emission all run unchanged -- nothing downstream knows packs exist. 31 unit tests; suite failure set identical to main's pre-existing 33. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
i18n phase 3 pushed ( The scenario from the original ruling, verified as a test:
The implementation shape is the part worth reviewing: everything folds into one merged key space before the existing pipeline, so the rename catch, coverage, placeholder parity, segment codegen and emission all run unchanged — nothing downstream knows packs exist. Same trick as constants phase 3. 31 i18n tests (86 on the branch); suite failure set still identical to main's 33. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1a4ef0404b
ℹ️ 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".
| try emitConstantsModule(w, opts.constants); | ||
| try emitI18nModule(w, opts.i18n); |
There was a problem hiding this comment.
Add generated imports to promoted script modules
When a FlowNodes-bearing script imports constants or i18n, it is compiled as the isolated module created by emitPromotedScriptModules, whose import table contains neither generated module. Declaring these modules afterward and adding them to game_mod and artifact roots does not make them visible to the promoted module, so an otherwise valid promoted script fails with an unresolved import; wire both generated modules into every promoted script module.
Useful? React with 👍 / 👎.
| try w.writeAll(" .target = target,\n"); | ||
| try w.writeAll(" .optimize = optimize,\n"); | ||
| try w.writeAll(" });\n"); | ||
| try w.writeAll(" overrideImport(game_mod, \"i18n\", i18n_mod);\n"); |
There was a problem hiding this comment.
Expose i18n to isolated pack modules
When a light-pack script accesses the locale keys shipped by that pack through @import("i18n"), this wiring exposes i18n_mod only to game_mod and artifact roots. The isolated modules created by emitPackModules do not inherit either import table and do not list i18n, so pack code using the newly supported pack-locales feature fails to compile; inject i18n_mod into each pack module when i18n is enabled.
Useful? React with 👍 / 👎.
| 'r' => '\r', | ||
| '\\' => '\\', | ||
| '"' => '"', | ||
| else => s[i], // unknown escape: keep the char, drop the slash |
There was a problem hiding this comment.
Decode or reject unsupported YAML escape sequences
When a double-quoted constant uses a valid YAML escape not handled by this switch, the backslash is silently discarded rather than the escape being decoded or rejected. For example, icon: "\u263A" is generated as the literal text u263A instead of ☺, corrupting authored constant data while generation succeeds; implement the remaining accepted escapes or report them as unsupported.
Useful? React with 👍 / 👎.
| const prefixed = try std.fmt.allocPrint(arena, "{s}__{s}", .{ pd.name, pe.key }); | ||
| if (in.game_parsed[gi].get(prefixed) != null) continue; // game wins | ||
| try entries.append(arena, .{ .key = prefixed, .value = pe.value }); |
There was a problem hiding this comment.
Reject colliding composite i18n pack namespaces
When two valid pack/key combinations compose to the same prefixed key—for example pack a with key b__c and pack a__b with key c—both appends produce a__b__c. The merged reference then causes emitKeyTree to generate duplicate K declarations and the generated module fails to compile; detect duplicate composite keys or use an unambiguous canonical pack prefix before appending.
Useful? React with 👍 / 👎.
| const contrib: ?locales_mod.Locale = pd.by_tag[gi] orelse | ||
| (if (gi == in.reference_idx) pd.reference else null); |
There was a problem hiding this comment.
Preserve the pack reference as the fallback source
When a pack declares a reference different from the project reference and also ships the project's reference tag, this expression chooses that tag instead of pd.reference for the merged reference row. For example, with project reference en, pack reference fr, and pack files for both, a missing pt pack string is backfilled from English rather than the pack's declared French reference, contradicting the configured locale → pack-reference fallback chain and displaying the wrong language; keep the pack-reference value as the fallback independently of the translation used for the project's reference locale.
Useful? React with 👍 / 👎.
| var marks = usage.Marks.init(arena); | ||
| try collectMarks(arena, &marks, game_dir); |
There was a problem hiding this comment.
Scan external pack sources for strict i18n coverage
When a cached or otherwise external pack is the only code that reads one of its localized keys, this scan visits only game_dir, so the key remains unmarked. If one of the game's offered locales lacks that pack string, .strict = true therefore succeeds instead of reporting the used untranslated key; include each pack source directory in the usage scan, while avoiding rescanning packs already beneath the game tree.
Useful? React with 👍 / 👎.
| /// its own English, not from a project reference that never contained | ||
| /// them. Absent = the project's reference, which is correct for in-tree | ||
| /// packs authored alongside the game. | ||
| i18n_reference: ?[]const u8 = null, |
There was a problem hiding this comment.
Parse the pack i18n reference from the manifest
When pack.labelle declares .i18n_reference, the ZON parsing shape has no corresponding field and parsing uses ignore_unknown_fields = true, so the declaration is silently discarded and this public field remains null. root.zig consequently always passes the project reference to the i18n phase, causing packs authored in another locale either to require the wrong reference file or to use the wrong fallback; add the field to ZonPackManifest, transfer it into PackManifest, and release it in deinit.
Useful? React with 👍 / 👎.
CodeRabbit and Codex posted 48 comments across the branch; most were right.
The two criticals first:
tf's ring could wrap MID-RESULT. appendBytes reset frame_len to zero when a
chunk did not fit, invalidating the start tf had already captured -- a
reversed slice (panic in safe builds), or bytes of some unrelated earlier
string. The ring now wraps only BETWEEN results, appendBytes truncates and
never moves backwards, and the last byte is reserved so the sentinel always
has a home. Verified by hammering 3000 tf calls through the 16 KiB ring on a
generated module: every result well-formed, every sentinel intact.
A minimal iOS/Android game with constants or i18n emitted a build.zig with an
undefined `target`: the alias guards did not know the new modules exist. They
do now, and pack modules joined the condition for the same reason.
The wiring gap both bots found independently: promoted-script modules and
isolated pack modules never received constants_mod/i18n_mod in their import
tables, so importing "constants" failed to resolve in exactly the sources
the usage scanner covers. The data modules now emit FIRST and both module
kinds import them.
The YAML parser stops corrupting silently in seven ways: '#' only starts a
comment after whitespace (icon#selected is data), unterminated quotes error,
null/Null/NULL/~ are named errors (no null kind exists), a doubled single
quote decodes to an apostrophe, unknown double-quote escapes refuse instead
of dropping the slash (cafe with an accent no longer becomes cafu00e9), 1e3
is a float, and -.5 hits the numeric error rather than becoming a string.
The tab-in-indentation error the first commit intended finally fires -- the
sentinel never set has_tab.
The usage scanner scans twice per file (declarations are order-independent;
use-above-binding counted as nothing before), tolerates whitespace inside
the import call, and widens to all-used when a file cannot be read or an
iteration aborts -- a skipped file could hide uses, and false "unused"
warnings are the forbidden direction. i18n additionally refuses to promote
widened marks to strict errors: when the scan cannot prove usage, failing
builds over keys nothing renders would invert the usage-aware contract.
Generated declarations are quoted identifiers, so a key named fn or type
compiles. Both writeEscaped's emit hex escapes for control bytes -- a locale
string with a JSON-decoded NUL no longer produces a file that cannot parse
(or a corrupted sentinel). Locale filenames must be BCP-47-shaped, with
pt_BR named as the classic typo. Key depth is bounded before the fixed
codegen stacks. Escaped braces in placeholder-less strings decode in the
table, so t() shows "Set {name}" rather than doubled braces.
Structural honesty: namespace collisions (pack a + b__c.yaml vs pack a__b +
c.yaml) are errors naming both files; pack names sanitize through the same
rules as the rest of the pack pipeline (my-pack participates as my_pack); an
override's unused-warning points at the overriding game file, not the
pack's; the pack-inside-game test is a path-boundary check, not a
byte-prefix one (game-pack is not inside game); directory iteration errors
propagate instead of silently truncating the input set; unterminated block
comments in JSONC refuse instead of parsing as valid; diagnostics print
once per generation rather than once per target; and a plugin named
"constants" or "i18n" is refused by name before the generated module
silently replaces it.
95 unit tests across the six files; suite failure set byte-identical to
main's pre-existing 33; examples/null regenerates clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
Review triage — all 48 findings addressed in
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2132749606
ℹ️ 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".
| for (cfg.plugins) |plugin| { | ||
| if (std.mem.eql(u8, plugin.name, "constants") or std.mem.eql(u8, plugin.name, "i18n")) { | ||
| std.log.err("plugin name '{s}' collides with the generated {s} module (RFC in labelle-engine#811); rename the plugin", .{ plugin.name, plugin.name }); | ||
| return error.ReservedPluginName; | ||
| } |
There was a problem hiding this comment.
Gate reserved plugin names on generated modules
When a project uses an existing plugin named constants or i18n but has no corresponding constants/ or locales/ content, this unconditional check now aborts generation even though no generated module exists to collide with the plugin. Apply the reservation only when the relevant phase actually emits a module, preserving the promised no-op behavior for projects that do not enable these features.
Useful? React with 👍 / 👎.
| const prefixed = try std.fmt.allocPrint(arena, "{s}__{s}", .{ pd.name, pe.key }); | ||
| if (in.game_parsed[gi].get(prefixed) != null) continue; // game wins | ||
| try entries.append(arena, .{ .key = prefixed, .value = pe.value }); |
There was a problem hiding this comment.
Sanitize pack names when composing locale keys
When a valid pack name contains punctuation, such as my-pack, this emits my-pack__hunger.starving, but game locale keys must consist of Zig-identifier segments and therefore cannot spell that raw prefix. Using the canonical my_pack__... prefix instead fails the earlier lookup against pd.name, so such packs cannot use the advertised game override/additional-locale flow; compose and match keys with the same pack-name sanitizer used elsewhere.
Useful? React with 👍 / 👎.
| if (text.len >= 2 and text[0] == '"' and text[text.len - 1] == '"') { | ||
| switch (try unescapeDouble(self.arena, text[1 .. text.len - 1], line)) { |
There was a problem hiding this comment.
Reject content after a quoted YAML scalar
When malformed input begins and ends with a quote but contains tokens after an earlier closing quote, such as label: "ready" trailing ", this condition accepts the entire span and emits the value ready" trailing instead of reporting invalid YAML. Validate the quoted scalar while scanning for its actual closing delimiter and reject any non-comment content that follows it.
Useful? React with 👍 / 👎.
| if (!placed) { | ||
| std.debug.print("warning: i18n: pack '{s}' ships locale '{s}', which this game does not offer -- ignored (the game's locales/ decides the language list)\n", .{ pk.name, t }); | ||
| } |
There was a problem hiding this comment.
Gate pack-locale warnings on the diagnostics flag
When a pack ships a locale the game does not offer, the normal generate command reaches this warning once for the game target and again through generateTestsTarget; unlike the other diagnostics in this phase, it ignores the diagnostics = false tests-target setting. The same warning is therefore printed twice for every such pack locale, so pass the diagnostics gate into this merge path before emitting it.
Useful? React with 👍 / 👎.
| fn isForbiddenBool(s: []const u8) bool { | ||
| const forbidden = [_][]const u8{ "no", "No", "NO", "yes", "Yes", "YES", "on", "On", "ON", "off", "Off", "OFF", "True", "TRUE", "False", "FALSE" }; | ||
| for (forbidden) |f| { | ||
| if (std.mem.eql(u8, s, f)) return true; |
There was a problem hiding this comment.
Reject single-letter YAML 1.1 booleans
When a constant is written as bare y, Y, n, or N, YAML 1.1 treats it as a boolean, but this incomplete forbidden-spelling list lets it fall through as a string. That reintroduces the same silent cross-version typing ambiguity this parser explicitly rejects for yes and no; include the single-letter forms so authors must write true/false or quote the intended text.
Useful? React with 👍 / 👎.
The deferred-three-times generated-main wiring turned out to be the wrong plan. The frame loop lives inside the engine and backends, so there is no assembler-side splice point for per-frame work, and threading an env read through every lifecycle style (loop, callback, preview, per platform) would have spread one line of intent across five emitters. Instead the generated module reads LABELLE_LOCALE itself, lazily, on the first lookup -- the alternative the review explicitly offered. Same getenv pattern as labelle-engine's runtime_env.zig: comptime-guarded on builtin.link_libc, so wasm/wasi compile the return-early branch and libc-less test binaries exercise it. An explicit setLocale() before the first lookup wins over the env var -- a live choice outranks a dev knob -- and unknown tags are ignored, so a leaked dev var cannot break a player's run. Verified both ways on a generated module: a libc-less test binary keeps the default and honors setLocale; `LABELLE_LOCALE=pt zig test -lc` answers the first t() in Portuguese. resetFrameArena wiring remains engine work by the same analysis (a frame hook, labelle-engine territory); the ring's between-results wrap keeps tf correct without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
…rror) — unblocks this PR's Examples job
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 58542b313c
ℹ️ 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 (parts.items.len == 0) { | ||
| // Bare root. `const X = C;` re-roots the whole namespace under X. | ||
| if (it.takePendingBinding()) |name| { | ||
| try roots.put(try arena.dupe(u8, name), {}); | ||
| } else { |
There was a problem hiding this comment.
Treat public root aliases as cross-file escapes
When a helper file re-exports the generated root with pub const K = @import("i18n").K; (or the equivalent C alias) and another file accesses keys through that helper, this records only a file-local root and the consuming file has no recognized generated-module import. The used key is therefore never marked: constants can receive a false unused warning, while strict i18n coverage can miss a translation that is used in production. Detect public re-exports or conservatively widen the scan when the root may escape its file.
Useful? React with 👍 / 👎.
| } | ||
| const key_owned = try self.arena.dupe(u8, key); | ||
|
|
||
| const rest = std.mem.trim(u8, ln.content[colon + 1 ..], " "); |
There was a problem hiding this comment.
Trim YAML separation tabs from scalar values
When a valid mapping uses a tab as separation after the colon, such as label:\tready, this trims only spaces and classifies the value as a string beginning with a literal tab. The generated constant consequently becomes "\tready" rather than "ready"; trailing separation tabs are likewise retained. Tabs should remain rejected in indentation, but inline YAML whitespace must be removed around the scalar.
Useful? React with 👍 / 👎.
| \\ for (tags, 0..) |t_, i| { | ||
| \\ if (std.mem.eql(u8, t_, val)) active = i; | ||
| \\ } |
There was a problem hiding this comment.
Compare BCP-47 locale tags case-insensitively
When a locale file is named pt-BR.jsonc but LABELLE_LOCALE is set to the equivalent BCP-47 spelling pt-br, this bytewise comparison ignores the override and leaves the default language active. BCP-47 tags are case-insensitive, so the environment hook and setLocale should canonicalize tags or use an ASCII case-insensitive comparison.
Useful? React with 👍 / 👎.
| if (std.mem.indexOfScalar(u8, text, ':') != null) { | ||
| return failc(self.arena, line, "'{s}' contains YAML syntax characters; quote it if it is meant as a string", .{text}); | ||
| } | ||
| return .{ .ok = .{ .kind = .string, .text = try self.arena.dupe(u8, text), .line = line } }; |
There was a problem hiding this comment.
Reject YAML special floating scalars
When a constant uses a YAML floating spelling such as .inf, -.Inf, or .nan, it matches neither the numeric recognizers nor looksNumeric and falls through as a bare string. This silently changes a YAML numeric scalar into text, contrary to the parser's rule that unsupported implicit typing must be rejected; explicitly refuse these spellings and require quoting when the text itself is intended.
Useful? React with 👍 / 👎.
| fn collectMarks(arena: Allocator, marks: *usage.Marks, dir_path: []const u8) !void { | ||
| const io = phaseIo(); | ||
| const cwd = std.Io.Dir.cwd(); | ||
| var dir = cwd.openDir(io, dir_path, .{ .iterate = true }) catch return; |
There was a problem hiding this comment.
Widen coverage when the source root cannot be opened
When the game source root cannot be opened for iteration, for example because direct access to locales/ is allowed but directory listing of the project root is denied, this returns with an empty used-key set. Strict coverage then treats every key as unused and can silently miss translations required by code; this I/O failure should set marks.all just like iteration and file-read failures do.
Useful? React with 👍 / 👎.
| \\pub fn setLocale(tag: []const u8) bool { | ||
| \\ env_checked = true; | ||
| \\ for (tags, 0..) |t_, i| { | ||
| \\ if (std.mem.eql(u8, t_, tag)) { |
There was a problem hiding this comment.
Preserve the environment fallback after a rejected locale
When application startup probes a persisted locale that is no longer shipped, setLocale returns false but sets env_checked before finding a match. A subsequent first lookup therefore never applies an otherwise valid LABELLE_LOCALE override and remains in the default locale, despite the API promising that a false return changes nothing; mark the environment settled only after a locale is successfully selected.
Useful? React with 👍 / 👎.
All Codex, all real: A pub re-export of the root (pub const K = @import(...).C) is a cross-file escape: another file can use keys through the helper, and a per-file scan cannot see into it. Widen; a private alias still tracks precisely. setLocale marked the env question settled BEFORE checking the tag, so a startup probe of a persisted locale that is no longer shipped returned false ("changes nothing") having eaten the LABELLE_LOCALE fallback. Only a successful match settles it now -- verified live: a rejected setLocale("de") followed by the first t() still applies LABELLE_LOCALE=pt-BR. BCP-47 tags are case-insensitive; setLocale("pt-br") and a lowercased env var now find pt-BR. activeLocale still reports the file's canonical spelling. A tab after the colon is YAML separation, not value content -- "label:\tready" shipped a literal tab in the constant. Tabs stay forbidden in indentation. .inf/.Inf/.INF/.nan spellings (signed included) fell through as bare strings, silently turning a YAML numeric into text. Named errors now, like null. An unopenable source root left the mark set empty, which reads as every-constant-unused (warning spam) or every-key-unused (missed strict coverage). Both collectMarks widen to all-used instead -- the one safe direction. 101 unit tests; behavior fixes verified against generated modules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
Round 2 triage — six for six, all fixed in The standout: Also: 101 unit tests. Round 3 is the next push's reviews; per the standing directive this PR auto-merges after round 3 settles, CI green. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5df3ee5dc6
ℹ️ 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".
| while (it.consumeDot()) { | ||
| const ident = it.nextIdent() orelse break; |
There was a problem hiding this comment.
Parse quoted identifier segments in usage chains
When a generated key contains a Zig keyword, valid source must access it as, for example, K.menu.@"error". Here consumeDot advances past the second dot but nextIdent cannot consume the quoted identifier, so the scanner records only menu and later marks that entire subtree as used. With .i18n.strict = true, an untranslated sibling such as K.menu.help then causes generation to fail even though it is unused; consume quoted identifiers instead of truncating the chain.
Useful? React with 👍 / 👎.
| for (ptags.items, 0..) |t, i| { | ||
| var placed = false; | ||
| for (in.game_tags, 0..) |gt, gi| { | ||
| if (std.mem.eql(u8, t, gt)) { |
There was a problem hiding this comment.
Match pack locale tags case-insensitively
When the game offers pt-BR and an independently authored pack ships the equivalent pt-br.jsonc, this bytewise comparison leaves the pack locale unmapped and reports it as ignored. The merged pt-BR row then backfills the pack's keys from its reference locale, so users receive the wrong language even though BCP-47 tags are case-insensitive; canonicalize tags or use the same case-insensitive comparison as the generated runtime API.
Useful? React with 👍 / 👎.
Two, both fallout from round 2's own changes, both real. The chain walker could not consume @"error"-style quoted identifiers, so K.menu.@"error" truncated at menu and recorded an interior stop -- widening the whole subtree. Under warnings that only hides dead keys, but under .strict it flags untranslated SIBLINGS as used and fails the build over keys nothing renders. nextIdent now reads quoted identifiers, and the pinning test asserts the exact leaf marks without the sibling. mergePacks mapped pack locale files onto the game's tag set bytewise while round 2 made the generated runtime case-insensitive -- an independently authored pack shipping pt-br.jsonc was "ignored" and its keys wrongly backfilled from the pack reference into the game's pt-BR row. The merge now compares tags the same way the runtime does. 103 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
|
Round 3 triage — two findings, both fixed in Both were fallout from round 2's own changes, which is what review rounds are for: the chain walker couldn't consume 103 unit tests. This completes the three review rounds per the standing directive — merging when this push's CI settles green, unless a new major finding lands first. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 67d4d11919
ℹ️ 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".
| fn indexOfTag(tags: []const []const u8, tag: []const u8) ?usize { | ||
| for (tags, 0..) |t, i| { | ||
| if (std.mem.eql(u8, t, tag)) return i; | ||
| } |
There was a problem hiding this comment.
Match configured locale tags case-insensitively
When a locale file is named pt-BR.jsonc but .i18n.default or .i18n.reference uses the equivalent BCP-47 spelling pt-br, this bytewise lookup rejects the configuration before generation. The generated runtime and pack merger already compare tags case-insensitively, so apply the same comparison when resolving configured tags.
Useful? React with 👍 / 👎.
| const stem = entry.name[0 .. entry.name.len - ".jsonc".len]; | ||
| if (!isBcp47ish(stem)) { | ||
| std.debug.print("locales/{s}: '{s}' is not a BCP-47 tag (expected e.g. en, pt-BR). Underscores are the classic typo: pt_BR should be pt-BR\n", .{ entry.name, stem }); | ||
| bad_tag = true; | ||
| continue; | ||
| } | ||
| try tags.append(arena, try arena.dupe(u8, stem)); |
There was a problem hiding this comment.
Reject locale filenames that differ only by case
On case-sensitive filesystems, both en.jsonc and EN.jsonc are appended even though BCP-47 treats them as the same tag. The generated locale selector then exposes duplicate languages, and setLocale("en") selects whichever duplicate appears last rather than necessarily the configured default; reject case-insensitive duplicates while building the tag list.
Useful? React with 👍 / 👎.
| while (i < s.len) : (i += 1) { | ||
| if (s[i] == '\\' and i + 1 < s.len) { |
There was a problem hiding this comment.
Reject a trailing escape in double-quoted YAML
When a scalar ends with a backslash immediately before its apparent closing quote, such as label: "abc\", YAML treats that quote as escaped and the scalar as unterminated. Here the outer check accepts the final quote as the delimiter, while this loop copies the remaining backslash literally because it has no following byte, silently accepting malformed YAML as the value abc\; return an unterminated/invalid-escape error instead.
Useful? React with 👍 / 👎.
| if (roots.contains(tok.text)) { | ||
| try consumeChainOrBinding(marks, &it, arena, roots); |
There was a problem hiding this comment.
Scope discovered root aliases to their declarations
When two functions reuse the same local name, for example one declares const Keys = @import("i18n").K while another declares an unrelated local Keys, the first discovery pass places that name in a file-wide root set. Every Keys.foo in the second function is then treated as an i18n access, so .strict can fail on an untranslated key that production code never reads; track alias scope or conservatively handle rebinding instead of applying every discovered local alias across the whole file.
Useful? React with 👍 / 👎.
…latform#786 friction #1) v0.97.0's constants/i18n wiring (#656) reached game_mod, every artifact root, promoted-script modules and pack modules — but never plugin modules, so a lib like flying-platform's needs_machine (home of RFC-CONSTANTS' headline example, health_drain_rate) could not @import("constants"): a plugin compiles inside its own module, whose import table carries only what the shared injection region overrideImports into it. One new emission, emitLibPluginDataImports, right after the constants_mod/i18n_mod decls in the platform-shared region: every IN-PROJECT `@libs/` plugin module gets overrideImport(plugin_<name>_mod, "constants", constants_mod); overrideImport(plugin_<name>_mod, "i18n", i18n_mod); so all platforms and the tests target resolve a lib's C.*/K.* reads from the same line of code. Scope is deliberate, drawn on the inProjectLibDir line the #82 test-step chaining already draws: external (github:/registry) and out-of-project local: packages are NOT wired. A reusable package cannot depend on one game's generated data, and overrideImport REPLACES an existing entry — unconditional injection would silently clobber a constants/i18n module such a package declares for itself. Byte-identity holds: the helper writes nothing unless (constants or i18n) and a `@libs/` plugin are both present — the committed desktop goldens pass unchanged, and a new test pins that a `@libs/`-bearing project with the flags off contains not a byte of constants/i18n text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk
…latform#786 friction #1) (#662) * feat: plugin modules receive constants/i18n — the libs/ gap (flying-platform#786 friction #1) v0.97.0's constants/i18n wiring (#656) reached game_mod, every artifact root, promoted-script modules and pack modules — but never plugin modules, so a lib like flying-platform's needs_machine (home of RFC-CONSTANTS' headline example, health_drain_rate) could not @import("constants"): a plugin compiles inside its own module, whose import table carries only what the shared injection region overrideImports into it. One new emission, emitLibPluginDataImports, right after the constants_mod/i18n_mod decls in the platform-shared region: every IN-PROJECT `@libs/` plugin module gets overrideImport(plugin_<name>_mod, "constants", constants_mod); overrideImport(plugin_<name>_mod, "i18n", i18n_mod); so all platforms and the tests target resolve a lib's C.*/K.* reads from the same line of code. Scope is deliberate, drawn on the inProjectLibDir line the #82 test-step chaining already draws: external (github:/registry) and out-of-project local: packages are NOT wired. A reusable package cannot depend on one game's generated data, and overrideImport REPLACES an existing entry — unconditional injection would silently clobber a constants/i18n module such a package declares for itself. Byte-identity holds: the helper writes nothing unless (constants or i18n) and a `@libs/` plugin are both present — the committed desktop goldens pass unchanged, and a new test pins that a `@libs/`-bearing project with the flags off contains not a byte of constants/i18n text. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix: inProjectLibDir rejects traversal/degenerate components (#662 review) The `libs/` check was a prefix test on the unnormalized path, so `@libs/../../shared` classified as in-project and collected the privileges keyed off that classification -- test-step chaining (#82) and, since this PR, the generated-data injection -- while normalizing to a directory outside libs/. The classifier now walks components and returns null on `.`/`..`/empty (or a backslash smuggling one), with the valid shapes pinned alongside the escapes. Symlink containment stays out of scope: this is a generate-time nominal classifier over a repo string the project author writes about their own tree, not a trust boundary with filesystem access. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix: carry the in-project-lib classification from the resolver — canonical, not lexical (#662 round 3) CodeRabbit (Major) and Codex (P2) both landed on the same residual: the lexical component walk still classified `libs/foo` as in-project when the entry is a symlink or junction to an out-of-project package, so the generated-data wiring could clobber that package's own `constants`/`i18n` import. The fix is the one the resolver already implies: classification belongs where paths are canonicalized, not re-derived lexically at emission. `cache.isInProjectLib` (src/cache/resolve.zig) realpath-resolves the `@libs/...` path — symlinks and junctions followed — and requires containment under the project's own canonical `libs/` dir on a component boundary. root.zig computes the classified name list (only when a data module exists — no path I/O otherwise) and threads it through the new `BuildZigOptions.lib_plugin_names`; `emitLibPluginDataImports` consumes the carried names and derives nothing itself. `inProjectLibDir` stays lexical BY DESIGN for the #82 test-step chaining it still serves: it derives the literal `../../<path>` cwd of an emitted `zig build test` shell-out, which mutates no import table — and its traversal hardening from round 2 stands. Coverage: resolve.zig tests pin the plain in-project dir (true), `local:` spelling (false), missing dir (false), traversal that canonicalizes outside libs/ (false), the `libs-extra` prefix-boundary trap (false), and the symlink cases both ways — an escape to an external dir classifies external, an in-libs alias stays in-project — skipping only if symlink creation itself is unprivileged on Windows (the containment math is OS-independent and covered by the traversal case). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix: isInProjectLib rejects non-canonical spellings before realpath (#662 round 4) `@libs/foo/../bar` canonicalizes inside libs/ and classified true, while the lexical #82 classifier rejects the same spelling — the two accept sets diverged, granting the data imports to a plugin denied test-step chaining. The same component walk now runs before canonicalization, so a plugin either has full in-project standing or none. False-case tests pin `foo/../bar`, `./bar`, `//bar`, and the backslash twin, with the canonical spelling as the in-place control. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…latform#786 friction #2/#3) (#663) * feat(i18n): keyword-key lint + null-terminated t() variants (flying-platform#786 friction #2/#3) Friction #2 -- Zig-keyword key segments (FP had to rename pause.resume -> pause.resume_game after the fact): - new src/zig_keywords.zig: detection defers to std.zig.Token.getKeyword (the compiler's own table, never drifts) + the @""-quoted call-site path builder. Primitives (bool, u8) deliberately unlinted: K.hud.bool is ordinary field access. - i18n phase: generation-time warning against the reference locale (the key space -- one warning per key however many files carry it) and each pack's reference, naming file, key, offending segment, the exact K.pause.@"resume" call-site spelling and the resume_ rename. Warning, never an error: the @"" path works (usage_scan covers quoted segments since #656 round 3). Diagnostics-gated like the coverage warnings. - constants phase: same lint, same shared helper, over defining yaml files with file:line, plus a keyword filename namespace (error.yaml -> C.@"error"). Override files skipped -- linted where the pack defined the keys. Pure collectors do the finding (unit-testable without capturing stderr); runPhase owns the printing. Friction #3 -- null-terminated access for cimgui: VERDICT, already sound since #656, so no tz/tfz siblings. t() returns [:0]const u8 off the sentinel-typed literal table (decoded-escape strings re-emitted as literals, so they carry the NUL too); tf() returns [:0]const u8 and the ring discipline holds through truncation (appendBytes never consumes the last byte, the sentinel lands in-bounds and frame_len steps past it, wrap only between results). Shipped the regression locks instead: - text-level signature/table asserts beside the emitter - test/i18n_sentinel_tests.zig: generates a real i18n.zig, then COMPILES AND RUNS a harness against it (zig test via the #586 zig_exe seam) -- comptime @typeof asserts + runtime .ptr[len]==0 probes over the static, decoded, keyword-keyed and ring-formatted paths - src/root.zig: i18n_phase goes pub for that suite Plurals (tp/tpf, #660) untouched -- #660 is still open. Projects without locales/ or constants/ stay byte-identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix(i18n): one lint warning per surfaced pack key; quote non-bare segments in diagnostic paths CodeRabbit round 1 (both Minor, both real): - a game override of a keyword-bearing pack key warned twice -- once from the game-side pass, once from the pack-side pass over the same surfaced key. The pack's key space owns the diagnostic now: the game-side pass skips pack-namespaced keys (same raw-name `<pack>__` prefixing mergePacks uses). - quoteDottedPath keys off std.zig.isValidId instead of isKeyword alone: i18n composes surfaced paths from the RAW pack name, so a segment like `my-pack__hunger` needs @"" for shape, not keyword-ness -- the shown call-site path is now valid Zig for those packs too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * Merge main (plurals #660) — keep both i18n_phase test suites The keyword-lint/sentinel tests and the phase-4 plural tests were added at the same insertion points; the zipper resolution keeps main's test region intact and re-appends this branch's four test blocks. Failure set verified against main's baseline: zero new. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk * fix merge: take main's i18n_locales (plural Value union) wholesale The earlier resolution regressed i18n_locales.zig to the pre-#660 shape (this branch never touched the file), which broke the exe compile at the activeTag shape check. Restored main's version verbatim and moved the keyword-lint test fixtures to the union spelling (.value = .{ .str = .. }). Exe builds; test failure set is byte-identical to main's baseline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014pbwN1ytAa8tctZMaTMjAk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Implements phases 1 and 2 of RFC-CONSTANTS (labelle-engine#811, tracking labelle-engine#810).
Phase 1 — parser, codegen, wiring
constants_yaml.zig— the strict-subset parser, hand-written, resolving Open Question 1: the subset is small enough that a parser is less code than a dependency, and it makes §2 enforceable by construction — nothing implements YAML 1.1 implicit typing, so nothing can silently apply it:enabled: nodelay: 12:30,id: 0755&anchor,!!tag,{a: 1},- item,---version: 1.20Source-text preservation is also what makes
5.0emit as comptime_float and5as comptime_int — observable: the probe test does integer division on aC.*int.constants_phase.zig— scan, validate, emit. One namespace per file, errors printfile:line, nothing half-written lands. Noconstants/→ nothing emitted, byte-identical build.zig, staleconstants.zigdeleted.Wiring — the promoted-scripts pattern: one
constantsmodule,overrideImportintogame_mod,addImporton every artifact including the tests target.Phase 2 — usage warnings, sound under aliasing
usage_scan.zigis the shared pass §6 calls for, parameterised by module name and root symbol so i18n'sKreuses it unchanged.The ruling (recorded in both RFCs) implemented with no dataflow analysis at all: a chain that stops at an interior node marks the whole subtree used, so
const cfg = C.decay.hunger;is covered at the binding site andcfgnever needs tracking. Renamed roots and re-rooted aliases are followed; every escape the scanner can't follow — the root passed as a value, a bare module binding,const x = f(C);— widens to all-used. Comments and string literals never count;foo.C.xis somebody else'sC.One direction, held everywhere: the scanner may under-report dead constants; it can never fire on a constant that is actually read. A warning that can be wrong teaches people to ignore the lint, and the lint is the point.
Verified
.labelle-skip)examples/null: direct use + alias-only use → warnings for exactly the two unread constants, nothing elseconstants/Deliberately absent
Phase 3 (pack constants, game-over-pack precedence per RFC §1.1) and phase 4 (FP's 38-constant migration, domain by domain).
Summary by CodeRabbit
New Features
Bug Fixes