Skip to content

packs: enforce cross-pack @import ACL (rule 4) — #491 - #505

Merged
apotema merged 1 commit into
mainfrom
feat/491-typed-acl
Jul 2, 2026
Merged

apotema merged 1 commit into
mainfrom
feat/491-typed-acl

Conversation

@apotema

@apotema apotema commented Jul 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes part of #491. Turns one slice of the pack anti-corruption layer from taste/review into a compiler-adjacent labelle check gate — the slice that is actually enforceable in the assembler without Zig semantic/call-graph analysis.

What #441 already enforces (confirmed, unchanged)

pack_validate.zig (#441, CLOSED) already fails generate on:

  • unknown dependency — a depends_on naming a pack/plugin not declared in project.labelle (nor the implicit contracts).
  • dependency cycle — the depends_on graph must stay acyclic (DFS with a cycle-path diagnostic).

So candidate (b) from the #491 investigation is done — no work needed.

And labelle check (check.zig) already lints three §6 ACL rules: cross-pack registry access (getType("other__X") / view(.{Foreign})), raw .global-facet writes, and event-direction inversions.

What this PR adds — rule 4: cross-pack-import

None of the existing rules caught the escape engine #656 flagged as the real ACL breach: recovering a foreign type by importing its file outright —

const Worker = @import("../../citizens/components/worker.zig").Worker;

This sidesteps both module isolation and the string-keyed registry (rule 1). This PR flags it textually: each relative .zig @import is resolved lexically against the importing file's directory and tested for containment under a foreign pack's canonical on-disk dir. An import into a pack not in the current pack's depends_on (and not contracts) is a violation.

An import of a declared dependency's file stays allowed — packs currently share a copied tree with no module wall (RFC §6 "isolation is future work"), so reaching a declared dep is the sanctioned path; only reaching a non-dependency pack is the reach-around.

What this PR deliberately does NOT do (and why)

  • Full "a pack only CALLS its deps' exposed fns" enforcement. Needs Zig call-graph/semantic analysis the assembler does not do (engine feat: constants phases 1–3 + i18n phase 1 — the shared scan-and-codegen pass, complete #656). Out of reach here — by design.
  • Semantic reach-around routes beyond the literal @import: a re-export chain, a type reached through a value, or an import via a named module. Same call-graph limitation. Rule 4 closes the blatant literal case only, and says so in-code.
  • .exposes-symbol resolution (candidate (a)). .exposes/PackExposes is parsed but has zero consumers and zero real pack.labelle examples in the tree, and packs: scan a light pack's scripts/ dir (deferred from #440/#485) #487 scans pack script filenames only (no pub fn symbol table). A resolution check would therefore encode a currently-undefined contract and risk false positives during partial surface migration (surface split between game-root and pack). Recommend packs: type/enforce the query+command surface from .exposes/.depends_on #491 first defines the exposes→symbol contract (or folds exposes-resolution into the engine-side compile enforcement, where the symbol table actually exists) before this is built.

The enforceable boundary (for #491's record)

Tests

+8 (6 pure scanSource cases incl. allowed-dep, own-file, module-import, disabled-context, and a citizens/citizens2 prefix-boundary case; 2 e2e runLint fixtures exercising real-path resolution). Full clean zig build test = 1149 pass, zig build green.

https://claude.ai/code/session_01P7B7UzgrWEbBYLT3YBrAog

Summary by CodeRabbit

  • New Features

    • Added a new lint check that flags cross-pack file imports when a pack references another pack’s .zig file without an allowed dependency.
    • Expanded end-to-end scanning so pack relationships are considered during linting.
  • Bug Fixes

    • Improved path handling to avoid false positives near similarly named directories.
    • Kept the new check disabled for standalone/unit-test scans with synthetic paths.
  • Tests

    • Added coverage for flagged, allowed, same-pack, non-.zig, and boundary-path cases.

`.exposes`/`.depends_on` were parsed but the ACL was taste-enforced. #441
already gates the DAG (unknown-dep + acyclic); this adds the one enforcement
piece that is tractable in the assembler WITHOUT Zig semantic/call-graph
analysis: a textual `labelle check` rule that flags a pack `@import`ing a
source file inside ANOTHER pack's directory when that pack is not declared in
its `depends_on` (and is not `contracts`).

This closes the blatant, literal form of the direct-TYPE-import reach-around
engine #656 identified as the real ACL escape (recovering a foreign type by
importing its file, sidestepping module isolation AND the string-keyed
registry rule-1 catch). Detection resolves each relative `.zig` `@import`
lexically against the importing file's dir and tests containment against the
canonical foreign pack dirs; an import of a DECLARED dependency's file stays
allowed (packs share a copied tree with no module wall yet, RFC §6).

Deliberately NOT attempted: full "a pack only CALLS exposed fns" enforcement,
and semantic reach-around routes (re-export chains, a type reached through a
value, imports via a named module) — those need call-graph analysis the
assembler does not do (engine #656). `.exposes`-symbol resolution is also left
out: it has zero consumers/examples today and #487 scans script FILENAMES
only (no pub-fn symbol table), so a resolution check would encode an undefined
contract and risk false positives during partial surface migration.

- new Rule.cross_pack_import (+slug) and ForeignPack/Context plumbing in check.zig
- check_cmd builds the per-pack foreign-pack view (allowed = declared dep or contracts)
- 8 tests: 6 pure scanSource cases + 2 e2e runLint fixtures (real-path resolution)

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

Copy link
Copy Markdown

Warning

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

@coderabbitai

coderabbitai Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1cac2014-86bd-4f28-b521-cc7c6d4ae04d

📥 Commits

Reviewing files that changed from the base of the PR and between e16eecc and 53973ff.

📒 Files selected for processing (2)
  • src/check.zig
  • src/check_cmd.zig

📝 Walkthrough

Walkthrough

Adds a new pack-lint rule ("rule 4": cross_pack_import) to check.zig that flags relative .zig @import paths resolving into another pack's directory not declared in depends_on. check_cmd.zig computes per-pack foreign_packs allow/deny data and wires it into the lint Context, with unit and fixture tests added for both.

Changes

Cross-pack import lint rule

Layer / File(s) Summary
Rule contract and Context extension
src/check.zig
Adds documentation, cross_pack_import enum variant and slug, ForeignPack struct, and Context fields (current_pack_dir, foreign_packs) to enable the new rule.
Import detection and boundary checking
src/check.zig
Detects @import("...") string literals in scanSource, adds pathUnder and checkCrossPackImport to resolve relative import paths and flag those crossing into disallowed foreign pack directories; adds unit tests for flagged, allowed, own-file, non-.zig, disabled-rule, and sibling-prefix boundary cases.
Per-pack wiring and fixture tests
src/check_cmd.zig
Adds foreignPacksFor and containsStr helpers to build allow/deny lists from depends_on, wires foreign_packs into runLint's Context, updates the existing fixture switch, and adds end-to-end tests for flagged and clean cross-pack import scenarios.

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

Possibly related issues

Possibly related PRs

Poem

A rabbit peeked past pack-dir fence,
"That import's not yours!" — makes sense!
With paths resolved and boundaries drawn,
foreign burrows checked at dawn. 🐇
Depends_on lists now hold the key,
hop clean across the repo, free!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: enforcing cross-pack @import ACL rule 4 for packs.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/491-typed-acl

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

@apotema

apotema commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53973ff91b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/check.zig
if (ctx.current_pack_dir.len == 0) return; // rule disabled (unit-test path)
if (ctx.foreign_packs.len == 0) return; // nothing foreign to reach

const path = decodeStringLiteral(src[path_tok.start..path_tok.end]) orelse return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Decode escaped @import paths before ACL checks

When a pack uses a valid Zig string escape in the import path, for example @import("..\x2f..\x2fproduction/events/item_produced.zig"), decodeStringLiteral returns null and rule 4 skips the import, even though Zig still resolves it to the foreign file. The existing registry-name scanner could ignore escapes because those names are plain identifiers, but file paths need real string decoding before they are resolved or the new cross-pack import ACL is easy to bypass.

Useful? React with 👍 / 👎.

Comment thread src/check.zig
Comment on lines +504 to +505
const dir = std.fs.path.dirname(file) orelse ".";
const resolved = std.fs.path.resolve(arena, &.{ dir, path }) catch return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve imports in the copied pack layout

For cached or otherwise non-sibling pack sources, this resolves the string against the original source file, but scanPack later copies every pack to target/packs/<pack_name> and Zig resolves these @imports from that copied layout. An undeclared import like @import("../../production/events/item_produced.zig") in a cached citizens pack will compile after generation as target/packs/production/..., yet here it resolves under the cached citizens source tree and never matches production's source dir, so rule 4 misses the cross-pack import for distributed packs.

Useful? React with 👍 / 👎.

Comment thread src/check.zig
Comment on lines +508 to +512
if (pathUnder(resolved, ctx.current_pack_dir)) return;

for (ctx.foreign_packs) |fp| {
if (!pathUnder(resolved, fp.dir)) continue;
if (fp.allowed) return; // declared dependency (or contracts) — permitted

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match the innermost pack before allowing imports

When pack directories overlap, these early returns can classify an import into a nested foreign pack as safe because it is also under the current pack (or under an allowed ancestor pack) before the more specific foreign pack is checked. For example, if @packs/world and @packs/world/production are both declared packs, a world source can import production/... without depends_on and line 508 treats it as an own-pack path, bypassing rule 4.

Useful? React with 👍 / 👎.

Comment thread src/check.zig
// walker produced, so the result shares a canonical base with the foreign
// pack dirs (both come from the same `resolvePlugin` realpath pass).
const dir = std.fs.path.dirname(file) orelse ".";
const resolved = std.fs.path.resolve(arena, &.{ dir, path }) catch return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Canonicalize resolved imports before containment checks

This compares the lexically resolved import path to canonical pack dirs without calling realpath on the imported file. On case-insensitive filesystems an import like @import("../../Production/events/item_produced.zig") still opens production/..., and symlinked directories have the same problem, but the string prefix test will not match the foreign pack dir (or may return as current-pack), so rule 4 misses a real cross-pack import.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant