From f9e67a7e0a5d021d536556d98f449b9c7025b3bd Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Tue, 20 Jan 2026 11:34:46 -0800 Subject: [PATCH 1/7] Add draft of static require RFC --- rfcs/static-require-bundle.md | 219 ++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 rfcs/static-require-bundle.md diff --git a/rfcs/static-require-bundle.md b/rfcs/static-require-bundle.md new file mode 100644 index 00000000..2e8a65b3 --- /dev/null +++ b/rfcs/static-require-bundle.md @@ -0,0 +1,219 @@ +# RFC: Static require() for ServerLua + +* Status: **DRAFT** + +## Summary + +Compile-time static `require()` resolution that bundles multiple modules into a single deployable unit. Client-side bundling +avoids the kind of security issues (see MySQL's `LOAD LOCAL`) where the server has to tell the client what local resources are needed +to continue bundling, and the client can't reason about what resources are needed on its own. Preserves original filenames and line numbers +for debugging via per-Proto source info in bytecode. + +## Motivation + +**Problem:** No code reuse beyond copy-paste in-viewer. Large scripts are unwieldy. LSL's `#include` loses dependency info after processing, + if you don't have the local dependencies you have no option but to edit the post-processed source code soup. + +**Why static `require()`:** SL's creation and permission models don't play nice with dynamic runtime dependencies. Bundling together dependencies when + building makes it easy for us to support multiple development styles (external editor with custom build toolchain, in-viewer editor have different opinions about + how to include files and from where) + +**Why it's tricky:** Server compiles, but dependencies live on client. Can't reasonably have server request files (i.e. `LOAD LOCAL` from MySQL). +Need debug info across modules. Want future tree shaking/inlining for optimization purposes. + +## Path Resolution + +All paths are explicit - no relative paths (`./`, `../`). + +| Pattern | Resolves to | +|---------|---------------------------------------------------------------------------------------| +| `require("foo")` | `package.path`-like semantics. Includes from the top of the package, or from libs dir | +| `require("@myalias/utils")` | User-configured alias → local path | +| `require("@sl/json")` | Platform libs (reserved namespace for LL-provided native Lua libs) | + +**Client resolves aliases before bundling.** Bundle stores alias paths; runtime does simple string lookup when actually + calling `require()` + +## Bundle Format + +Text-based, valid Luau syntax, lightly inspired by MIME multipart RFC: + +```lua +--[[!!SLUA:BUNDLE!!]] +-- NOTE: May have some metadata in the header too +-- MAIN is implicit (first section after header) +local foo = require("@myproject/lib/foo") +local json = require("@sl/json") +foo.bar() + +--[[!!SLUA:MODULE:@myproject/lib/foo!!]] +local helpers = require("@myproject/lib/helpers") +return { bar = function() return helpers.helper() end } + +--[[!!SLUA:MODULE:@myproject/lib/helpers!!]] +return { helper = function() return "hello" end } +``` + +**Rules:** +- `--[[!!SLUA:BUNDLE!!]]` header must be first line +- MAIN is implicit (content between header and first MODULE) +- `--[[!!SLUA:MODULE:path!!]]` marks each dependency +- `--[[!!SLUA:` in user source is rejected +- Platform libs (`@sl/...`) not included - provided by runtime +- Generally the user only sees and directly edits the `MAIN` bit of the bundle +- Users may define their own global aliases that refer to particular libs on their disk +- - For ex. you might `require("@textlib/v2")` to pull in v2 of your text rendering library + +Conceptually, the bundle provides a sort of virtual filesystem for the runtime `require()` implementation. + +Notably, since the bundle format includes the source code as-is, before any optimization and tree-shaking, file name +and line mappings for errors are automatically correct, without `--!@line` directives or similar. + +## Editing Workflow + +```mermaid +flowchart TD + subgraph Start + direction LR + OpenExisting[Open existing script] + CreateNew[Create new script] + end + + DownloadArchive[Download source archive] + IsValidBundle{Valid bundle?} + MainView[MAIN-only view] + RawView[Raw view] + UserEdits[User edits] + + CreateNew --> MainView + OpenExisting --> DownloadArchive + DownloadArchive --> IsValidBundle + IsValidBundle -->|Yes| MainView + IsValidBundle -->|No| RawView + MainView --> UserEdits + RawView --> UserEdits + + UserEdits -->|Toggle view| CheckUnsaved + UserEdits -->|Save| StartSave + + subgraph Toggle[Toggle View] + CheckUnsaved{Unsaved changes?} + TogglePrompt{Discard or cancel?} + CheckToggleDir{Raw to MAIN?} + ValidBundle{Valid bundle?} + DoToggle[Toggle view] + ShowParseError[Show parse error] + + CheckUnsaved -->|No| CheckToggleDir + CheckUnsaved -->|Yes| TogglePrompt + TogglePrompt -->|Discard| CheckToggleDir + CheckToggleDir -->|No| DoToggle + CheckToggleDir -->|Yes| ValidBundle + ValidBundle -->|Yes| DoToggle + ValidBundle -->|No| ShowParseError + end + + subgraph Save + StartSave[User saves] + IsRawView{Viewing raw?} + SendRaw[Send as-is] + HasLocalDep{Local dep exists?} + CheckOwnership{Same user last saved?} + BundleLocal[Bundle in local dep] + CheckMatch{Local matches bundle?} + ConfirmUntrusted{Confirm: pull local dep\ninto untrusted script?} + DepInBundle{Dep in existing bundle?} + BundleFromBundle[Bundle from existing] + ResolveError[Error: can't resolve] + SendBundle[Send bundle] + AbortSave[Abort] + + StartSave --> IsRawView + IsRawView -->|Yes| SendRaw + IsRawView -->|No| HasLocalDep + HasLocalDep -->|Yes| CheckOwnership + CheckOwnership -->|Yes| BundleLocal + CheckOwnership -->|No| CheckMatch + CheckMatch -->|Yes| BundleLocal + CheckMatch -->|No| ConfirmUntrusted + ConfirmUntrusted -->|Yes| BundleLocal + ConfirmUntrusted -->|No| AbortSave + HasLocalDep -->|No| DepInBundle + DepInBundle -->|Yes| BundleFromBundle + DepInBundle -->|No| ResolveError + ResolveError --> AbortSave + BundleLocal --> SendBundle + BundleFromBundle --> SendBundle + end + + TogglePrompt -->|Cancel| UserEdits + ShowParseError --> UserEdits + DoToggle --> UserEdits + AbortSave --> UserEdits + + SendRaw --> Compile + SendBundle --> Compile + + subgraph Server + Compile[Compile & store] + Done[Return result] + Compile --> Done + end +``` + +**Key points:** +- MAIN-only view: user sees entry script, client bundles on save +- Raw view: user sees/edits full archive, sent as-is +- Ownership check: security measure to prevent leaking local code into scripts others control +- - Open question as to how this should work, we'll need to do some server-side enrichment +- Fallback: if local dep missing, use version from downloaded bundle +- - This allows users to do one-off edits to scripts from the viewer even if they don't have all the + constituent parts on their drives. + +## Runtime + +- `require()` implemented in C (hides module table/cache from scripts) +- Module results cached after first execution +- Each module runs in sandboxed environment via `dangerouslyexecuterequiredmodule()` +- - This function will be hidden from view and not directly usable. +- Simple string lookup - no alias resolution at runtime + +## Bytecode Extension + +**Problem:** Standard Luau shares one `chunkname` across all functions. Bundles have multiple + source files. It's useful to be able to have proper filename and line mappings in errors. + +**Solution:** Per-Proto source in bytecode. Each function stores its source filename. Loader reads per-function source for correct stack traces. + +```cpp +// In lvmload.cpp +TString* protoSource = hasPerProtoSource + ? strings[readVarInt(data, size, offset)] + : source; // Fallback for non-bundles +p->source = protoSource; +``` + +## Errors + +| Error | When | Message | +|-------|------|---------| +| Dynamic require | Compile | `require() argument must be a string literal` | +| Relative path | Compile | `relative paths not supported` | +| Unknown alias | Compile | `unknown alias '@foo' in require` | +| Module not found | Compile | `cannot find module ''` | +| Circular dependency | Compile | `circular dependency: -> -> ` | +| Delimiter in source | Compile | `source cannot contain '--[[!!SLUA:'` | +| Depth exceeded | Compile | `require depth exceeds maximum` | +| Module not in bundle | Runtime | `module not found: ` | + +## Limits + +- Max dependency depth: 100 +- Max total modules: 1000 +- No circular dependencies (no different from typical Luau here!) + +## Future Work + +- Tree shaking (eliminate unused exports) +- Cross-module inlining (`--!pure` modules) +- Inventory-based module resolution From f6784eaa822476deecb3e954732a15aa1206cbc0 Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Thu, 7 May 2026 16:13:15 -0700 Subject: [PATCH 2/7] Add bundler MVP, update RFC to match bundler --- .github/workflows/slua_bundle.yml | 51 ++ .gitignore | 7 + rfcs/static-require-bundle.md | 306 +++++++++--- tools/slua_bundle/README.md | 78 +++ tools/slua_bundle/pyproject.toml | 64 +++ tools/slua_bundle/slua_bundle/__init__.py | 63 +++ tools/slua_bundle/slua_bundle/__main__.py | 5 + tools/slua_bundle/slua_bundle/bundler.py | 217 +++++++++ tools/slua_bundle/slua_bundle/canonicalize.py | 97 ++++ tools/slua_bundle/slua_bundle/cli.py | 111 +++++ tools/slua_bundle/slua_bundle/errors.py | 12 + tools/slua_bundle/slua_bundle/extractor.py | 135 ++++++ tools/slua_bundle/slua_bundle/fs.py | 173 +++++++ tools/slua_bundle/slua_bundle/luaurc.py | 58 +++ tools/slua_bundle/slua_bundle/py.typed | 0 tools/slua_bundle/slua_bundle/resolver.py | 124 +++++ tools/slua_bundle/slua_bundle/runtime.py | 222 +++++++++ tools/slua_bundle/tests/__init__.py | 0 tools/slua_bundle/tests/_helpers.py | 10 + tools/slua_bundle/tests/test_bundler.py | 447 ++++++++++++++++++ tools/slua_bundle/tests/test_canonicalize.py | 191 ++++++++ tools/slua_bundle/tests/test_disk_fs.py | 56 +++ tools/slua_bundle/tests/test_e2e.py | 103 ++++ tools/slua_bundle/tests/test_extractor.py | 201 ++++++++ tools/slua_bundle/tests/test_memory_fs.py | 49 ++ tools/slua_bundle/tests/test_resolver.py | 106 +++++ tools/slua_bundle/tests/test_runtime.py | 227 +++++++++ 27 files changed, 3060 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/slua_bundle.yml create mode 100644 tools/slua_bundle/README.md create mode 100644 tools/slua_bundle/pyproject.toml create mode 100644 tools/slua_bundle/slua_bundle/__init__.py create mode 100644 tools/slua_bundle/slua_bundle/__main__.py create mode 100644 tools/slua_bundle/slua_bundle/bundler.py create mode 100644 tools/slua_bundle/slua_bundle/canonicalize.py create mode 100644 tools/slua_bundle/slua_bundle/cli.py create mode 100644 tools/slua_bundle/slua_bundle/errors.py create mode 100644 tools/slua_bundle/slua_bundle/extractor.py create mode 100644 tools/slua_bundle/slua_bundle/fs.py create mode 100644 tools/slua_bundle/slua_bundle/luaurc.py create mode 100644 tools/slua_bundle/slua_bundle/py.typed create mode 100644 tools/slua_bundle/slua_bundle/resolver.py create mode 100644 tools/slua_bundle/slua_bundle/runtime.py create mode 100644 tools/slua_bundle/tests/__init__.py create mode 100644 tools/slua_bundle/tests/_helpers.py create mode 100644 tools/slua_bundle/tests/test_bundler.py create mode 100644 tools/slua_bundle/tests/test_canonicalize.py create mode 100644 tools/slua_bundle/tests/test_disk_fs.py create mode 100644 tools/slua_bundle/tests/test_e2e.py create mode 100644 tools/slua_bundle/tests/test_extractor.py create mode 100644 tools/slua_bundle/tests/test_memory_fs.py create mode 100644 tools/slua_bundle/tests/test_resolver.py create mode 100644 tools/slua_bundle/tests/test_runtime.py diff --git a/.github/workflows/slua_bundle.yml b/.github/workflows/slua_bundle.yml new file mode 100644 index 00000000..6398f9d2 --- /dev/null +++ b/.github/workflows/slua_bundle.yml @@ -0,0 +1,51 @@ +name: slua_bundle + +on: + push: + branches: [main, develop] + paths: + - 'tools/slua_bundle/**' + - '.github/workflows/slua_bundle.yml' + pull_request: + paths: + - 'tools/slua_bundle/**' + - '.github/workflows/slua_bundle.yml' + +defaults: + run: + working-directory: tools/slua_bundle + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - run: ruff check . + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install --upgrade pip + - run: pip install . --group dev + - run: mypy slua_bundle + + test: + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ["3.7", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - run: pip install . pytest + - run: pytest diff --git a/.gitignore b/.gitignore index cba1cf26..a1e33005 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,10 @@ __pycache__ !/fuzz/corpus/json/*.json *.code-workspace *.tar.bz2 + +# Python tooling +*.egg-info/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.swp diff --git a/rfcs/static-require-bundle.md b/rfcs/static-require-bundle.md index 2e8a65b3..4d7b1bf3 100644 --- a/rfcs/static-require-bundle.md +++ b/rfcs/static-require-bundle.md @@ -1,6 +1,7 @@ # RFC: Static require() for ServerLua * Status: **DRAFT** +* Reference implementation: [`tools/slua_bundle/`](../tools/slua_bundle/) ## Summary @@ -9,6 +10,10 @@ avoids the kind of security issues (see MySQL's `LOAD LOCAL`) where the server h to continue bundling, and the client can't reason about what resources are needed on its own. Preserves original filenames and line numbers for debugging via per-Proto source info in bytecode. +SLua intentionally reuses upstream Luau ecosystem conventions where they exist - `.luaurc` for aliases, `init.luau` for module-as-directory entries, +Wally-style project layout - so existing Roblox/Luau tooling works without modification. SLua adds the bundle format and per-Proto source bytecode +on top; configuration is not a SLua extension surface. + ## Motivation **Problem:** No code reuse beyond copy-paste in-viewer. Large scripts are unwieldy. LSL's `#include` loses dependency info after processing, @@ -23,51 +28,230 @@ Need debug info across modules. Want future tree shaking/inlining for optimizati ## Path Resolution -All paths are explicit - no relative paths (`./`, `../`). +**Aliases are pure identity, not location.** A `require("@alias/path")` call is a logical name for a module. The path +never encodes where the module is fetched from or what kind of resolver produced it. Two scripts that share an alias +path are referring to the same logical module regardless of how each environment resolved it. + +| Pattern | Meaning | +|---------|-----------------------------------------------------------------------------------------------| +| `require("@root/utils")` | The built-in `@root/` alias always points to the project root. See [Built-in aliases](#built-in-aliases). | +| `require("@myalias/utils")` | User-declared alias from `.luaurc`. Resolved by the bundler; canonical bundle key is the alias path itself. | +| `require("./foo")`, `require("../foo")`, `require("@self/foo")` | Location-dependent. Canonicalized to absolute alias form at bundle time; see [Canonicalization](#canonicalization). | + +Bare identifiers (`require("foo")`) are not valid - upstream Luau already rejects anything without a `./`, `../`, or +`@` prefix. The `@sl/` namespace is reserved for future use. + +**Bundlers resolve aliases at bundle time.** The bundle stores each module under its canonical absolute alias key. +Runtime `require()` is a simple string lookup against the bundle's module table - no alias resolution at runtime. + +### Built-in aliases + +- `@root/` — always points to the project root. Lets scripts reference project-internal modules without depending on + the project's PROJECT name. Reserved: `.luaurc` may not declare an alias named `root`. +- `@self/` — upstream Luau convention: "current module's directory" (relative to the requiring file). Treated by the + resolver, not by `.luaurc`. Reserved — `.luaurc` may not declare an alias named `self`. + +Project-internal canonical keys always have the form `@root/...`, regardless of the bundle's PROJECT name. PROJECT is +viewer-linkage metadata only and never appears in canonical keys. + +### Canonicalization + +Every module is keyed in the bundle under an absolute alias path. The bundler rewrites the require's string constant +in emitted bytecode so runtime lookup matches that key directly. + +```mermaid +flowchart TD + Start[require S from module R] + Start --> Kind{S starts with} + Kind -->|@alias/...| Direct[Canonical key = S] + Kind -->|./, ../, @self/| Resolve[Resolve against R's location -> physical path L] + Resolve --> FindAlias{Most-specific covering alias for L} + FindAlias -->|Found| Build["Canonical key = @alias/{L relative to target}"] + FindAlias -->|None| NoAliasErr["Error: no alias spans L (shouldn't happen: @root always covers project_root)"] + Direct --> Done[Stored in bundle module table] + Build --> Done +``` + +The covering-alias set always includes `@root` for `project_root`, so files under `project_root` always canonicalize +cleanly. Files outside `project_root` need an explicit `.luaurc` alias spanning them. + +MAIN is canonicalized identically: the `MAIN` directive's value acts as MAIN's anchor key, exactly +as the MODULE marker acts for module bodies. MAIN is always present in well-formed bundles. -| Pattern | Resolves to | -|---------|---------------------------------------------------------------------------------------| -| `require("foo")` | `package.path`-like semantics. Includes from the top of the package, or from libs dir | -| `require("@myalias/utils")` | User-configured alias → local path | -| `require("@sl/json")` | Platform libs (reserved namespace for LL-provided native Lua libs) | +**Tiebreaker for identical-target aliases.** Specificity is by number of path components in the alias target (deeper +wins). When two aliases point at the exactly-same directory at the deepest covering tier: +- If `@root` is one of them, `@root` wins silently. Common case: a user declared a Wally-style alias for + `project_root` in `.luaurc` not realizing `@root` already covers it. +- Otherwise, the alphabetically-first alias name wins (ASCII byte order), and the bundler emits a warning. This is a + config smell — usually a copy-paste mistake in `.luaurc`. Alias names are assumed ASCII (Luau / Wally convention); + non-ASCII alias names are unspecified. -**Client resolves aliases before bundling.** Bundle stores alias paths; runtime does simple string lookup when actually - calling `require()` +**Worked example.** Inside `Packages/SomeLib/init.luau` with `.luaurc` defining `"SomeLib": "Packages/SomeLib"`: + +```lua +local Module = require("./src/Module") +``` + +resolves physically to `Packages/SomeLib/src/Module.luau`. The most-specific covering alias is `@SomeLib`, so the +bundle key becomes `@SomeLib/src/Module` - identical to what `require("@SomeLib/src/Module")` would produce. Wally +packages canonicalize this way without any SLua-specific handling. + +Same source tree + same `.luaurc` produces identical canonical keys regardless of which bundler ran; `.luaurc` ships +alongside the source. Resolvers returning virtual source (e.g. inventory) must declare an alias prefix for what they +return, or reject location-dependent requires inside it - a resolver concern, not the format's. ## Bundle Format -Text-based, valid Luau syntax, lightly inspired by MIME multipart RFC: +Text-based, valid Luau syntax, lightly inspired by MIME multipart RFC. The format is **producer-agnostic**: the viewer +and any external bundler emit the same artifact, and the server cannot tell which built it. ```lua ---[[!!SLUA:BUNDLE!!]] --- NOTE: May have some metadata in the header too --- MAIN is implicit (first section after header) -local foo = require("@myproject/lib/foo") -local json = require("@sl/json") +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/HudController +-- !!LUABUNDLE:BODY +local foo = require("@root/lib/foo") foo.bar() - ---[[!!SLUA:MODULE:@myproject/lib/foo!!]] -local helpers = require("@myproject/lib/helpers") +-- !!LUABUNDLE:MODULE @root/lib/foo +local helpers = require("./helpers") return { bar = function() return helpers.helper() end } - ---[[!!SLUA:MODULE:@myproject/lib/helpers!!]] +-- !!LUABUNDLE:MODULE @root/lib/helpers return { helper = function() return "hello" end } ``` -**Rules:** -- `--[[!!SLUA:BUNDLE!!]]` header must be first line -- MAIN is implicit (content between header and first MODULE) -- `--[[!!SLUA:MODULE:path!!]]` marks each dependency -- `--[[!!SLUA:` in user source is rejected -- Platform libs (`@sl/...`) not included - provided by runtime -- Generally the user only sees and directly edits the `MAIN` bit of the bundle -- Users may define their own global aliases that refer to particular libs on their disk -- - For ex. you might `require("@textlib/v2")` to pull in v2 of your text rendering library +**Directives** (each on its own line, line-comment form): -Conceptually, the bundle provides a sort of virtual filesystem for the runtime `require()` implementation. +| Directive | Required | Purpose | +|-----------|----------|---------| +| `-- !!LUABUNDLE:VERSION ` | yes, first line | Bundle format version. Consumers MUST reject unknown versions. Future version bumps are non-back-compatible unless explicitly stated. | +| `-- !!LUABUNDLE:PROJECT ` | no | Advisory viewer-linkage metadata; associates the bundle with a disk project. Not used for canonicalization. See [Project Linkage](#project-linkage). | +| `-- !!LUABUNDLE:MAIN ` | yes | Canonical key for the MAIN section (e.g. `@root/HudController`). Anchors `./`, `../`, and `@self/` requires inside MAIN. Always emitted by the reference bundler; computed from MAIN's path under `project_root`. | +| `-- !!LUABUNDLE:BODY` | yes | Separates the header from MAIN's source body. Everything between BODY and the first MODULE marker (or EOF) is MAIN. | +| `-- !!LUABUNDLE:MODULE ` | per module | Marks each dependency. The canonical key identifies the module for runtime `require()` lookup. | -Notably, since the bundle format includes the source code as-is, before any optimization and tree-shaking, file name -and line mappings for errors are automatically correct, without `--!@line` directives or similar. +**Other rules:** +- Lines matching `^-- *!!LUABUNDLE:` in user source are rejected at bundle time. Users may not author lines that look like bundle directives. +- Generally the user only sees and directly edits the `MAIN` body of the bundle. +- Users may define their own aliases (via `.luaurc`) referring to libs on disk; see [Configuration & Project Layout](#configuration--project-layout). + +The bundle conceptually provides a virtual filesystem for the runtime `require()` implementation. Because the format +includes source code as-is - before any optimization or tree-shaking - filenames and line mappings for errors are +automatically correct without `--!@line` directives. + +## Configuration & Project Layout + +SLua reuses Luau ecosystem conventions for project configuration. **No SLua-specific config file is introduced.** + +### Alias config: `.luaurc` + +`.luaurc` is the standard Luau alias config (JSON, Wally-compatible). SLua bundlers read it the same way `lune`, the +`luau` CLI, and Roblox Studio do: + +```json +{ + "languageMode": "strict", + "aliases": { + "Pkg": "Packages" + } +} +``` + +Project-internal modules don't need a user alias — the built-in `@root/` covers `project_root`. `.luaurc` is for +declaring external aliases (Wally packages, vendored libs, virtual-source resolvers, etc.). The alias name `root` is +reserved. + +**SLua bundlers read only the `.luaurc` at `project_root`.** Nested `.luaurc` files deeper in the tree are ignored; +files above `project_root` are never consulted. This deviates from upstream Luau's walking behavior on purpose: +bundle reproducibility requires the alias set to be a function of the project tree alone. Walking up past +`project_root` would let aliases bleed in from whatever directory the developer happened to bundle from, breaking +identical-source-tree-gives-identical-canonical-keys. + +### Recommended directory layout + +Mirror Rojo/Wally idiom: + +``` +myhud/ # project_root (any name; not load-bearing) + .luaurc # aliases for external deps (Wally-friendly, Roblox-compatible) + wally.toml # optional, Wally manifest + Packages/ # Wally-vendored deps (e.g. @SomeLib) + src/ + HudController.luau # top of src/ -> SL script "HudController" (canonical: @root/src/HudController) + DataLink.luau # top of src/ -> SL script "DataLink" (canonical: @root/src/DataLink) + lib/ + utils.luau # nested -> helper, @root/src/lib/utils + shared.luau # nested -> helper, @root/src/lib/shared +``` + +**Convention:** files at the **immediate root of `src/`** become SL scripts (one each, named after the disk filename). +Files in **any subdirectory** are project-local helpers, never deployed as standalone scripts. A CLI deploy tool +(`slua deploy ` or similar) reads the top of `src/`, bundles each top-level file with its required modules, +and uploads one SL script per file. No manifest is needed; the directory shape is the manifest. + +This dissolves the question of what "the main file" is named at the script level: the SL script name *is* the disk +filename. There is no `main.luau` magic. + +### Module-as-directory entry: `init.luau` + +When an alias resolves to a directory (e.g., `require("@coollib")` where `coollib` is a directory), the entry file is +`init.luau`. **This is upstream Luau's convention** (see `Require/include/Luau/Require.h:33-34`), adopted unchanged so +SLua require behaves identically to Lute, Roblox, and the `luau` CLI for directory aliases. `init.luau` has no special +meaning at the SL-script level - only for module-as-directory. + +### Wally compatibility + +SLua projects are normal Luau projects. `wally install` populates `Packages/`, `.luaurc` aliases (`@Pkg/...`) point at +it, the bundler reads disk normally. No SLua-side configuration is needed beyond what any other Luau tool already +supports. + +## Resolver Behaviour + +A bundler maps an alias path to source code at bundle time. The RFC commits only to **minimum** behaviour and stays +silent on the rest: + +- Bundlers **SHOULD** support `.luaurc`-based disk alias resolution. This is the Wally/Roblox compatibility floor. +- Bundlers **MAY** support additional resolvers (inventory, marketplace, http registries, etc.). These are entirely an + implementation choice; the RFC neither enumerates nor specifies them. +- The bundle's embedded source is the **universal last-resort resolver**. Any bundler can rebundle a bundle it received - + even if it cannot reach the original sources - by reusing the bundled copies of each module. + +The last-resort resolver is what keeps a viewer-authored bundle re-buildable from an external CLI without inventory +access: when an alias has no local resolver, the bundler falls back to the source already embedded in the previous +bundle. The script never breaks just because the new environment lacks the original resolver. + +## Project Linkage + +The bundle header's optional `PROJECT ` directive lets a viewer associate a script with a local disk project +for editing. The RFC specifies the **contract** only: + +- A viewer MAY maintain bindings from project names (and optionally per-object UUID overrides) to local disk directories. +- Resolvers see the path resulting from the binding; how it is stored is viewer business. +- Bindings are per-user-per-machine, never synced to SL servers, and not assumed identical between collaborators. +- Header is metadata only; an unset project name just means "no disk linkage known here." A bundler that ignores it + works correctly. + +### Non-normative appendix: registry sketch + +A minimal viewer-internal registry could look like: + +```json +{ + "projects": { + "myhud": "/Users/me/slua/myhud", + "vendor-system": "/Users/me/slua/vending" + }, + "objects": { + "01234567-89ab-cdef-0123-456789abcdef": "/Users/me/slua/oldhud" + } +} +``` + +Resolution order: object UUID override -> project header name -> unbound. + +This appendix is illustrative. TPVs may store bindings however suits their codebase; only the contract above is +load-bearing. + +Viewer-to-disk **sync-back** (writing MAIN edits back to a bound disk project) is deferred to a follow-up RFC. ## Editing Workflow @@ -117,11 +301,11 @@ flowchart TD StartSave[User saves] IsRawView{Viewing raw?} SendRaw[Send as-is] - HasLocalDep{Local dep exists?} + ResolverSucceeds{Resolver produces source?} CheckOwnership{Same user last saved?} - BundleLocal[Bundle in local dep] - CheckMatch{Local matches bundle?} - ConfirmUntrusted{Confirm: pull local dep\ninto untrusted script?} + BundleResolved[Bundle from resolver] + CheckMatch{Resolver matches bundle?} + ConfirmUntrusted{Confirm: pull resolved source\ninto untrusted script?} DepInBundle{Dep in existing bundle?} BundleFromBundle[Bundle from existing] ResolveError[Error: can't resolve] @@ -130,19 +314,19 @@ flowchart TD StartSave --> IsRawView IsRawView -->|Yes| SendRaw - IsRawView -->|No| HasLocalDep - HasLocalDep -->|Yes| CheckOwnership - CheckOwnership -->|Yes| BundleLocal + IsRawView -->|No| ResolverSucceeds + ResolverSucceeds -->|Yes| CheckOwnership + CheckOwnership -->|Yes| BundleResolved CheckOwnership -->|No| CheckMatch - CheckMatch -->|Yes| BundleLocal + CheckMatch -->|Yes| BundleResolved CheckMatch -->|No| ConfirmUntrusted - ConfirmUntrusted -->|Yes| BundleLocal + ConfirmUntrusted -->|Yes| BundleResolved ConfirmUntrusted -->|No| AbortSave - HasLocalDep -->|No| DepInBundle + ResolverSucceeds -->|No| DepInBundle DepInBundle -->|Yes| BundleFromBundle DepInBundle -->|No| ResolveError ResolveError --> AbortSave - BundleLocal --> SendBundle + BundleResolved --> SendBundle BundleFromBundle --> SendBundle end @@ -162,13 +346,14 @@ flowchart TD ``` **Key points:** -- MAIN-only view: user sees entry script, client bundles on save -- Raw view: user sees/edits full archive, sent as-is -- Ownership check: security measure to prevent leaking local code into scripts others control -- - Open question as to how this should work, we'll need to do some server-side enrichment -- Fallback: if local dep missing, use version from downloaded bundle -- - This allows users to do one-off edits to scripts from the viewer even if they don't have all the - constituent parts on their drives. +- MAIN-only view: user sees entry script, client bundles on save by running its resolver chain. +- Raw view: user sees/edits full archive, sent as-is. This path also serves externally-built bundles uploaded directly. +- Resolver chain: each alias is tried against the bundler's resolvers (`.luaurc` disk minimum; viewer may add inventory or others). Chain order and resolver types are bundler-specific (see [Resolver Behaviour](#resolver-behaviour)). +- Ownership check: security measure to prevent leaking resolved source into a script the current user does not own. +- - Open question as to how this should work; needs server-side enrichment. +- Universal fallback: if no resolver succeeds, use the version embedded in the previous bundle. This lets users do + one-off edits even when they lack the original resolver context (e.g., editing in an external CLI a script that was + originally bundled by a viewer with inventory access). ## Runtime @@ -198,14 +383,28 @@ p->source = protoSource; | Error | When | Message | |-------|------|---------| | Dynamic require | Compile | `require() argument must be a string literal` | -| Relative path | Compile | `relative paths not supported` | +| Bare identifier | Compile | `require path must start with './', '../', or '@'` | | Unknown alias | Compile | `unknown alias '@foo' in require` | -| Module not found | Compile | `cannot find module ''` | +| Require escapes alias | Compile | `require '' from : '..' traverses past alias root` | +| Invalid path component | Compile | `component '' starts with '.'` / `component contains NUL` | +| Relative require without anchor | Compile | MAIN uses `./`, `../`, or `@self/` but the bundle has no `MAIN` directive | +| No covering alias | Bundle time | `no alias spans ; add a .luaurc entry covering it` (won't fire for files under `project_root` since `@root` always covers it) | +| Reserved alias | Bundle time | `.luaurc` declares an alias name reserved by the format (`root`, `self`) | +| Alias collision | Bundle time | Two `.luaurc` aliases point at the exact same directory; emitted as a warning, not an error | +| Ambiguous file resolution | Bundle time | Both `.luau` and `/init.luau` exist; remove one to disambiguate | +| No resolver succeeded | Bundle time | `cannot resolve module '': no resolver produced source and no copy in existing bundle` | | Circular dependency | Compile | `circular dependency: -> -> ` | -| Delimiter in source | Compile | `source cannot contain '--[[!!SLUA:'` | +| Delimiter in source | Compile | `source cannot contain '-- !!LUABUNDLE:'` | | Depth exceeded | Compile | `require depth exceeds maximum` | +| Duplicate MODULE marker | Parse | Bundle contains two `MODULE` directives with the same canonical key | +| Malformed module key | Parse | A `MODULE` key does not begin with `@` | +| Missing MAIN directive | Parse | Bundle has no `MAIN` directive | | Module not in bundle | Runtime | `module not found: ` | +The reference Python bundler raises the bundle-time and parse errors directly. The production C++ Luau compiler will +surface the compile-time errors at compile time; the reference bundler raises analogous errors at bundle time as a +stand-in. + ## Limits - Max dependency depth: 100 @@ -216,4 +415,5 @@ p->source = protoSource; - Tree shaking (eliminate unused exports) - Cross-module inlining (`--!pure` modules) -- Inventory-based module resolution +- Viewer-to-disk sync-back semantics (separate RFC, after viewer implementation experience) +- Marketplace / registry resolver designs (whatever shape they take, they sit alongside disk and inventory as additional bundler-side resolvers; the bundle format does not need to know) diff --git a/tools/slua_bundle/README.md b/tools/slua_bundle/README.md new file mode 100644 index 00000000..3fec4d80 --- /dev/null +++ b/tools/slua_bundle/README.md @@ -0,0 +1,78 @@ +# slua-bundle + +Reference implementation of the SLua static-require-bundle algorithm. Bundle a Luau project rooted at a `MAIN` module into a single text artifact, inspect bundle artifacts, and extract bundles back into a self-contained project tree. + +For the format spec and design rationale, see [`rfcs/static-require-bundle.md`](../../rfcs/static-require-bundle.md) at the repo root. + +## Install + +Requires Python 3.7+. + +``` +pip install ./tools/slua_bundle +``` + +Or, for development: + +``` +pip install -e ./tools/slua_bundle +``` + +Either form puts a `slua-bundle` command on `$PATH`. + +## CLI + +### `slua-bundle bundle` + +Bundle a project tree into a single text artifact. Project-internal modules canonicalize under the built-in `@root` alias. `--project` is an optional advisory project name emitted as the bundle's `PROJECT` directive (for viewer-side linkage to a disk project); it does not affect canonicalization. + +``` +slua-bundle bundle --root ./src ./src/Main.luau -o bundle.lua +slua-bundle bundle --root ./src --project myhud ./src/Main.luau -o bundle.lua +``` + +Omit `-o` to write to stdout. + +Pass `--input-bundle PATH` to use an existing bundle as a last-resort resolver: when a require's source is missing on disk (or its alias isn't declared in `.luaurc`), the embedded copy from the prior bundle is used instead. Disk wins when both are available. This is the rebundle-without-source flow - useful when a CLI receives a viewer-built bundle and needs to rebuild it without access to the original resolvers (inventory, etc.). + +### `slua-bundle inspect` + +Show a bundle's structure: format version, advisory project name (if any), MAIN entry point, and a per-module byte breakdown sorted by canonical key. + +``` +slua-bundle inspect bundle.lua +``` + +### `slua-bundle extract` + +Reverse a bundle into a project tree. `@root` modules land flat under `/`, non-root aliases under `//`. A `.luaurc` is generated when non-root aliases are present. Re-bundling the extracted tree reproduces the original bundle byte-for-byte. + +``` +slua-bundle extract -o ./extracted bundle.lua +``` + +## Library + +```python +from pathlib import Path +from slua_bundle import DiskFS, bundle, parse_bundle +from slua_bundle.extractor import extract_to_dir + +fs = DiskFS(Path("./src")) +text = bundle(fs, project_root=Path("./src").resolve(), + main_path=Path("./src/Main.luau").resolve()) +# Optionally pass project_name="myhud" to emit a PROJECT directive. + +parsed = parse_bundle(text) +extract_to_dir(parsed, DiskFS(Path("./extracted")), Path("./extracted").resolve()) +``` + +`MemoryFS` is also available for dict-backed in-memory use, mirroring `DiskFS`. + +## Development + +``` +pip install -e ./tools/slua_bundle +cd tools/slua_bundle +python -m pytest +``` diff --git a/tools/slua_bundle/pyproject.toml b/tools/slua_bundle/pyproject.toml new file mode 100644 index 00000000..734e306f --- /dev/null +++ b/tools/slua_bundle/pyproject.toml @@ -0,0 +1,64 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "slua-bundle" +version = "0.1.0" +description = "Reference implementation of the SLua static-require-bundle algorithm: bundle, inspect, and extract Luau projects." +requires-python = ">=3.7" +readme = "README.md" + +[project.scripts] +slua-bundle = "slua_bundle.cli:main" + +[tool.setuptools] +packages = ["slua_bundle"] + +[tool.setuptools.package-data] +slua_bundle = ["py.typed"] + +[dependency-groups] +dev = [ + "mypy>=1.0", + "pytest>=8", + "pytest-cov>=6", + "ruff>=0.8", +] +test = [ + "pytest>=8", + "pytest-cov", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 +target-version = "py37" + +[tool.ruff.lint] +select = ["B", "C", "E", "F", "W", "I", "C90", "RUF"] +ignore = ["E501", "RUF012"] + +[tool.ruff.lint.isort] +combine-as-imports = true + +[tool.ruff.lint.mccabe] +max-complexity = 25 + +[tool.mypy] +follow_imports = "normal" +strict_optional = true + +[tool.coverage.run] +source = ["slua_bundle"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] diff --git a/tools/slua_bundle/slua_bundle/__init__.py b/tools/slua_bundle/slua_bundle/__init__.py new file mode 100644 index 00000000..6e794e59 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/__init__.py @@ -0,0 +1,63 @@ +from .bundler import ( + AmbiguousResolutionError, + DepthExceededError, + MarkerInjectionError, + ModuleCountExceededError, + NoResolverError, + bundle, +) +from .canonicalize import ( + AliasCollisionWarning, + NoCoveringAliasError, + ReservedAliasError, + canonicalize, +) +from .errors import BundleError +from .fs import DiskFS, FSBackend, MemoryFS +from .luaurc import InvalidLuaurcError +from .resolver import ( + BareIdentifierError, + InvalidPathComponentError, + RelativeRequireWithoutAnchorError, + RequireEscapesAliasError, + UnknownAliasError, + resolve, +) +from .runtime import ( + BundleParseError, + BundleParser, + CircularDependencyError, + ParsedBundle, + parse_bundle, + simulate, +) + +__all__ = [ + "AliasCollisionWarning", + "AmbiguousResolutionError", + "BareIdentifierError", + "BundleError", + "BundleParseError", + "BundleParser", + "CircularDependencyError", + "DepthExceededError", + "DiskFS", + "FSBackend", + "InvalidLuaurcError", + "InvalidPathComponentError", + "MarkerInjectionError", + "MemoryFS", + "ModuleCountExceededError", + "NoCoveringAliasError", + "NoResolverError", + "ParsedBundle", + "RelativeRequireWithoutAnchorError", + "RequireEscapesAliasError", + "ReservedAliasError", + "UnknownAliasError", + "bundle", + "canonicalize", + "parse_bundle", + "resolve", + "simulate", +] diff --git a/tools/slua_bundle/slua_bundle/__main__.py b/tools/slua_bundle/slua_bundle/__main__.py new file mode 100644 index 00000000..dd8a8c90 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .cli import main + +sys.exit(main()) diff --git a/tools/slua_bundle/slua_bundle/bundler.py b/tools/slua_bundle/slua_bundle/bundler.py new file mode 100644 index 00000000..19e1b8d7 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/bundler.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import re +from collections import deque +from pathlib import PurePath + +from .canonicalize import build_alias_map, canonicalize +from .errors import BundleError +from .fs import FSBackend, normalize +from .resolver import _split_canonical, resolve +from .runtime import parse_bundle + +BUNDLE_VERSION = 1 + +MAX_DEPTH = 100 +MAX_MODULES = 1000 + +REQUIRE_RE = re.compile(r'\brequire\s*\(\s*(?:"([^"]*)"|\'([^\']*)\')\s*\)') +_MARKER_RE = re.compile(r"^--\s*!!LUABUNDLE:", re.MULTILINE) + + +class MarkerInjectionError(BundleError): + pass + + +class AmbiguousResolutionError(BundleError): + """Both .luau and /init.luau exist for a resolved require.""" + + +class NoResolverError(BundleError): + """No resolver produced source and no copy was found in the existing bundle.""" + + +class DepthExceededError(BundleError): + """The require graph exceeds MAX_DEPTH levels from MAIN.""" + + +class ModuleCountExceededError(BundleError): + """The require graph exceeds MAX_MODULES total modules.""" + + +def _check_no_marker_injection(origin: str, source: str) -> None: + if _MARKER_RE.search(source): + raise MarkerInjectionError( + f"{origin} contains a line that looks like a bundle marker; " + "source files must not contain '-- !!LUABUNDLE:' at the start of a line" + ) + + +def bundle( + vfs: FSBackend, + project_root: PurePath, + main_path: PurePath, + project_name: str | None = None, + existing_bundle: str | None = None, +) -> str: + """Bundle MAIN and everything it transitively requires. + + Pure-trace: starts at MAIN, regex-finds `require()` calls, resolves each + to a physical file, and enqueues unvisited targets. Files unreachable + from MAIN never enter the bundle. Aliases come from project_root's + .luaurc only (nested .luaurc files are ignored for reproducibility); + the built-in @root alias always covers project_root. + + project_name, if provided, is emitted as the bundle's PROJECT directive + (advisory viewer-linkage metadata) but is not used for canonicalization. + + existing_bundle, if provided, acts as the universal last-resort resolver: + when an alias has no on-disk source (or no .luaurc entry), the bundler + falls back to the source already embedded in that bundle. Disk wins + when both are available. + """ + project_root = normalize(project_root) + main_path = normalize(main_path) + + alias_map = build_alias_map(vfs, project_root) + main_key = canonicalize(vfs, main_path, project_root) + main_source = vfs.read(main_path) + _check_no_marker_injection(str(main_path), main_source) + + fallback_modules: dict[str, str] = {} + if existing_bundle is not None: + parsed = parse_bundle(existing_bundle) + fallback_modules.update(parsed.modules) + fallback_modules[parsed.fields["main"]] = parsed.main_source + + known_aliases = set(alias_map.keys()) + for key in fallback_modules: + known_aliases.add(key[1:].split("/", 1)[0]) + + visited: dict[str, str] = {} + queue: deque[tuple[str, str, int]] = deque() + queue.append((main_key, main_source, 0)) + enqueued: set[str] = {main_key} + + while queue: + key, source, depth = queue.popleft() + if depth > MAX_DEPTH: + raise DepthExceededError( + f"require depth {depth} exceeds maximum {MAX_DEPTH} at module {key}" + ) + if key in visited: + continue + visited[key] = source + + for dq, sq in REQUIRE_RE.findall(source): + req_str = dq or sq + target_key, target_source = _resolve_source( + vfs, alias_map, fallback_modules, project_root, + key, req_str, known_aliases, + ) + if target_key in enqueued: + continue + _check_no_marker_injection(target_key, target_source) + if len(enqueued) >= MAX_MODULES: + raise ModuleCountExceededError( + f"bundle exceeds maximum of {MAX_MODULES} modules" + ) + enqueued.add(target_key) + queue.append((target_key, target_source, depth + 1)) + + main_body = visited.pop(main_key) + + parts: list[str] = [f"-- !!LUABUNDLE:VERSION {BUNDLE_VERSION}\n"] + if project_name is not None: + parts.append(f"-- !!LUABUNDLE:PROJECT {project_name}\n") + parts.append(f"-- !!LUABUNDLE:MAIN {main_key}\n") + parts.append("-- !!LUABUNDLE:BODY\n") + parts.append(_terminate(main_body)) + for key, source in visited.items(): + parts.append(f"-- !!LUABUNDLE:MODULE {key}\n") + parts.append(_terminate(source)) + return "".join(parts) + + +def _resolve_source( + vfs: FSBackend, + alias_map: dict[str, PurePath], + fallback_modules: dict[str, str], + project_root: PurePath, + requirer_anchor_key: str, + require_str: str, + known_aliases: set[str], +) -> tuple[str, str]: + """Resolve a require() string to (canonical_key, source). + + Disk resolver wins; the fallback bundle's embedded copy is the universal + last resort (RFC: 'Resolver Behaviour'). When the alias isn't even known + to disk, fall back directly. + + On disk hits, re-canonicalize from the physical path so colliding aliases + (two .luaurc entries targeting the same directory) de-dup to a single + canonical key and the collision warning fires from canonicalize(). + """ + target_key = resolve(require_str, requirer_anchor_key, known_aliases) + alias, rel_parts = _split_canonical(target_key) + + if alias in alias_map: + target_path = _luau_resolve_to_file(vfs, alias_map[alias], rel_parts) + if target_path is not None: + canonical_key = canonicalize(vfs, target_path, project_root) + return canonical_key, vfs.read(target_path) + + if target_key in fallback_modules: + return target_key, fallback_modules[target_key] + + raise NoResolverError( + f"cannot resolve module '{require_str}' to '{target_key}': " + "no resolver produced source and no copy in existing bundle" + ) + + +def _luau_resolve_to_file( + vfs: FSBackend, + base_dir: PurePath, + rel_parts: list[str], +) -> PurePath | None: + """Apply Luau's file resolution: try /.../.luau, then /...//init.luau. + + Returns None when no file is found - the caller decides whether to try + another resolver (existing-bundle fallback) or surface NoResolverError. + Raises AmbiguousResolutionError when both candidate files exist. + """ + if not rel_parts: + # `@SomeAlias` with no tail: the alias root itself is the module. + # Look for init.luau directly in the alias root. + candidate = base_dir / "init.luau" + return candidate if vfs.is_file(candidate) else None + + leaf = rel_parts[-1] + parent_dir = base_dir + for p in rel_parts[:-1]: + parent_dir = parent_dir / p + + leaf_file = parent_dir / f"{leaf}.luau" + init_file = parent_dir / leaf / "init.luau" + + leaf_exists = vfs.is_file(leaf_file) + init_exists = vfs.is_file(init_file) + + if leaf_exists and init_exists: + raise AmbiguousResolutionError( + f"both {leaf_file} and {init_file} exist; remove one to disambiguate" + ) + if leaf_exists: + return leaf_file + if init_exists: + return init_file + return None + + +def _terminate(content: str) -> str: + """Force exactly one trailing newline so the next marker lands at column 0. + + Body content is otherwise preserved byte-for-byte. + """ + return content if content.endswith("\n") else content + "\n" diff --git a/tools/slua_bundle/slua_bundle/canonicalize.py b/tools/slua_bundle/slua_bundle/canonicalize.py new file mode 100644 index 00000000..c229ae84 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/canonicalize.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import warnings +from pathlib import PurePath + +from .errors import BundleError +from .fs import FSBackend, normalize +from .luaurc import load_config + + +RESERVED_ALIASES = frozenset({"root", "self"}) + + +class NoCoveringAliasError(BundleError): + pass + + +class ReservedAliasError(BundleError): + pass + + +class AliasCollisionWarning(UserWarning): + pass + + +def _is_prefix(prefix: PurePath, path: PurePath) -> bool: + if prefix == path: + return True + return prefix in path.parents + + +def build_alias_map( + vfs: FSBackend, + project_root: PurePath, +) -> dict[str, PurePath]: + """Project-root .luaurc + the built-in @root alias. + + Nested .luaurc files are ignored: alias set must be a function of the + project tree alone for bundle reproducibility. Reserved alias names + (currently just 'root') cannot be declared in .luaurc. + """ + project_root = normalize(project_root) + aliases = load_config(vfs, project_root) + for reserved in RESERVED_ALIASES: + if reserved in aliases: + raise ReservedAliasError( + f"alias '{reserved}' is reserved and cannot be declared in .luaurc" + ) + aliases["root"] = project_root + return aliases + + +def canonicalize( + vfs: FSBackend, + file_path: PurePath, + project_root: PurePath, +) -> str: + file_path = normalize(file_path) + project_root = normalize(project_root) + + aliases = build_alias_map(vfs, project_root) + covering = [(target, name) for name, target in aliases.items() if _is_prefix(target, file_path)] + if not covering: + raise NoCoveringAliasError( + f"no alias spans {file_path}, add a .luaurc entry covering it" + ) + + max_depth = max(len(t.parts) for t, _ in covering) + most_specific = [(t, n) for t, n in covering if len(t.parts) == max_depth] + # Two aliases at identical depth that both cover the same file must share + # the same target path (only one directory at any given depth can be an + # ancestor of a given file), so target_dir below is unambiguous. + target_dir = most_specific[0][0] + + if len(most_specific) > 1: + names = sorted(n for _, n in most_specific) + if "root" in names: + alias_name = "root" + else: + alias_name = names[0] + warnings.warn( + f"multiple .luaurc aliases target the same directory {target_dir}: " + f"{names}; using '{alias_name}' (ASCII-first) for canonical keys", + AliasCollisionWarning, + stacklevel=2, + ) + else: + alias_name = most_specific[0][1] + + rel = file_path.relative_to(target_dir) + rel_parts = list(rel.with_suffix("").parts) + if rel_parts and rel_parts[-1] == "init": + rel_parts.pop() + + if not rel_parts: + return f"@{alias_name}" + return f"@{alias_name}/" + "/".join(rel_parts) diff --git a/tools/slua_bundle/slua_bundle/cli.py b/tools/slua_bundle/slua_bundle/cli.py new file mode 100644 index 00000000..ebf7e5da --- /dev/null +++ b/tools/slua_bundle/slua_bundle/cli.py @@ -0,0 +1,111 @@ +"""Command-line interface to the bundler. + +Subcommands: + bundle -- produce a bundle from a project on disk + inspect -- pretty-print a bundle's structure + extract -- reverse a bundle into a self-contained project tree + +Errors raised by the library are caught at the top of `main()` and +printed to stderr with exit code 1; unexpected exceptions propagate so +stack traces stay visible during prototype development. +""" + +from __future__ import annotations + +import argparse +import pathlib +import sys + +from .bundler import bundle +from .errors import BundleError +from .extractor import extract_to_dir +from .fs import DiskFS +from .runtime import parse_bundle + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="python -m slua_bundle") + sub = parser.add_subparsers(dest="cmd", required=True) + + p_bundle = sub.add_parser("bundle", help="Bundle a project tree") + p_bundle.add_argument("--project", default=None, help="Optional advisory project name (emitted as PROJECT directive for viewer linkage)") + p_bundle.add_argument("--root", required=True, type=pathlib.Path, help="Project root directory (covered by built-in @root alias)") + p_bundle.add_argument("--input-bundle", dest="input_bundle", type=pathlib.Path, default=None, help="Existing bundle whose embedded modules act as a last-resort resolver when disk sources are missing") + p_bundle.add_argument("-o", "--output", type=pathlib.Path, default=None, help="Write bundle to this file (default: stdout)") + p_bundle.add_argument("main", type=pathlib.Path, help="Path to MAIN .luau file") + + p_inspect = sub.add_parser("inspect", help="Show a bundle's structure") + p_inspect.add_argument("bundle_file", type=pathlib.Path, nargs="?", default=None, help="Bundle file to inspect (default: stdin)") + + p_extract = sub.add_parser("extract", help="Reverse a bundle into a project tree") + p_extract.add_argument("-o", "--output", required=True, type=pathlib.Path, help="Output directory") + p_extract.add_argument("bundle_file", type=pathlib.Path, help="Bundle file to extract") + + return parser + + +def _cmd_bundle(args: argparse.Namespace) -> int: + disk = DiskFS(args.root) + existing = args.input_bundle.read_text() if args.input_bundle else None + text = bundle( + disk, + project_root=args.root.resolve(), + main_path=args.main.resolve(), + project_name=args.project, + existing_bundle=existing, + ) + if args.output: + args.output.write_text(text) + else: + sys.stdout.write(text) + return 0 + + +def _read_bundle_text(arg: pathlib.Path | None) -> str: + if arg is None: + return sys.stdin.read() + return arg.read_text() + + +def _cmd_inspect(args: argparse.Namespace) -> int: + text = _read_bundle_text(args.bundle_file) + parsed = parse_bundle(text) + print(f"VERSION {parsed.fields.get('version', '?')}") + project = parsed.fields.get('project') + print(f"PROJECT {project}" if project else "PROJECT ") + main_key = parsed.fields.get("main") + main_size = len(parsed.main_source.encode("utf-8")) + if main_key: + print(f"MAIN {main_key} ({main_size} bytes)") + else: + print(f"MAIN ({main_size} bytes)") + modules = sorted(parsed.modules.items()) + total = sum(len(s.encode("utf-8")) for _, s in modules) + print(f"MODULES ({len(modules)}, total {total} bytes):") + for key, source in modules: + print(f" {key} ({len(source.encode('utf-8'))} bytes)") + return 0 + + +def _cmd_extract(args: argparse.Namespace) -> int: + text = args.bundle_file.read_text() + parsed = parse_bundle(text) + extract_to_dir(parsed, DiskFS(args.output), args.output.resolve()) + n = 1 + len(parsed.modules) + print(f"extracted {n} modules to {args.output}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + handlers = { + "bundle": _cmd_bundle, + "inspect": _cmd_inspect, + "extract": _cmd_extract, + } + try: + return handlers[args.cmd](args) + except BundleError as e: + print(f"error: {e}", file=sys.stderr) + return 1 diff --git a/tools/slua_bundle/slua_bundle/errors.py b/tools/slua_bundle/slua_bundle/errors.py new file mode 100644 index 00000000..c4ee25dd --- /dev/null +++ b/tools/slua_bundle/slua_bundle/errors.py @@ -0,0 +1,12 @@ +"""Root exception for everything raised by slua_bundle. + +Catch `BundleError` to handle any user-surfaceable error the bundler, +parser, resolver, or extractor can raise. Specific subclasses live next +to the code that raises them. +""" + +from __future__ import annotations + + +class BundleError(Exception): + pass diff --git a/tools/slua_bundle/slua_bundle/extractor.py b/tools/slua_bundle/slua_bundle/extractor.py new file mode 100644 index 00000000..82bc77be --- /dev/null +++ b/tools/slua_bundle/slua_bundle/extractor.py @@ -0,0 +1,135 @@ +"""Reverse a parsed bundle into a self-contained on-disk project tree. + +Layout convention: @root/... files land at /, files under other +aliases at //. A .luaurc declaring every non-root alias is +written when any are present. The extracted tree is hermetic: re-bundling +it with `--root ` reproduces the original bundle byte-for-byte. +""" + +from __future__ import annotations + +import json +from pathlib import PurePath + +from .errors import BundleError +from .fs import FSBackend +from .resolver import _split_canonical +from .runtime import ParsedBundle + + +class ExtractError(BundleError): + pass + + +class ExtractCollisionError(ExtractError): + """Two canonical keys map to the same physical path.""" + + +class ExtractClobberError(ExtractError): + """Output path already contains files that extract would overwrite.""" + + +class ExtractMissingMainError(ExtractError): + """Bundle has no MAIN directive; cannot place MAIN's body.""" + + +class ExtractUnsafeKeyError(ExtractError): + """Canonical key has a component that would escape the output dir or + otherwise produce an unsafe path.""" + + +_UNSAFE_COMPONENTS = frozenset({"", ".", ".."}) + + +def _validate_key_parts(canonical_key: str, alias: str, parts: list[str]) -> None: + """Reject keys whose components could traverse outside or + produce malformed paths. Our own bundler never emits these; this is + the trust boundary for bundles from unknown sources. + """ + for component in (alias, *parts): + if component in _UNSAFE_COMPONENTS: + raise ExtractUnsafeKeyError( + f"canonical key {canonical_key!r} contains unsafe component {component!r}" + ) + if any(c in component for c in ("/", "\\", "\x00")): + raise ExtractUnsafeKeyError( + f"canonical key {canonical_key!r} contains illegal char in component {component!r}" + ) + + +def physical_path_for_key( + canonical_key: str, + output: PurePath, +) -> PurePath: + """Map @alias/path -> on-disk path under . + + @root files go flat under ; other aliases go under a same-named + subdir. Bare alias keys (stripped init.luau) become init.luau in the + appropriate dir. + """ + alias, parts = _split_canonical(canonical_key) + _validate_key_parts(canonical_key, alias, parts) + base = output if alias == "root" else output / alias + if not parts: + return base / "init.luau" + return base.joinpath(*parts[:-1], f"{parts[-1]}.luau") + + +def extract_to_dir( + parsed: ParsedBundle, + out_fs: FSBackend, + output: PurePath, +) -> None: + main_key = parsed.fields.get("main") + if not main_key: + raise ExtractMissingMainError( + "bundle has no MAIN directive; cannot place MAIN's body. " + "Re-bundle with current code -- older bundles produced without " + "MAIN are not extractable." + ) + + sources: dict[str, str] = {main_key: parsed.main_source} + sources.update(parsed.modules) + + layout: dict[str, PurePath] = { + key: physical_path_for_key(key, output) + for key in sources + } + + seen: dict[PurePath, str] = {} + for key, path in layout.items(): + prior = seen.get(path) + if prior is not None: + raise ExtractCollisionError( + f"keys {prior!r} and {key!r} both map to {path}; " + "rename one alias or restructure the bundle" + ) + seen[path] = key + + luaurc_path = output / ".luaurc" + aliases_for_luaurc = sorted({ + _split_canonical(key)[0] + for key in sources + if _split_canonical(key)[0] != "root" + }) + + pre_existing: list[PurePath] = [ + path for path in layout.values() if out_fs.is_file(path) + ] + if aliases_for_luaurc and out_fs.is_file(luaurc_path): + pre_existing.append(luaurc_path) + if pre_existing: + listing = ", ".join(str(p) for p in pre_existing) + raise ExtractClobberError( + f"refusing to overwrite existing files: {listing}" + ) + + for key, path in layout.items(): + out_fs.write(path, sources[key]) + + if aliases_for_luaurc: + body = json.dumps( + {"aliases": {a: a for a in aliases_for_luaurc}}, + indent=2, + ) + "\n" + out_fs.write(luaurc_path, body) diff --git a/tools/slua_bundle/slua_bundle/fs.py b/tools/slua_bundle/slua_bundle/fs.py new file mode 100644 index 00000000..4efbcaf5 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/fs.py @@ -0,0 +1,173 @@ +""" +Filesystem backends for the bundler. + +Two backends ship: MemoryFS (dict-backed, used by tests) and DiskFS +(real on-disk projects). Each declares a `Path` class attribute -- the +PurePath subclass it works with -- so application code can stay generic +over `PurePath` without mixing POSIX and Windows path types within a +single backend. + +Bundle keys are constructed from `PurePath.parts`, which returns plain +string tuples for any subclass, so the wire format is POSIX-shaped on +every host. +""" + +from __future__ import annotations + +import os +import pathlib +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from pathlib import PurePath, PurePosixPath +from typing import ClassVar, Iterator + + +def _is_anchor(s: str) -> bool: + return s in ("/", "\\") or s.endswith((":\\", ":/")) + + +def normalize(p: PurePath) -> PurePath: + """Collapse `.` and `..` segments, preserving the path subclass.""" + cls = type(p) + parts: list[str] = [] + for part in p.parts: + if part == "..": + if parts and parts[-1] != ".." and not _is_anchor(parts[-1]): + parts.pop() + else: + parts.append(part) + elif part == ".": + continue + else: + parts.append(part) + if not parts: + return cls(".") + return cls(*parts) + + +class FSBackend(ABC): + Path: ClassVar[type[PurePath]] + + @abstractmethod + def is_file(self, path: PurePath | str) -> bool: ... + + @abstractmethod + def read(self, path: PurePath | str) -> str: ... + + @abstractmethod + def is_dir(self, path: PurePath | str) -> bool: ... + + @abstractmethod + def iter_files(self) -> Iterator[PurePath]: ... + + @abstractmethod + def write(self, path: PurePath | str, content: str) -> None: + """Write `content` to `path`, creating parent dirs as needed.""" + + def to_path(self, p: PurePath | str) -> PurePath: + if isinstance(p, str): + p = self.Path(p) + return normalize(p) + + +@dataclass +class MemoryFS(FSBackend): + """ + In-memory dict-backed filesystem. Used by tests for deterministic, + cross-platform behavior. Keys are normalized at construction and at + lookup, mimicking how `open()` resolves `..`/`.` during traversal. + """ + + Path: ClassVar[type[PurePath]] = PurePosixPath + files: dict[PurePosixPath, str] = field(default_factory=dict) + + @classmethod + def from_dict(cls, d: dict[str, str]) -> "MemoryFS": + return cls({_as_posix(k): v for k, v in d.items()}) + + def is_file(self, path: PurePath | str) -> bool: + return _as_posix(path) in self.files + + def read(self, path: PurePath | str) -> str: + key = _as_posix(path) + if key not in self.files: + raise FileNotFoundError(f"not a file: {path}") + return self.files[key] + + def is_dir(self, path: PurePath | str) -> bool: + key = _as_posix(path) + return any(key in p.parents for p in self.files) + + def iter_files(self) -> Iterator[PurePosixPath]: + return iter(self.files.keys()) + + def write(self, path: PurePath | str, content: str) -> None: + self.files[_as_posix(path)] = content + + +def _as_posix(path: PurePath | str) -> PurePosixPath: + if isinstance(path, str): + path = PurePosixPath(path) + elif not isinstance(path, PurePosixPath): + path = PurePosixPath(*path.parts) + result = normalize(path) + assert isinstance(result, PurePosixPath) + return result + + +class DiskFS(FSBackend): + """ + Real on-disk filesystem rooted at a single directory. + + Path type is `pathlib.Path` (PosixPath on POSIX, WindowsPath on NT -- + both inherit from PurePath). Paths passed in are typically absolute + paths under the root; paths returned from `iter_files()` are + absolute. + + Symlinks are followed: a symlink in a developer's project is + intentional, pathlib detects cycles, and any file landing outside an + alias root surfaces cleanly via NoCoveringAliasError. + """ + + Path: ClassVar[type[PurePath]] = pathlib.Path + + def __init__(self, root: pathlib.Path) -> None: + self._root = root.resolve() + + @property + def root(self) -> pathlib.Path: + return self._root + + def _coerce(self, path: PurePath | str) -> pathlib.Path: + if isinstance(path, str): + return pathlib.Path(path) + if isinstance(path, pathlib.Path): + return path + return pathlib.Path(*path.parts) + + def is_file(self, path: PurePath | str) -> bool: + return self._coerce(path).is_file() + + def read(self, path: PurePath | str) -> str: + return self._coerce(path).read_text() + + def is_dir(self, path: PurePath | str) -> bool: + return self._coerce(path).is_dir() + + def iter_files(self) -> Iterator[pathlib.Path]: + # Skip hidden directories (.git/, node_modules-style hidden trees, + # etc.) and hidden files except .luaurc, which carries config we + # need. Dotfile filtering keeps real-world projects bundleable + # without spurious MarkerInjectionError hits or perf cliffs. + for cur, dirs, files in os.walk(self._root, followlinks=True): + dirs[:] = [d for d in dirs if not d.startswith(".")] + cur_path = pathlib.Path(cur) + for name in files: + if name.startswith(".") and name != ".luaurc": + continue + yield cur_path / name + + def write(self, path: PurePath | str, content: str) -> None: + target = self._coerce(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) diff --git a/tools/slua_bundle/slua_bundle/luaurc.py b/tools/slua_bundle/slua_bundle/luaurc.py new file mode 100644 index 00000000..17bac870 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/luaurc.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import re +from pathlib import PurePath + +from .errors import BundleError +from .fs import FSBackend, normalize + +CONFIG_NAME = ".luaurc" + + +class InvalidLuaurcError(BundleError): + """A .luaurc file exists but is not valid JSONC.""" + +# Match Luau's actual .luaurc parser (Config/src/Config.cpp): // line +# comments and trailing commas in objects/arrays. Block comments +# (`/* */`) are NOT accepted by Luau and stay in the stream so +# json.loads fails on them. +# +# Two passes so a trailing comma hidden behind a line comment isn't +# missed -- a single combined regex consumes the comment and never +# revisits the comma underneath. String literals are preserved by the +# alternation in each pattern. +_LINE_COMMENT_RE = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*') +_TRAILING_COMMA_RE = re.compile(r'"(?:\\.|[^"\\])*"|,(?=\s*[}\]])') + + +def _strip_jsonc(s: str) -> str: + def keep_strings(m: re.Match[str]) -> str: + text = m.group(0) + return text if text.startswith('"') else "" + s = _LINE_COMMENT_RE.sub(keep_strings, s) + s = _TRAILING_COMMA_RE.sub(keep_strings, s) + return s + + +def load_config(vfs: FSBackend, config_dir: PurePath) -> dict[str, PurePath]: + config_path = config_dir / CONFIG_NAME + if not vfs.is_file(config_path): + return {} + try: + raw = json.loads(_strip_jsonc(vfs.read(config_path))) + except json.JSONDecodeError as e: + raise InvalidLuaurcError( + f"{config_path}: invalid JSONC ({e.msg} at line {e.lineno}, column {e.colno})" + ) from e + aliases = raw.get("aliases", {}) + out: dict[str, PurePath] = {} + for name, target in aliases.items(): + target_path = vfs.Path(target) + if target_path.is_absolute(): + out[name] = normalize(target_path) + else: + out[name] = normalize(config_dir / target_path) + return out + + diff --git a/tools/slua_bundle/slua_bundle/py.typed b/tools/slua_bundle/slua_bundle/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/tools/slua_bundle/slua_bundle/resolver.py b/tools/slua_bundle/slua_bundle/resolver.py new file mode 100644 index 00000000..85745483 --- /dev/null +++ b/tools/slua_bundle/slua_bundle/resolver.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from .errors import BundleError + + +class BareIdentifierError(BundleError): + pass + + +class UnknownAliasError(BundleError): + pass + + +class RequireEscapesAliasError(BundleError): + pass + + +class RelativeRequireWithoutAnchorError(BundleError): + pass + + +class InvalidPathComponentError(BundleError): + pass + + +def _split_canonical(key: str) -> tuple[str, list[str]]: + assert key.startswith("@"), f"canonical key must start with @: {key}" + rest = key[1:] + if "/" in rest: + alias, tail = rest.split("/", 1) + return alias, tail.split("/") + return rest, [] + + +def _join_canonical(alias: str, parts: list[str]) -> str: + if not parts: + return f"@{alias}" + return f"@{alias}/" + "/".join(parts) + + +def _normalize(parts: list[str], context: str) -> list[str]: + out: list[str] = [] + for p in parts: + if p == "" or p == ".": + continue + if p == "..": + if not out: + raise RequireEscapesAliasError( + f"{context}: '..' traverses past alias root" + ) + out.pop() + else: + if p.startswith("."): + raise InvalidPathComponentError( + f"{context}: component {p!r} starts with '.' " + "(would resolve to a hidden file)" + ) + if "\x00" in p: + raise InvalidPathComponentError( + f"{context}: component contains NUL" + ) + out.append(p) + return out + + +def resolve(require_str: str, anchor_key: str | None, known_aliases: set[str]) -> str: + if require_str.startswith("@"): + rest = require_str[1:] + if "/" in rest: + alias, tail = rest.split("/", 1) + else: + alias, tail = rest, "" + + if alias == "self": + if anchor_key is None: + raise RelativeRequireWithoutAnchorError( + f"require '{require_str}' uses '@self' but the requiring module has no " + "anchor key (set 'main=' in the BUNDLE header for MAIN)" + ) + # Match Luau's RequireNavigator: @self resets to the requirer's + # *module path* and navigates from there. The module path is the + # file with its extension (and `init` suffix) stripped, which is + # exactly the canonical key. So @self/x from a requirer with key + # @p/lib/foo resolves to @p/lib/foo/x -- inside a subdirectory + # named after the file. That only points somewhere real for + # init.luau-style modules (whose canonical key already has no + # leaf); for leaf files @self is mostly useless. + anchor_alias, anchor_parts = _split_canonical(anchor_key) + tail_parts = tail.split("/") if tail else [] + new_parts = _normalize( + anchor_parts + tail_parts, + f"require '{require_str}' from {anchor_key}", + ) + return _join_canonical(anchor_alias, new_parts) + + if alias not in known_aliases: + raise UnknownAliasError( + f"unknown alias '@{alias}' in require '{require_str}'" + ) + tail_parts = tail.split("/") if tail else [] + new_parts = _normalize(tail_parts, f"require '{require_str}'") + return _join_canonical(alias, new_parts) + + if require_str.startswith("./") or require_str.startswith("../") or require_str in ( + ".", + "..", + ): + if anchor_key is None: + raise RelativeRequireWithoutAnchorError( + f"relative require '{require_str}' has no anchor " + "(set 'main=' in the BUNDLE header for MAIN)" + ) + anchor_alias, anchor_parts = _split_canonical(anchor_key) + anchor_dir_parts = anchor_parts[:-1] if anchor_parts else [] + rel_parts = require_str.split("/") + new_parts = _normalize( + anchor_dir_parts + rel_parts, + f"require '{require_str}' from {anchor_key}", + ) + return _join_canonical(anchor_alias, new_parts) + + raise BareIdentifierError( + f"require path must start with './', '../', or '@' (got '{require_str}')" + ) diff --git a/tools/slua_bundle/slua_bundle/runtime.py b/tools/slua_bundle/slua_bundle/runtime.py new file mode 100644 index 00000000..1560a4ea --- /dev/null +++ b/tools/slua_bundle/slua_bundle/runtime.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Iterator + +from .errors import BundleError +from .resolver import resolve + +SUPPORTED_VERSIONS = frozenset({1}) + +MARKER_RE = re.compile(r"^--\s*!!LUABUNDLE:(\S+)(?:\s+(.+?))?\s*$") +REQUIRE_RE = re.compile(r'\brequire\s*\(\s*"([^"]*)"\s*\)') + + +class BundleParseError(BundleError): + pass + + +class CircularDependencyError(BundleError): + pass + + +@dataclass +class ParsedBundle: + fields: dict[str, str] + main_source: str + modules: dict[str, str] + + +@dataclass(frozen=True) +class _Line: + lineno: int + raw: str + marker_kind: str | None + marker_arg: str + + @property + def is_marker(self) -> bool: + return self.marker_kind is not None + + +def _scan(text: str) -> Iterator[_Line]: + for lineno, raw in enumerate(text.splitlines(keepends=True), start=1): + stripped = raw.rstrip("\n").rstrip("\r") + m = MARKER_RE.match(stripped) + kind = m.group(1) if m else None + arg = (m.group(2) or "").strip() if m else "" + yield _Line(lineno=lineno, raw=raw, marker_kind=kind, marker_arg=arg) + + +class BundleParser: + def __init__(self, text: str) -> None: + self._lines = _scan(text) + self.fields: dict[str, str] = {} + self.main_source: str = "" + self.modules: dict[str, str] = {} + + def parse(self) -> ParsedBundle: + self._consume_version() + self._consume_header() + if "main" not in self.fields: + raise BundleParseError("missing MAIN directive") + self.main_source, trailing = self._consume_body() + self._consume_modules(trailing) + return ParsedBundle( + fields=self.fields, + main_source=self.main_source, + modules=self.modules, + ) + + def _consume_version(self) -> None: + try: + line = next(self._lines) + except StopIteration: + raise BundleParseError("empty bundle (missing VERSION)") from None + if not line.is_marker or line.marker_kind != "VERSION": + what = f"marker {line.marker_kind}" if line.is_marker else "body content" + raise BundleParseError( + f"line {line.lineno}: expected VERSION marker first, got {what}" + ) + try: + version = int(line.marker_arg) + except ValueError as e: + raise BundleParseError( + f"line {line.lineno}: VERSION arg must be an integer, got {line.marker_arg!r}" + ) from e + if version not in SUPPORTED_VERSIONS: + raise BundleParseError( + f"line {line.lineno}: unsupported VERSION {version} " + f"(supported: {sorted(SUPPORTED_VERSIONS)})" + ) + self.fields["version"] = str(version) + + def _consume_header(self) -> None: + for line in self._lines: + if not line.is_marker: + raise BundleParseError( + f"line {line.lineno}: body content before BODY marker" + ) + kind = line.marker_kind + if kind == "PROJECT": + self._set_unique(line, "project") + elif kind == "MAIN": + self._set_unique(line, "main") + elif kind == "BODY": + if line.marker_arg: + raise BundleParseError(f"line {line.lineno}: BODY takes no argument") + return + else: + raise BundleParseError( + f"line {line.lineno}: unknown header directive {kind} " + "(at this VERSION, only PROJECT, MAIN, BODY are valid)" + ) + raise BundleParseError("missing BODY marker") + + def _set_unique(self, line: _Line, field_name: str) -> None: + if not line.marker_arg: + raise BundleParseError( + f"line {line.lineno}: {line.marker_kind} requires an argument" + ) + if field_name in self.fields: + raise BundleParseError( + f"line {line.lineno}: duplicate {line.marker_kind} directive" + ) + self.fields[field_name] = line.marker_arg + + def _consume_body(self) -> tuple[str, _Line | None]: + """Consume non-marker lines until the next marker or EOF. + + Returns (body source, the marker that ended the body or None for EOF). + Body content is preserved verbatim -- no stripping -- so round-trip + through the bundler is stable. + """ + body: list[str] = [] + for line in self._lines: + if line.is_marker: + return "".join(body), line + body.append(line.raw) + return "".join(body), None + + def _consume_modules(self, first_marker: _Line | None) -> None: + current = first_marker + while current is not None: + if current.marker_kind != "MODULE": + raise BundleParseError( + f"line {current.lineno}: unexpected marker {current.marker_kind} after BODY " + "(only MODULE markers are valid)" + ) + key = current.marker_arg + if not key: + raise BundleParseError( + f"line {current.lineno}: MODULE requires a canonical key" + ) + if key in self.modules: + raise BundleParseError( + f"line {current.lineno}: duplicate MODULE marker for {key}" + ) + body, next_marker = self._consume_body() + self.modules[key] = body + current = next_marker + + +def parse_bundle(text: str) -> ParsedBundle: + return BundleParser(text).parse() + + +def simulate(text: str) -> dict[str, int]: + """ + Simulate a Luau VM running the bundle with `require()`-cache semantics. + + Starts at MAIN, recursively walks `require()` calls, and counts how + many times each module body would execute under correct caching -- + once on first require, zero on cache hits. The return value maps each + canonical key to its body-execution count; a correctly-bundled, + correctly-traversed program has every reachable key at exactly 1. + + Counts > 1 indicate a require-cache bug; missing keys indicate + unreachable (over-bundled) modules. Cycles raise CircularDependencyError. + + No Lua code is actually evaluated -- this is a static walk over the + require graph, comparing dedup behavior against the contract. + """ + parsed = parse_bundle(text) + main_anchor = parsed.fields["main"] # required by the parser + + all_modules = dict(parsed.modules) + if main_anchor in all_modules: + raise BundleParseError( + f"BUNDLE main={main_anchor} collides with a module key in the same bundle" + ) + all_modules[main_anchor] = parsed.main_source + + known_aliases: set[str] = {"root"} + for key in all_modules: + if not key.startswith("@"): + raise BundleParseError(f"module key does not start with @: {key}") + alias = key[1:].split("/", 1)[0] + known_aliases.add(alias) + + body_runs: dict[str, int] = {} + cached: set[str] = set() + in_progress: list[str] = [] + + def run(key: str) -> None: + if key in in_progress: + chain = " -> ".join([*in_progress, key]) + raise CircularDependencyError(f"circular dependency: {chain}") + if key in cached: + return + if key not in all_modules: + raise BundleParseError(f"required module {key} not in bundle") + in_progress.append(key) + for req in REQUIRE_RE.findall(all_modules[key]): + target = resolve(req, key, known_aliases) + run(target) + in_progress.pop() + body_runs[key] = body_runs.get(key, 0) + 1 + cached.add(key) + + run(main_anchor) + return body_runs diff --git a/tools/slua_bundle/tests/__init__.py b/tools/slua_bundle/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/slua_bundle/tests/_helpers.py b/tools/slua_bundle/tests/_helpers.py new file mode 100644 index 00000000..0046caba --- /dev/null +++ b/tools/slua_bundle/tests/_helpers.py @@ -0,0 +1,10 @@ +"""Shared test helpers.""" + +from __future__ import annotations + +import json + + +def luaurc(aliases: dict[str, str]) -> str: + """Render a minimal .luaurc body. Use raw strings for JSONC-shape tests.""" + return json.dumps({"aliases": aliases}) diff --git a/tools/slua_bundle/tests/test_bundler.py b/tools/slua_bundle/tests/test_bundler.py new file mode 100644 index 00000000..68b49107 --- /dev/null +++ b/tools/slua_bundle/tests/test_bundler.py @@ -0,0 +1,447 @@ +"""Bundle production from an FSBackend.""" + +import warnings +from pathlib import PurePosixPath + +import pytest + +from slua_bundle import ( + AliasCollisionWarning, + AmbiguousResolutionError, + DepthExceededError, + MarkerInjectionError, + MemoryFS, + ModuleCountExceededError, + NoResolverError, + ReservedAliasError, + bundle, + parse_bundle, +) +from slua_bundle.bundler import MAX_DEPTH, MAX_MODULES + +from ._helpers import luaurc + + +def test_bundle_emits_main_anchor_and_modules(): + vfs = MemoryFS.from_dict({ + "/project/src/HudController.luau": 'require("./lib/foo")', + "/project/src/lib/foo.luau": 'return "foo"', + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/HudController.luau"), + "myhud", + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/HudController +-- !!LUABUNDLE:BODY +require("./lib/foo") +-- !!LUABUNDLE:MODULE @root/lib/foo +return "foo" +""" + + +def test_bundle_alias_pointing_outside_project_root_is_fine(): + """An explicit alias to a directory outside project_root is honored. + + The user knows what they're declaring; the bundler maps the file under + the alias and ships it. This is what enables vendored deps that don't + live under project_root. + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"shared": "/elsewhere"}), + "/project/src/Main.luau": 'require("@shared/util")', + "/elsewhere/util.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/src/Main +-- !!LUABUNDLE:BODY +require("@shared/util") +-- !!LUABUNDLE:MODULE @shared/util +return {} +""" + + +def test_bundle_includes_wally_package(): + # Bundler reads only the project-root .luaurc. Wally writes its alias + # declarations there, which is sufficient for MAIN to resolve @SomeLib. + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), + "/project/src/Main.luau": 'require("@SomeLib/util")', + "/project/Packages/SomeLib/util.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/src/Main +-- !!LUABUNDLE:BODY +require("@SomeLib/util") +-- !!LUABUNDLE:MODULE @SomeLib/util +return {} +""" + + +def test_bundle_rejects_marker_injection_in_source(): + """Source files must not contain '-- !!LUABUNDLE:' markers; bundler must reject.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": 'require("./lib/evil")', + "/project/src/lib/evil.luau": "-- !!LUABUNDLE:MODULE!! @evil/injected\nreturn {}", + }) + with pytest.raises(MarkerInjectionError): + bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + + +def test_bundle_rejects_marker_injection_in_main(): + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": "-- !!LUABUNDLE:BUNDLE!! evil\nreturn {}", + }) + with pytest.raises(MarkerInjectionError): + bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + + +def test_bundle_rejects_ambiguous_resolution(): + """When both .luau and /init.luau exist, the require is ambiguous.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": 'require("./foo")', + "/project/src/foo.luau": 'return "leaf"', + "/project/src/foo/init.luau": 'return "dir"', + }) + with pytest.raises(AmbiguousResolutionError): + bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + + +def test_bundle_only_includes_files_reachable_from_main(): + """Files not transitively required by MAIN are not bundled.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": 'require("./foo")', + "/project/src/foo.luau": "return 1", + "/project/src/orphan.luau": 'return "should not appear"', + "/project/src/lib/sub.luau": 'return "also unused"', + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require("./foo") +-- !!LUABUNDLE:MODULE @root/foo +return 1 +""" + + +def test_bundle_traces_single_quoted_requires(): + """require('./foo') is valid Lua and must be picked up like the double-quoted form.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": "require('./foo')", + "/project/src/foo.luau": "return 1", + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require('./foo') +-- !!LUABUNDLE:MODULE @root/foo +return 1 +""" + + +def test_bundle_rejects_require_to_missing_file(): + """A require pointing at a nonexistent file with no fallback raises NoResolverError.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": 'require("./missing")', + }) + with pytest.raises(NoResolverError, match="no resolver produced source"): + bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + + +def test_bundle_round_trip_is_stable(): + """bundle -> parse -> compare; module bodies and main come back verbatim.""" + main_src = 'require("./lib/foo")\nreturn 1\n' + foo_src = 'return "foo"\n' + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": main_src, + "/project/src/lib/foo.luau": foo_src, + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + parsed = parse_bundle(text) + assert parsed.main_source == main_src + assert parsed.modules == {"@root/lib/foo": foo_src} + + +def test_bundle_round_trip_appends_missing_trailing_newline(): + """A source without a trailing newline gets exactly one appended -- once -- for marker alignment.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": "return 1", # no trailing \n + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + parsed = parse_bundle(text) + # Body now has the forced \n. Re-parse is then idempotent. + assert parsed.main_source == "return 1\n" + + +def test_bundle_without_project_name_omits_project_directive(): + """PROJECT is optional viewer-linkage metadata; bundling without it emits no PROJECT line.""" + vfs = MemoryFS.from_dict({ + "/project/src/Main.luau": 'require("./foo")', + "/project/src/foo.luau": "return 1", + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/Main.luau"), + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require("./foo") +-- !!LUABUNDLE:MODULE @root/foo +return 1 +""" + parsed = parse_bundle(text) + assert "project" not in parsed.fields + assert parsed.modules == {"@root/foo": "return 1\n"} + + +def test_bundle_rejects_reserved_root_alias_in_luaurc(): + """@root is built-in; .luaurc cannot redefine it.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"root": "/elsewhere"}), + "/project/src/Main.luau": "return 1", + }) + with pytest.raises(ReservedAliasError): + bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + + +def test_bundle_user_alias_at_project_root_loses_to_root_silently(): + """User-declared alias targeting project_root coexists with @root; @root wins canonicalization with no warning.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"myproj": "."}), + "/project/src/Main.luau": "return 1", + }) + with warnings.catch_warnings(): + warnings.simplefilter("error", AliasCollisionWarning) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert "-- !!LUABUNDLE:MAIN @root/src/Main" in text + assert "@myproj" not in text + + +def test_bundle_two_user_aliases_at_identical_target_warns(): + """Two non-root user aliases pointing at the exact same directory: ASCII-first wins, warning emitted.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"Zeta": "/elsewhere", "Alpha": "/elsewhere"}), + "/project/src/Main.luau": 'require("@Alpha/util")', + "/elsewhere/util.luau": "return {}", + }) + with pytest.warns(AliasCollisionWarning, match="Alpha"): + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + assert "-- !!LUABUNDLE:MODULE @Alpha/util" in text + assert "-- !!LUABUNDLE:MODULE @Zeta/util" not in text + + +# ---- Last-resort resolver (existing_bundle fallback) ------------------------ + + +_PRIOR_BUNDLE = """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require("./lib/foo") +-- !!LUABUNDLE:MODULE @root/lib/foo +return "from prior bundle" +""" + + +def test_existing_bundle_used_when_disk_source_missing(): + """Disk has MAIN but not its dependency; embedded copy fills the gap.""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("./lib/foo")', + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + existing_bundle=_PRIOR_BUNDLE, + ) + assert "-- !!LUABUNDLE:MODULE @root/lib/foo" in text + assert 'return "from prior bundle"' in text + + +def test_existing_bundle_used_when_alias_missing_from_luaurc(): + """Require references an alias the new env has no .luaurc entry for.""" + prior = """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require("@SomeLib/util") +-- !!LUABUNDLE:MODULE @SomeLib/util +return "vendored" +""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("@SomeLib/util")', + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + existing_bundle=prior, + ) + assert "-- !!LUABUNDLE:MODULE @SomeLib/util" in text + assert 'return "vendored"' in text + + +def test_disk_preferred_over_existing_bundle_when_both_present(): + """Disk version wins when both resolvers can produce source.""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("./lib/foo")', + "/project/lib/foo.luau": 'return "from disk"', + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + existing_bundle=_PRIOR_BUNDLE, + ) + assert 'return "from disk"' in text + assert 'return "from prior bundle"' not in text + + +def test_no_resolver_succeeded_raises(): + """Neither disk nor existing bundle has the required module.""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("./lib/missing")', + }) + with pytest.raises(NoResolverError, match="no resolver produced source"): + bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + existing_bundle=_PRIOR_BUNDLE, + ) + + +# ---- Depth + module-count limits -------------------------------------------- + + +def _chain_vfs(n: int) -> MemoryFS: + """MAIN -> m1 -> m2 -> ... -> m{n-1}, requires written as ./mK.""" + files: dict[str, str] = { + "/p/Main.luau": 'require("./m1")' if n > 0 else "return 1", + } + for i in range(1, n): + body = f'require("./m{i+1}")' if i < n - 1 else "return 1" + files[f"/p/m{i}.luau"] = body + return MemoryFS.from_dict(files) + + +def test_depth_at_limit_allowed(): + """A chain at exactly MAX_DEPTH depth bundles cleanly.""" + # MAIN at depth 0, deepest module at depth MAX_DEPTH. + vfs = _chain_vfs(MAX_DEPTH + 1) + text = bundle( + vfs, + PurePosixPath("/p"), + PurePosixPath("/p/Main.luau"), + ) + assert f"-- !!LUABUNDLE:MODULE @root/m{MAX_DEPTH}" in text + + +def test_depth_exceeded_raises(): + """A chain one step deeper than MAX_DEPTH trips the limit.""" + vfs = _chain_vfs(MAX_DEPTH + 2) + with pytest.raises(DepthExceededError, match=f"exceeds maximum {MAX_DEPTH}"): + bundle( + vfs, + PurePosixPath("/p"), + PurePosixPath("/p/Main.luau"), + ) + + +def test_module_count_exceeded_raises(): + """MAIN with > MAX_MODULES distinct dependencies trips the count limit.""" + requires = "\n".join(f'require("./m{i}")' for i in range(MAX_MODULES + 1)) + files: dict[str, str] = {"/p/Main.luau": requires} + for i in range(MAX_MODULES + 1): + files[f"/p/m{i}.luau"] = "return 1" + vfs = MemoryFS.from_dict(files) + with pytest.raises(ModuleCountExceededError, match=f"maximum of {MAX_MODULES}"): + bundle( + vfs, + PurePosixPath("/p"), + PurePosixPath("/p/Main.luau"), + ) diff --git a/tools/slua_bundle/tests/test_canonicalize.py b/tools/slua_bundle/tests/test_canonicalize.py new file mode 100644 index 00000000..804d4777 --- /dev/null +++ b/tools/slua_bundle/tests/test_canonicalize.py @@ -0,0 +1,191 @@ +"""Canonicalize physical paths to alias-prefixed canonical keys.""" + +import warnings +from pathlib import PurePosixPath + +import pytest + +from slua_bundle import ( + AliasCollisionWarning, + InvalidLuaurcError, + MemoryFS, + NoCoveringAliasError, + ReservedAliasError, + canonicalize, +) + +from ._helpers import luaurc + + +def test_implicit_root_alias_no_luaurc(): + """No .luaurc -- @root implicitly covers project_root.""" + vfs = MemoryFS.from_dict({ + "/project/src/HudController.luau": "return {}", + "/project/src/lib/foo.luau": "return {}", + }) + project_root = PurePosixPath("/project/src") + assert canonicalize(vfs, PurePosixPath("/project/src/HudController.luau"), project_root) == "@root/HudController" + assert canonicalize(vfs, PurePosixPath("/project/src/lib/foo.luau"), project_root) == "@root/lib/foo" + + +def test_init_luau_strips_to_directory(): + vfs = MemoryFS.from_dict({ + "/project/src/init.luau": "", + "/project/src/lib/init.luau": "", + "/project/src/lib/foo.luau": "", + }) + project_root = PurePosixPath("/project/src") + assert canonicalize(vfs, PurePosixPath("/project/src/init.luau"), project_root) == "@root" + assert canonicalize(vfs, PurePosixPath("/project/src/lib/init.luau"), project_root) == "@root/lib" + + +def test_wally_alias_at_project_root_beats_root(): + """Top-level .luaurc declares @SomeLib; files inside it canonicalize under @SomeLib. + + Most-specific covering alias wins (more path parts), so files under + /project/Packages/SomeLib/ get @SomeLib/... rather than @root/Packages/SomeLib/... + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), + "/project/Packages/SomeLib/init.luau": "", + "/project/Packages/SomeLib/util.luau": "", + "/project/src/HudController.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/util.luau"), project_root) == "@SomeLib/util" + assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/init.luau"), project_root) == "@SomeLib" + assert canonicalize(vfs, PurePosixPath("/project/src/HudController.luau"), project_root) == "@root/src/HudController" + + +def test_nested_luaurc_is_ignored(): + """Aliases declared in a nested .luaurc are not honored. + + Bundle reproducibility requires the alias set to be fully determined by + project_root's .luaurc; a child .luaurc that isn't mirrored at the project + root has no effect, and a file it would have named is canonicalized under + @root instead. + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({}), + "/project/Packages/SomeLib/.luaurc": luaurc({"SomeLib": "."}), + "/project/Packages/SomeLib/util.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/util.luau"), project_root) == "@root/Packages/SomeLib/util" + + +def test_no_covering_alias_for_outside_path(): + """A file outside the project root with no covering .luaurc raises.""" + vfs = MemoryFS.from_dict({ + "/project/src/HudController.luau": "", + "/elsewhere/orphan.luau": "", + }) + project_root = PurePosixPath("/project/src") + with pytest.raises(NoCoveringAliasError): + canonicalize(vfs, PurePosixPath("/elsewhere/orphan.luau"), project_root) + + +def test_external_alias_with_explicit_target(): + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"shared": "shared-modules"}), + "/project/shared-modules/util.luau": "", + "/project/src/main.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@shared/util" + + +def test_jsonc_line_comments_and_trailing_commas_supported(): + """Match Luau's actual .luaurc parser: // line comments and trailing commas.""" + config = """ + { + // Wally-style comment + "aliases": { + "shared": "shared-modules", // trailing comment after value + }, + } + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/shared-modules/util.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@shared/util" + + +def test_jsonc_block_comments_rejected(): + """Luau's parser does not accept /* */; the prototype mirrors that.""" + config = '{"aliases": {/* not allowed */ "shared": "shared-modules"}}' + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/shared-modules/util.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.raises(InvalidLuaurcError, match="invalid JSONC"): + canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) + + +def test_reserved_root_alias_in_luaurc_rejected(): + """.luaurc may not declare an alias named `root`; it's the built-in project-root alias.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"root": "shared-modules"}), + "/project/Main.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.raises(ReservedAliasError): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) + + +def test_reserved_self_alias_in_luaurc_rejected(): + """.luaurc may not declare `self`; it's a Luau-ecosystem reserved name (current module's dir).""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"self": "shared-modules"}), + "/project/Main.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.raises(ReservedAliasError): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) + + +def test_user_alias_at_project_root_loses_to_root_silently(): + """User declares an alias targeting project_root; @root wins canonicalization, no warning.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"myhud": "."}), + "/project/Main.luau": "", + }) + project_root = PurePosixPath("/project") + with warnings.catch_warnings(): + warnings.simplefilter("error", AliasCollisionWarning) + assert canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) == "@root/Main" + + +def test_two_user_aliases_at_identical_target_warns_and_picks_ascii_first(): + """Two non-root aliases at the same target dir: ASCII-first wins, warning emitted.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"Zeta": "/elsewhere", "Alpha": "/elsewhere"}), + "/elsewhere/util.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.warns(AliasCollisionWarning, match="Alpha"): + assert canonicalize(vfs, PurePosixPath("/elsewhere/util.luau"), project_root) == "@Alpha/util" + + +def test_unnormalized_project_root_normalizes(): + """A caller-supplied project_root with `.` segments must normalize before lookups.""" + vfs = MemoryFS.from_dict({ + "/project/src/.luaurc": luaurc({}), + "/project/src/Main.luau": "", + }) + project_root = PurePosixPath("/project/./src") + assert canonicalize(vfs, PurePosixPath("/project/src/Main.luau"), project_root) == "@root/Main" + + +def test_jsonc_does_not_strip_inside_strings(): + """Comment-like sequences inside string literals must be preserved.""" + config = '{"aliases": {"path-with-slashes": "weird//name"}}' + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/weird/name/foo.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/weird/name/foo.luau"), project_root) == "@path-with-slashes/foo" diff --git a/tools/slua_bundle/tests/test_disk_fs.py b/tools/slua_bundle/tests/test_disk_fs.py new file mode 100644 index 00000000..fde6dfe7 --- /dev/null +++ b/tools/slua_bundle/tests/test_disk_fs.py @@ -0,0 +1,56 @@ +"""DiskFS: real-FS backend smoke tests against pytest's tmp_path.""" + +import pathlib + +from slua_bundle import DiskFS, bundle + + +def _write(root: pathlib.Path, rel: str, content: str) -> pathlib.Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(content) + return p + + +def test_bundle_smoke(tmp_path: pathlib.Path): + """End-to-end against real disk: bundle a small project. Full-text + equality also enforces the cross-platform contract that bundle keys are + POSIX-shaped on every host (the expected text uses forward slashes).""" + _write(tmp_path, "src/Main.luau", 'require("./lib/foo")') + _write(tmp_path, "src/lib/foo.luau", 'require("./bar")') + _write(tmp_path, "src/lib/bar.luau", "return 1") + + fs = DiskFS(tmp_path) + project_root = (tmp_path / "src").resolve() + text = bundle(fs, project_root, project_root / "Main.luau", "myhud") + + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:PROJECT myhud +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:BODY +require("./lib/foo") +-- !!LUABUNDLE:MODULE @root/lib/foo +require("./bar") +-- !!LUABUNDLE:MODULE @root/lib/bar +return 1 +""" + + +def test_iter_files_skips_dotfiles_except_luaurc(tmp_path: pathlib.Path): + """Hidden files (other than .luaurc) and hidden directories are pruned.""" + _write(tmp_path, "src/Main.luau", "return 1") + _write(tmp_path, ".luaurc", "{}") # kept + _write(tmp_path, ".env", "SECRET=1") # skipped + _write(tmp_path, ".cache/index", "stale") # whole dir pruned + _write(tmp_path, ".cache/blobs/blob", "binary-junk") + _write(tmp_path, "node_modules/.hidden/foo.luau", "x") # outer dir kept, inner pruned + _write(tmp_path, "node_modules/visible.luau", "y") # kept (not hidden) + + fs = DiskFS(tmp_path) + rels = sorted(str(p.relative_to(tmp_path)) for p in fs.iter_files()) + assert rels == [ + ".luaurc", + "node_modules/visible.luau", + "src/Main.luau", + ] diff --git a/tools/slua_bundle/tests/test_e2e.py b/tools/slua_bundle/tests/test_e2e.py new file mode 100644 index 00000000..f9ba957e --- /dev/null +++ b/tools/slua_bundle/tests/test_e2e.py @@ -0,0 +1,103 @@ +"""End-to-end: FSBackend -> bundle -> simulate.""" + +from pathlib import PurePosixPath + +from slua_bundle import MemoryFS, bundle, parse_bundle, simulate +from slua_bundle.extractor import extract_to_dir + +from ._helpers import luaurc + + +def test_happy_path(): + """MAIN -> ./lib/foo -> ./bar -> return.""" + vfs = MemoryFS.from_dict({ + "/project/src/HudController.luau": 'require("./lib/foo")', + "/project/src/lib/foo.luau": 'require("./bar")', + "/project/src/lib/bar.luau": "return 42", + }) + text = bundle( + vfs, + PurePosixPath("/project/src"), + PurePosixPath("/project/src/HudController.luau"), + "myhud", + ) + body_runs = simulate(text) + assert body_runs == { + "@root/HudController": 1, + "@root/lib/foo": 1, + "@root/lib/bar": 1, + } + + +def test_bare_project_no_luaurc(): + """@root covers project_root automatically; no .luaurc needed.""" + vfs = MemoryFS.from_dict({ + "/work/src/Main.luau": 'require("./lib/foo")', + "/work/src/lib/foo.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/work/src"), + PurePosixPath("/work/src/Main.luau"), + "myhud", + ) + assert "-- !!LUABUNDLE:MAIN @root/Main" in text + body_runs = simulate(text) + assert body_runs["@root/Main"] == 1 + assert body_runs["@root/lib/foo"] == 1 + + +def test_wally_style_dedup(): + """SomeLib's util reached two ways from its init.luau (@self/util and @SomeLib/util) dedups. + + The project-root .luaurc declares @SomeLib; that's the only config the + bundler reads. Inside SomeLib/init.luau, both @self/util (resolves under + init.luau's anchor module path) and @SomeLib/util reach the same file. + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), + "/project/src/Main.luau": 'require("@SomeLib")', + "/project/Packages/SomeLib/init.luau": 'require("@self/util"); require("@SomeLib/util")', + "/project/Packages/SomeLib/util.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + "myhud", + ) + body_runs = simulate(text) + assert body_runs["@SomeLib/util"] == 1 + assert body_runs["@SomeLib"] == 1 + assert body_runs["@root/src/Main"] == 1 + + +def test_rebundle_via_extractor_uses_last_resort_resolver(): + """Extract a bundle, drop a source file, rebundle with the original as + fallback. Demonstrates the RFC's rebundle-without-source flow: a CLI + receives a bundle, can't reach the original resolvers, but can still + rebuild because the prior bundle is the universal last-resort resolver. + """ + src_vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("./lib/foo")', + "/project/lib/foo.luau": 'return "hi"', + }) + initial = bundle( + src_vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + "myhud", + ) + + out_fs = MemoryFS() + extract_to_dir(parse_bundle(initial), out_fs, PurePosixPath("/out")) + del out_fs.files[PurePosixPath("/out/lib/foo.luau")] + + rebuilt = bundle( + out_fs, + PurePosixPath("/out"), + PurePosixPath("/out/Main.luau"), + "myhud", + existing_bundle=initial, + ) + assert rebuilt == initial diff --git a/tools/slua_bundle/tests/test_extractor.py b/tools/slua_bundle/tests/test_extractor.py new file mode 100644 index 00000000..644c599e --- /dev/null +++ b/tools/slua_bundle/tests/test_extractor.py @@ -0,0 +1,201 @@ +"""Reverse-engineer a bundle into a self-contained project tree.""" + +import json +from pathlib import PurePosixPath + +import pytest + +from slua_bundle import ( + MemoryFS, + bundle, + parse_bundle, +) +from slua_bundle.extractor import ( + ExtractClobberError, + ExtractCollisionError, + ExtractMissingMainError, + ExtractUnsafeKeyError, + extract_to_dir, + physical_path_for_key, +) +from slua_bundle.runtime import ParsedBundle + +OUT = PurePosixPath("/out") + + +@pytest.fixture +def fs() -> MemoryFS: + return MemoryFS() + + +def test_physical_path_root_alias_with_path(): + assert physical_path_for_key("@root/Main", OUT) == PurePosixPath("/out/Main.luau") + assert physical_path_for_key("@root/lib/foo", OUT) == PurePosixPath("/out/lib/foo.luau") + + +def test_physical_path_root_alias_bare(): + assert physical_path_for_key("@root", OUT) == PurePosixPath("/out/init.luau") + + +def test_physical_path_other_alias_with_path(): + assert physical_path_for_key("@SomeLib/util", OUT) == PurePosixPath("/out/SomeLib/util.luau") + + +def test_physical_path_other_alias_bare(): + assert physical_path_for_key("@SomeLib", OUT) == PurePosixPath("/out/SomeLib/init.luau") + + +def test_extract_writes_luaurc_for_external_aliases(fs: MemoryFS): + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source='require("@SomeLib/util")\n', + modules={"@SomeLib/util": "return {}\n"}, + ) + extract_to_dir(parsed, fs, OUT) + assert set(fs.iter_files()) == { + PurePosixPath("/out/Main.luau"), + PurePosixPath("/out/SomeLib/util.luau"), + PurePosixPath("/out/.luaurc"), + } + luaurc = json.loads(fs.read(OUT / ".luaurc")) + assert luaurc == {"aliases": {"SomeLib": "SomeLib"}} + + +def test_extract_no_luaurc_when_only_project_alias(fs: MemoryFS): + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source='require("./lib/foo")\n', + modules={"@root/lib/foo": "return 1\n"}, + ) + extract_to_dir(parsed, fs, OUT) + assert set(fs.iter_files()) == { + PurePosixPath("/out/Main.luau"), + PurePosixPath("/out/lib/foo.luau"), + } + + +def test_extract_places_modules_at_expected_paths(fs: MemoryFS): + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source="MAIN\n", + modules={ + "@root/lib/foo": "FOO\n", + "@SomeLib/util": "UTIL\n", + "@SomeLib": "SOMELIB_INIT\n", + }, + ) + extract_to_dir(parsed, fs, OUT) + assert {p: fs.read(p) for p in fs.iter_files() if p.name != ".luaurc"} == { + PurePosixPath("/out/Main.luau"): "MAIN\n", + PurePosixPath("/out/lib/foo.luau"): "FOO\n", + PurePosixPath("/out/SomeLib/util.luau"): "UTIL\n", + PurePosixPath("/out/SomeLib/init.luau"): "SOMELIB_INIT\n", + } + + +def test_extract_round_trips_in_memory(): + src = MemoryFS.from_dict({ + "/proj/Main.luau": 'require("./lib/foo")\nreturn 1\n', + "/proj/lib/foo.luau": 'return "foo"\n', + }) + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/Main.luau"), "myhud") + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/extracted")) + + rebuilt = bundle(out, PurePosixPath("/extracted"), PurePosixPath("/extracted/Main.luau"), "myhud") + assert rebuilt == original + + +def test_extract_round_trips_with_external_alias_in_memory(): + src = MemoryFS.from_dict({ + "/proj/.luaurc": json.dumps({"aliases": {"SomeLib": "Packages/SomeLib"}}), + "/proj/Main.luau": 'require("@SomeLib/util")\n', + "/proj/Packages/SomeLib/util.luau": 'return "u"\n', + }) + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/Main.luau"), "myhud") + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/extracted")) + assert out.is_file(PurePosixPath("/extracted/SomeLib/util.luau")) + assert out.is_file(PurePosixPath("/extracted/.luaurc")) + + rebuilt = bundle(out, PurePosixPath("/extracted"), PurePosixPath("/extracted/Main.luau"), "myhud") + assert rebuilt == original + + +def test_extract_refuses_collision(fs: MemoryFS): + """Bare alias key and explicit `init` leaf both target init.luau.""" + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source="\n", + modules={ + "@root": "return {}\n", # -> out/init.luau + "@root/init": "return {}\n", # -> out/init.luau (collision) + }, + ) + with pytest.raises(ExtractCollisionError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_refuses_to_clobber(): + fs = MemoryFS.from_dict({"/out/Main.luau": "DO NOT TOUCH\n"}) + before = set(fs.iter_files()) + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source='return 1\n', + modules={"@root/lib/foo": "FOO\n"}, + ) + with pytest.raises(ExtractClobberError): + extract_to_dir(parsed, fs, OUT) + assert set(fs.iter_files()) == before + assert fs.read(OUT / "Main.luau") == "DO NOT TOUCH\n" + + +def test_extract_rejects_traversal_in_module_key(fs: MemoryFS): + """A hand-crafted bundle with `..` components in a key must not write + outside . Our bundler never emits these; this defends against + malicious or corrupted bundles.""" + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source="\n", + modules={"@root/../etc/passwd": "owned\n"}, + ) + with pytest.raises(ExtractUnsafeKeyError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_rejects_traversal_in_main_key(fs: MemoryFS): + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/../escape"}, + main_source="\n", + modules={}, + ) + with pytest.raises(ExtractUnsafeKeyError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_rejects_empty_component(fs: MemoryFS): + """`@root//foo` produces an empty middle component.""" + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud", "main": "@root/Main"}, + main_source="\n", + modules={"@root//foo": "x\n"}, + ) + with pytest.raises(ExtractUnsafeKeyError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_errors_when_main_directive_missing(fs: MemoryFS): + parsed = ParsedBundle( + fields={"version": "1", "project": "myhud"}, + main_source="\n", + modules={}, + ) + with pytest.raises(ExtractMissingMainError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] diff --git a/tools/slua_bundle/tests/test_memory_fs.py b/tools/slua_bundle/tests/test_memory_fs.py new file mode 100644 index 00000000..7f3f059c --- /dev/null +++ b/tools/slua_bundle/tests/test_memory_fs.py @@ -0,0 +1,49 @@ +"""MemoryFS path normalization at construction and lookup.""" + +from pathlib import PurePosixPath + +import pytest + +from slua_bundle import MemoryFS + + +def test_from_dict_normalizes_keys(): + vfs = MemoryFS.from_dict({"/a/../b/foo.luau": "hello"}) + assert PurePosixPath("/b/foo.luau") in vfs.files + assert PurePosixPath("/a/../b/foo.luau") not in vfs.files + + +def test_read_normalizes_lookup(): + vfs = MemoryFS.from_dict({"/b/foo.luau": "hello"}) + assert vfs.read(PurePosixPath("/c/../b/foo.luau")) == "hello" + assert vfs.read(PurePosixPath("/b/./foo.luau")) == "hello" + + +def test_is_file_normalizes(): + vfs = MemoryFS.from_dict({"/x/y.luau": "."}) + assert vfs.is_file(PurePosixPath("/x/./y.luau")) + assert not vfs.is_file(PurePosixPath("/x/missing.luau")) + + +def test_is_dir_implicit_from_children(): + vfs = MemoryFS.from_dict({"/proj/src/main.luau": "."}) + assert vfs.is_dir(PurePosixPath("/proj")) + assert vfs.is_dir(PurePosixPath("/proj/src")) + assert vfs.is_dir(PurePosixPath("/proj/./src")) + assert not vfs.is_dir(PurePosixPath("/proj/src/main.luau")) + assert not vfs.is_dir(PurePosixPath("/missing")) + + +def test_read_missing_raises(): + vfs = MemoryFS.from_dict({"/a.luau": "."}) + with pytest.raises(FileNotFoundError): + vfs.read(PurePosixPath("/b.luau")) + + +def test_string_inputs_accepted(): + """All public MemoryFS methods accept either a string or a PurePosixPath.""" + vfs = MemoryFS.from_dict({"/proj/src/main.luau": "hello"}) + assert vfs.is_file("/proj/src/main.luau") + assert vfs.is_dir("/proj/src") + assert vfs.read("/proj/src/main.luau") == "hello" + assert vfs.read("/proj/./src/../src/main.luau") == "hello" diff --git a/tools/slua_bundle/tests/test_resolver.py b/tools/slua_bundle/tests/test_resolver.py new file mode 100644 index 00000000..8f1c01ba --- /dev/null +++ b/tools/slua_bundle/tests/test_resolver.py @@ -0,0 +1,106 @@ +"""Lexical resolution of require strings to canonical keys.""" + +import pytest + +from slua_bundle import ( + BareIdentifierError, + InvalidPathComponentError, + RelativeRequireWithoutAnchorError, + RequireEscapesAliasError, + UnknownAliasError, + resolve, +) + +KNOWN = {"myhud", "SomeLib"} + + +def test_absolute_alias_passthrough(): + assert resolve("@myhud/lib/foo", "@myhud/Main", KNOWN) == "@myhud/lib/foo" + + +def test_self_from_init_module_resolves_to_alias_root(): + """@self/x from an init.luau-style anchor (no leaf in the key) lands at the alias root. + This is the realistic / useful case -- @self in an init.luau is sibling-like. + """ + assert resolve("@self/lib/foo", "@myhud", KNOWN) == "@myhud/lib/foo" + + +def test_self_from_alias_root_with_no_leaf(): + """Same as above with a different alias -- explicit pin on Luau's behavior.""" + assert resolve("@self/util", "@SomeLib", KNOWN) == "@SomeLib/util" + + +def test_self_from_leaf_includes_filename_in_path(): + """@self/x from a leaf file resolves *under* a subdir named after the file + (extension stripped). This matches Luau's RequireNavigator semantics; in + practice the subdir rarely exists, so @self in a leaf is rarely useful. + """ + assert resolve("@self/lib/foo", "@myhud/HudController", KNOWN) == "@myhud/HudController/lib/foo" + assert resolve("@self/sibling", "@myhud/lib/bar", KNOWN) == "@myhud/lib/bar/sibling" + + +def test_dot_relative_resolves_to_anchor_dir(): + assert resolve("./bar", "@myhud/lib/foo", KNOWN) == "@myhud/lib/bar" + + +def test_dotdot_relative_pops_one_level(): + assert resolve("../sibling", "@myhud/lib/foo", KNOWN) == "@myhud/sibling" + + +def test_dot_relative_collapses_dot_segments(): + assert resolve("./a/./b/../c", "@myhud/lib/foo", KNOWN) == "@myhud/lib/a/c" + + +def test_bare_identifier_rejected(): + with pytest.raises(BareIdentifierError): + resolve("foo", "@myhud/Main", KNOWN) + + +def test_unknown_alias_rejected(): + with pytest.raises(UnknownAliasError): + resolve("@nope/x", "@myhud/Main", KNOWN) + + +def test_escape_via_dotdot_inside_absolute_alias(): + with pytest.raises(RequireEscapesAliasError): + resolve("@myhud/../escape", "@myhud/Main", KNOWN) + + +def test_escape_via_dotdot_in_relative(): + with pytest.raises(RequireEscapesAliasError): + resolve("../../escape", "@myhud/lib/foo", KNOWN) + + +def test_escape_via_self_dotdot(): + # Need three `..` to escape past @myhud/lib/foo's three components. + with pytest.raises(RequireEscapesAliasError): + resolve("@self/../../../escape", "@myhud/lib/foo", KNOWN) + + +def test_relative_without_anchor_rejected(): + """MAIN without main= cannot use relative requires.""" + with pytest.raises(RelativeRequireWithoutAnchorError): + resolve("./x", None, KNOWN) + + +def test_self_without_anchor_rejected(): + with pytest.raises(RelativeRequireWithoutAnchorError): + resolve("@self/x", None, KNOWN) + + +def test_absolute_alias_works_without_anchor(): + """MAIN without main= can still use absolute aliases.""" + assert resolve("@myhud/lib/foo", None, KNOWN) == "@myhud/lib/foo" + + +def test_path_component_starting_with_dot_rejected(): + """A component like '.bashrc' would resolve to a hidden file -- reject.""" + with pytest.raises(InvalidPathComponentError): + resolve("@myhud/.bashrc", "@myhud/Main", KNOWN) + with pytest.raises(InvalidPathComponentError): + resolve("./.config/foo", "@myhud/lib/x", KNOWN) + + +def test_path_component_with_nul_rejected(): + with pytest.raises(InvalidPathComponentError): + resolve("@myhud/lib/foo\x00bar", "@myhud/Main", KNOWN) diff --git a/tools/slua_bundle/tests/test_runtime.py b/tools/slua_bundle/tests/test_runtime.py new file mode 100644 index 00000000..04b70a22 --- /dev/null +++ b/tools/slua_bundle/tests/test_runtime.py @@ -0,0 +1,227 @@ +"""Bundle parsing and execution-graph traversal.""" + +from __future__ import annotations + +import pytest + +from slua_bundle import ( + BareIdentifierError, + BundleParseError, + CircularDependencyError, + UnknownAliasError, + parse_bundle, + simulate, +) + + +def _make(*lines: str) -> str: + return "\n".join(lines) + "\n" + + +def _v1_header(project: str | None = "myhud", main: str | None = None) -> tuple[str, ...]: + parts = ["-- !!LUABUNDLE:VERSION 1"] + if project is not None: + parts.append(f"-- !!LUABUNDLE:PROJECT {project}") + if main is not None: + parts.append(f"-- !!LUABUNDLE:MAIN {main}") + parts.append("-- !!LUABUNDLE:BODY") + return tuple(parts) + + +def test_parse_bundle_extracts_fields_and_modules(): + text = _make( + *_v1_header(project="myhud", main="@myhud/Main"), + "main body line 1", + "-- !!LUABUNDLE:MODULE @myhud/lib/foo", + "foo body", + ) + parsed = parse_bundle(text) + assert parsed.fields == {"version": "1", "project": "myhud", "main": "@myhud/Main"} + assert parsed.main_source == "main body line 1\n" + assert parsed.modules == {"@myhud/lib/foo": "foo body\n"} + + +def test_parse_bundle_rejects_missing_version(): + text = _make( + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:BODY", + "main body", + ) + with pytest.raises(BundleParseError, match="VERSION"): + parse_bundle(text) + + +def test_parse_bundle_rejects_unsupported_version(): + text = _make( + "-- !!LUABUNDLE:VERSION 99", + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:BODY", + ) + with pytest.raises(BundleParseError, match="unsupported VERSION"): + parse_bundle(text) + + +def test_parse_bundle_rejects_duplicate_project(): + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:PROJECT otherhud", + "-- !!LUABUNDLE:BODY", + ) + with pytest.raises(BundleParseError, match="duplicate PROJECT"): + parse_bundle(text) + + +def test_parse_bundle_rejects_duplicate_main(): + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:MAIN @myhud/A", + "-- !!LUABUNDLE:MAIN @myhud/B", + "-- !!LUABUNDLE:BODY", + ) + with pytest.raises(BundleParseError, match="duplicate MAIN"): + parse_bundle(text) + + +def test_parse_bundle_rejects_unknown_header_directive(): + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:FUTURE-THING something", + "-- !!LUABUNDLE:BODY", + "main body", + ) + with pytest.raises(BundleParseError, match="unknown header directive"): + parse_bundle(text) + + +def test_parse_bundle_rejects_missing_body_marker(): + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + ) + with pytest.raises(BundleParseError, match="missing BODY"): + parse_bundle(text) + + +def test_parse_bundle_accepts_missing_project(): + """PROJECT is optional (advisory viewer-linkage metadata).""" + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:MAIN @root/X", + "-- !!LUABUNDLE:BODY", + "main body", + ) + parsed = parse_bundle(text) + assert "project" not in parsed.fields + assert parsed.main_source == "main body\n" + + +def test_parse_bundle_rejects_missing_main(): + """MAIN is required; consumers reject bundles without it.""" + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + "-- !!LUABUNDLE:BODY", + "main body", + ) + with pytest.raises(BundleParseError, match="missing MAIN"): + parse_bundle(text) + + +def test_parse_bundle_rejects_unexpected_marker_after_body(): + text = _make( + *_v1_header(main="@myhud/Main"), + "main body", + "-- !!LUABUNDLE:FUTURE-THING something", + "-- !!LUABUNDLE:MODULE @myhud/x", + "x body", + ) + with pytest.raises(BundleParseError, match="unexpected marker"): + parse_bundle(text) + + +def test_parse_bundle_rejects_body_before_body_marker(): + text = _make( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:PROJECT myhud", + "stray body line", + "-- !!LUABUNDLE:BODY", + ) + with pytest.raises(BundleParseError, match="body content before BODY"): + parse_bundle(text) + + +def test_simulate_runs_each_module_body_once_for_dedup(): + """Dedup via canonical key. MAIN is init.luau-style (canonical @myhud, no leaf) + so @self/x is sibling-like; bar uses ./foo to reach the same module.""" + text = _make( + *_v1_header(project="myhud", main="@myhud"), + 'require("@self/lib/foo"); require("@self/lib/bar")', + "-- !!LUABUNDLE:MODULE @myhud/lib/foo", + 'return "foo"', + "-- !!LUABUNDLE:MODULE @myhud/lib/bar", + 'require("./foo"); return "bar"', + ) + body_runs = simulate(text) + assert body_runs["@myhud/lib/foo"] == 1 + assert body_runs["@myhud/lib/bar"] == 1 + assert body_runs["@myhud"] == 1 + + +def test_simulate_circular_dependency_detected(): + text = _make( + *_v1_header(project="p", main="@p/Main"), + 'require("@p/A")', + "-- !!LUABUNDLE:MODULE @p/A", + 'require("@p/B")', + "-- !!LUABUNDLE:MODULE @p/B", + 'require("@p/A")', + ) + with pytest.raises(CircularDependencyError) as excinfo: + simulate(text) + msg = str(excinfo.value) + assert "@p/A" in msg and "@p/B" in msg + + +def test_simulate_main_as_import_target_is_a_cycle(): + """A module requiring MAIN's canonical key creates a cycle.""" + text = _make( + *_v1_header(project="p", main="@p/Main"), + 'require("@p/A")', + "-- !!LUABUNDLE:MODULE @p/A", + 'require("@p/Main")', + ) + with pytest.raises(CircularDependencyError): + simulate(text) + + +def test_simulate_main_with_anchor_accepts_relative_and_absolute(): + """init.luau-style MAIN: @self/x and ./x both land at the alias root and dedup.""" + text = _make( + *_v1_header(project="p", main="@p"), + 'require("@self/lib/foo"); require("./lib/foo")', + "-- !!LUABUNDLE:MODULE @p/lib/foo", + 'return "foo"', + ) + body_runs = simulate(text) + assert body_runs["@p/lib/foo"] == 1 + + +def test_simulate_bare_identifier_rejected(): + text = _make( + *_v1_header(project="p", main="@p/Main"), + 'require("foo")', + ) + with pytest.raises(BareIdentifierError): + simulate(text) + + +def test_simulate_unknown_alias_rejected(): + text = _make( + *_v1_header(project="p", main="@p/Main"), + 'require("@nope/x")', + ) + with pytest.raises(UnknownAliasError): + simulate(text) From 210fe3b3f08aa739677e4688ac0242ef780ff83d Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:02:37 -0700 Subject: [PATCH 3/7] Appease the linter --- tools/slua_bundle/slua_bundle/canonicalize.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tools/slua_bundle/slua_bundle/canonicalize.py b/tools/slua_bundle/slua_bundle/canonicalize.py index c229ae84..544b29b7 100644 --- a/tools/slua_bundle/slua_bundle/canonicalize.py +++ b/tools/slua_bundle/slua_bundle/canonicalize.py @@ -7,7 +7,6 @@ from .fs import FSBackend, normalize from .luaurc import load_config - RESERVED_ALIASES = frozenset({"root", "self"}) From 242812c58ae0280e23c2f757099d654d86c678c1 Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:08:10 -0700 Subject: [PATCH 4/7] Fix GH mermaid rendering --- rfcs/static-require-bundle.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rfcs/static-require-bundle.md b/rfcs/static-require-bundle.md index 4d7b1bf3..848a54eb 100644 --- a/rfcs/static-require-bundle.md +++ b/rfcs/static-require-bundle.md @@ -63,8 +63,8 @@ in emitted bytecode so runtime lookup matches that key directly. flowchart TD Start[require S from module R] Start --> Kind{S starts with} - Kind -->|@alias/...| Direct[Canonical key = S] - Kind -->|./, ../, @self/| Resolve[Resolve against R's location -> physical path L] + Kind -->|"@alias/..."| Direct[Canonical key = S] + Kind -->|"./, ../, @self/"| Resolve[Resolve against R's location -> physical path L] Resolve --> FindAlias{Most-specific covering alias for L} FindAlias -->|Found| Build["Canonical key = @alias/{L relative to target}"] FindAlias -->|None| NoAliasErr["Error: no alias spans L (shouldn't happen: @root always covers project_root)"] From d8547b409b55f2194a57098ba95c0399af0c685c Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:31:53 -0700 Subject: [PATCH 5/7] Add some tests --- tools/slua_bundle/tests/test_bundler.py | 14 ++++++ tools/slua_bundle/tests/test_e2e.py | 61 ++++++++++++++++++++++++- tools/slua_bundle/tests/test_runtime.py | 12 +++++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/tools/slua_bundle/tests/test_bundler.py b/tools/slua_bundle/tests/test_bundler.py index 68b49107..af69e4bd 100644 --- a/tools/slua_bundle/tests/test_bundler.py +++ b/tools/slua_bundle/tests/test_bundler.py @@ -432,6 +432,20 @@ def test_depth_exceeded_raises(): ) +def test_parenless_require_sugar_is_traced(): + """require "./lib/foo" (valid Luau call sugar) pulls the module in.""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require "./lib/foo"', + "/project/lib/foo.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + ) + assert "@root/lib/foo" in parse_bundle(text).modules + + def test_module_count_exceeded_raises(): """MAIN with > MAX_MODULES distinct dependencies trips the count limit.""" requires = "\n".join(f'require("./m{i}")' for i in range(MAX_MODULES + 1)) diff --git a/tools/slua_bundle/tests/test_e2e.py b/tools/slua_bundle/tests/test_e2e.py index f9ba957e..8f6c0e4c 100644 --- a/tools/slua_bundle/tests/test_e2e.py +++ b/tools/slua_bundle/tests/test_e2e.py @@ -2,7 +2,9 @@ from pathlib import PurePosixPath -from slua_bundle import MemoryFS, bundle, parse_bundle, simulate +import pytest + +from slua_bundle import AliasCollisionWarning, MemoryFS, bundle, parse_bundle, simulate from slua_bundle.extractor import extract_to_dir from ._helpers import luaurc @@ -101,3 +103,60 @@ def test_rebundle_via_extractor_uses_last_resort_resolver(): existing_bundle=initial, ) assert rebuilt == initial + + +# Runtime lookup is string-based and has no .luaurc, so every require string +# in an emitted bundle must hit a key in that bundle. The tests below pin +# that for the cases where canonicalization re-keys a module away from the +# spelling the require uses. + + +def test_losing_alias_spelling_still_resolves_at_runtime(): + """require via the tiebreak-losing alias of two same-target aliases.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"Zeta": "/elsewhere", "Alpha": "/elsewhere"}), + "/project/Main.luau": 'require("@Zeta/util")', + "/elsewhere/util.luau": "return {}", + }) + with pytest.warns(AliasCollisionWarning): + text = bundle(vfs, PurePosixPath("/project"), PurePosixPath("/project/Main.luau")) + body_runs = simulate(text) + assert all(count == 1 for count in body_runs.values()) + + +def test_alias_shadowed_by_root_still_resolves_at_runtime(): + """require via a user alias targeting project_root (re-keyed to @root).""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"myproj": "."}), + "/project/Main.luau": 'require("@myproj/util")', + "/project/util.luau": "return {}", + }) + text = bundle(vfs, PurePosixPath("/project"), PurePosixPath("/project/Main.luau")) + body_runs = simulate(text) + assert all(count == 1 for count in body_runs.values()) + + +def test_relative_require_crossing_into_deeper_alias_still_resolves(): + """relative require whose target a more specific alias re-keys.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"alpha": "/elsewhere", "util": "/elsewhere/sub"}), + "/project/Main.luau": 'require("@alpha/x")', + "/elsewhere/x.luau": 'require("./sub/helpers")', + "/elsewhere/sub/helpers.luau": "return {}", + }) + text = bundle(vfs, PurePosixPath("/project"), PurePosixPath("/project/Main.luau")) + body_runs = simulate(text) + assert all(count == 1 for count in body_runs.values()) + + +def test_alias_matching_is_case_insensitive_like_upstream(): + """alias matching folds case, per upstream Config.cpp/RequireNavigator.cpp.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), + "/project/Main.luau": 'require("@somelib/util")', + "/project/Packages/SomeLib/util.luau": "return {}", + }) + text = bundle(vfs, PurePosixPath("/project"), PurePosixPath("/project/Main.luau")) + body_runs = simulate(text) + assert all(count == 1 for count in body_runs.values()) + assert len(body_runs) == 2 diff --git a/tools/slua_bundle/tests/test_runtime.py b/tools/slua_bundle/tests/test_runtime.py index 04b70a22..3391df9c 100644 --- a/tools/slua_bundle/tests/test_runtime.py +++ b/tools/slua_bundle/tests/test_runtime.py @@ -209,6 +209,18 @@ def test_simulate_main_with_anchor_accepts_relative_and_absolute(): assert body_runs["@p/lib/foo"] == 1 +def test_simulate_traverses_single_quoted_requires(): + """simulate() walks single-quoted requires, same as the bundler.""" + text = _make( + *_v1_header(project="p", main="@p/Main"), + "require('./lib/foo')", + "-- !!LUABUNDLE:MODULE @p/lib/foo", + 'return "foo"', + ) + body_runs = simulate(text) + assert body_runs.get("@p/lib/foo") == 1 + + def test_simulate_bare_identifier_rejected(): text = _make( *_v1_header(project="p", main="@p/Main"), From ca29a3ea9253308473d74fc2782dd183c38981a7 Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:13:47 -0700 Subject: [PATCH 6/7] Refactor This does some pretty heavy lifting, rewriting the code so that aliases "ride along" with the payload so that they can always be statically resolved. We also add more assurances that the payload won't blow up in our face by adding some tests for common abuse cases, like overly-deep dependency trees and large numbers of modules. --- rfcs/static-require-bundle.md | 169 +++++++++--- tools/slua_bundle/README.md | 6 +- tools/slua_bundle/slua_bundle/__init__.py | 16 +- tools/slua_bundle/slua_bundle/bundler.py | 134 +++++++-- tools/slua_bundle/slua_bundle/canonicalize.py | 36 +-- tools/slua_bundle/slua_bundle/cli.py | 25 +- tools/slua_bundle/slua_bundle/errors.py | 8 + tools/slua_bundle/slua_bundle/extractor.py | 162 +++++++++-- tools/slua_bundle/slua_bundle/fs.py | 48 +++- tools/slua_bundle/slua_bundle/luaurc.py | 128 +++++++-- tools/slua_bundle/slua_bundle/resolver.py | 75 ++++- tools/slua_bundle/slua_bundle/runtime.py | 112 +++++++- tools/slua_bundle/tests/test_bundler.py | 132 ++++++++- tools/slua_bundle/tests/test_canonicalize.py | 125 ++++++++- tools/slua_bundle/tests/test_disk_fs.py | 44 ++- tools/slua_bundle/tests/test_e2e.py | 4 +- tools/slua_bundle/tests/test_extractor.py | 146 +++++++++- tools/slua_bundle/tests/test_resolver.py | 30 +- tools/slua_bundle/tests/test_runtime.py | 258 ++++++++++++++++-- 19 files changed, 1474 insertions(+), 184 deletions(-) diff --git a/rfcs/static-require-bundle.md b/rfcs/static-require-bundle.md index 848a54eb..864f34cc 100644 --- a/rfcs/static-require-bundle.md +++ b/rfcs/static-require-bundle.md @@ -35,56 +35,90 @@ path are referring to the same logical module regardless of how each environment | Pattern | Meaning | |---------|-----------------------------------------------------------------------------------------------| | `require("@root/utils")` | The built-in `@root/` alias always points to the project root. See [Built-in aliases](#built-in-aliases). | -| `require("@myalias/utils")` | User-declared alias from `.luaurc`. Resolved by the bundler; canonical bundle key is the alias path itself. | -| `require("./foo")`, `require("../foo")`, `require("@self/foo")` | Location-dependent. Canonicalized to absolute alias form at bundle time; see [Canonicalization](#canonicalization). | +| `require("@myalias/utils")` | User-declared alias from `.luaurc`. Resolved by the bundler; the bundle key is the alias path (case-folded, and possibly redirected by an `ALIAS` mapping; see [Canonicalization](#canonicalization)). | +| `require("./foo")`, `require("../foo")`, `require("@self/foo")` | Location-dependent. Absolutized against the requiring module's key at compile time; see [Canonicalization](#canonicalization). | Bare identifiers (`require("foo")`) are not valid - upstream Luau already rejects anything without a `./`, `../`, or -`@` prefix. The `@sl/` namespace is reserved for future use. +`@` prefix. The `@sl/` namespace is reserved for future use, and the reservation is enforced: requires through it, +`.luaurc` declarations of it, and bundle keys under it are all rejected (any casing). -**Bundlers resolve aliases at bundle time.** The bundle stores each module under its canonical absolute alias key. -Runtime `require()` is a simple string lookup against the bundle's module table - no alias resolution at runtime. +**Alias names are case-insensitive**, matching upstream Luau (`Config.cpp` folds to lowercase at `.luaurc` load; +`RequireNavigator.cpp` folds at require time). Canonical keys always carry the lowercase spelling; `.luaurc` may not +declare two names that collide after folding. The fold is ASCII A-Z only; non-ASCII alias names are unspecified, +also matching upstream. + +**Bundlers resolve aliases at bundle time; the server never sees `.luaurc`.** The bundle stores each module under +its canonical absolute alias key. At compile time the server makes every require string absolute - location-dependent +forms are absolutized against the enclosing module's key, then the result is rewritten through the bundle's +[`ALIAS` table](#alias-non-canonical-prefixes) (longest component-prefix match). Runtime `require()` is then a simple +string lookup against the bundle's module table - no alias resolution at runtime, and every module has exactly one key. ### Built-in aliases - `@root/` — always points to the project root. Lets scripts reference project-internal modules without depending on - the project's PROJECT name. Reserved: `.luaurc` may not declare an alias named `root`. + the project's PROJECT name. Reserved: `.luaurc` may not declare an alias named `root` (any casing). - `@self/` — upstream Luau convention: "current module's directory" (relative to the requiring file). Treated by the - resolver, not by `.luaurc`. Reserved — `.luaurc` may not declare an alias named `self`. + resolver, not by `.luaurc`. Reserved — `.luaurc` may not declare an alias named `self` (any casing). `@self` is + expanded away during compile-time absolutization, before the `ALIAS` table is consulted, so no mapping can ever + touch it; `ALIAS` directives naming it are rejected as hygiene. +- `@sl/` — reserved for future use; requires, `.luaurc` declarations, and bundle keys using it are rejected. Project-internal canonical keys always have the form `@root/...`, regardless of the bundle's PROJECT name. PROJECT is viewer-linkage metadata only and never appears in canonical keys. +**MAIN is always under `@root`.** The entry script must live under `project_root` and is keyed by its root-relative +path, ignoring aliases; parsers reject a `MAIN` directive whose key is not `@root` or `@root/...`. MAIN's key is +never a lookup target - in a pure-trace bundle everything is reachable from MAIN, so requiring it is by definition a +cycle - it exists only to anchor the BODY's location-dependent requires. + ### Canonicalization -Every module is keyed in the bundle under an absolute alias path. The bundler rewrites the require's string constant -in emitted bytecode so runtime lookup matches that key directly. +Every module is keyed in the bundle under exactly one absolute alias path: the **canonical key**, derived from the +module's physical location (most-specific covering alias). Source text is preserved byte-for-byte - nothing rewrites +require strings in module bodies. Instead, two kinds of keys are reconciled at compile time: + +- The **syntactic key** of a require: what the server computes from the require string and the enclosing MODULE/MAIN + key alone, with no `.luaurc` - alias names case-folded, `./`/`../`/`@self` absolutized against the anchor. +- The **canonical key** the module is stored under. + +These coincide almost always. When they diverge (the require spells the module through a different alias relationship +than the canonicalizer picked), the bundler factors the divergence into a prefix mapping and emits it as a header +[`ALIAS` directive](#alias-non-canonical-prefixes); the server applies the table after absolutization. Keying through +the physical file means two aliases to one file are still **one** module with one cache instance, matching upstream +Luau's cache-by-resolved-path semantics. ```mermaid flowchart TD Start[require S from module R] Start --> Kind{S starts with} - Kind -->|"@alias/..."| Direct[Canonical key = S] - Kind -->|"./, ../, @self/"| Resolve[Resolve against R's location -> physical path L] + Kind -->|"@alias/..."| Fold[Case-fold alias -> syntactic key K] + Kind -->|"./, ../, @self/"| Anchor[Absolutize against R's key -> syntactic key K] + Fold --> Resolve[Resolve K to physical file L] + Anchor --> Resolve Resolve --> FindAlias{Most-specific covering alias for L} - FindAlias -->|Found| Build["Canonical key = @alias/{L relative to target}"] + FindAlias -->|Found| Build["Canonical key C = @alias/{L relative to target}"] FindAlias -->|None| NoAliasErr["Error: no alias spans L (shouldn't happen: @root always covers project_root)"] - Direct --> Done[Stored in bundle module table] - Build --> Done + Build --> Same{K == C?} + Same -->|Yes| Done[Stored in bundle module table under C] + Same -->|No| Remap[Emit header ALIAS prefix mapping K-minus-tail -> C-alias] + Remap --> Done ``` The covering-alias set always includes `@root` for `project_root`, so files under `project_root` always canonicalize cleanly. Files outside `project_root` need an explicit `.luaurc` alias spanning them. -MAIN is canonicalized identically: the `MAIN` directive's value acts as MAIN's anchor key, exactly -as the MODULE marker acts for module bodies. MAIN is always present in well-formed bundles. +MAIN is the exception to most-specific-alias keying: its key is its root-relative path by fiat (see +[Built-in aliases](#built-in-aliases)). A deeper alias covering MAIN's directory simply makes the body's relative +requires diverge syntactic-vs-canonical, which the uniform `ALIAS` rule already handles. **Tiebreaker for identical-target aliases.** Specificity is by number of path components in the alias target (deeper -wins). When two aliases point at the exactly-same directory at the deepest covering tier: +wins). When two aliases point at the exactly-same directory at the deepest covering tier, the tiebreak picks the +**primary** (canonical) key only - requires spelled through the losing alias keep working via the emitted `ALIAS` +mapping: - If `@root` is one of them, `@root` wins silently. Common case: a user declared a Wally-style alias for `project_root` in `.luaurc` not realizing `@root` already covers it. -- Otherwise, the alphabetically-first alias name wins (ASCII byte order), and the bundler emits a warning. This is a - config smell — usually a copy-paste mistake in `.luaurc`. Alias names are assumed ASCII (Luau / Wally convention); - non-ASCII alias names are unspecified. +- Otherwise, the alphabetically-first folded alias name wins (ASCII byte order), and the bundler emits a warning - + a config smell, usually a copy-paste mistake in `.luaurc`, but not a broken bundle. **Worked example.** Inside `Packages/SomeLib/init.luau` with `.luaurc` defining `"SomeLib": "Packages/SomeLib"`: @@ -92,14 +126,46 @@ wins). When two aliases point at the exactly-same directory at the deepest cover local Module = require("./src/Module") ``` -resolves physically to `Packages/SomeLib/src/Module.luau`. The most-specific covering alias is `@SomeLib`, so the -bundle key becomes `@SomeLib/src/Module` - identical to what `require("@SomeLib/src/Module")` would produce. Wally -packages canonicalize this way without any SLua-specific handling. +resolves physically to `Packages/SomeLib/src/Module.luau`. The most-specific covering alias is `@somelib` (folded), +so the bundle key becomes `@somelib/src/Module` - identical to what `require("@SomeLib/src/Module")` produces after +case folding. Wally packages canonicalize this way without any SLua-specific handling. Same source tree + same `.luaurc` produces identical canonical keys regardless of which bundler ran; `.luaurc` ships alongside the source. Resolvers returning virtual source (e.g. inventory) must declare an alias prefix for what they return, or reject location-dependent requires inside it - a resolver concern, not the format's. +**Known deliberate divergence:** a `../` that climbs above the canonical alias root is an error, even when the +physical file exists under a shallower alias (upstream, resolving on disk, would find it). Key space has no headroom +above the alias root; the filesystem does. Restructure the require to spell the target through the shallower alias. + +### ALIAS: non-canonical prefixes + +Every syntactic-vs-canonical divergence has one shape: syntactic `@A/p1/../pk/tail` vs canonical `@B/tail`, where +alias B's directory equals A's directory plus `p1/../pk`. The shared tail means each alias *relationship* is a single +prefix mapping - "in this bundle, this prefix means that alias": + +```lua +-- !!LUABUNDLE:ALIAS @zeta @alpha (two aliases, same directory; @alpha won the tiebreak) +-- !!LUABUNDLE:ALIAS @myproj @root (user alias targeting project_root; @root wins) +-- !!LUABUNDLE:ALIAS @alpha/sub @util (nested alias, or a relative require crossing into @util's territory) +``` + +At compile time, after absolutization, the server rewrites any require key whose component-prefix matches a mapping +source (longest match wins) to the target plus the remainder. One application suffices: targets are always +tiebreak-winner aliases, already canonical. The runtime module table stays strictly one key per module. + +Validation (parse-time): +- `ALIAS` is a header directive (between `VERSION` and `BODY`); two `@`-prefixed keys in canonical (folded) form. +- The target must be a bare `@alias`. The source may carry a path tail (`@alpha/sub`), which is how nested aliases + and relative-require crossovers are expressed. +- Neither key may involve `@self`; the source may not be bare `@root` (nothing can ever legitimately remap the root + namespace away - the target of every derived mapping is a tiebreak winner, and `@root` always wins at its own + directory). `@root/sub` as a source is legal and necessary. +- Duplicate sources, a source mapping to itself, and self-nesting (`@b/x -> @b`, which claims an alias's directory + contains itself) are errors. +- No `MODULE` key may sit under a mapping's source prefix: module keys are canonical, so a remapped prefix only + redirects spellings that would otherwise dangle. (MAIN is exempt - root-relative by fiat, never a lookup target.) + ## Bundle Format Text-based, valid Luau syntax, lightly inspired by MIME multipart RFC. The format is **producer-agnostic**: the viewer @@ -125,12 +191,13 @@ return { helper = function() return "hello" end } |-----------|----------|---------| | `-- !!LUABUNDLE:VERSION ` | yes, first line | Bundle format version. Consumers MUST reject unknown versions. Future version bumps are non-back-compatible unless explicitly stated. | | `-- !!LUABUNDLE:PROJECT ` | no | Advisory viewer-linkage metadata; associates the bundle with a disk project. Not used for canonicalization. See [Project Linkage](#project-linkage). | -| `-- !!LUABUNDLE:MAIN ` | yes | Canonical key for the MAIN section (e.g. `@root/HudController`). Anchors `./`, `../`, and `@self/` requires inside MAIN. Always emitted by the reference bundler; computed from MAIN's path under `project_root`. | +| `-- !!LUABUNDLE:MAIN ` | yes | Key for the MAIN section; always `@root`-relative (computed from MAIN's path under `project_root`, ignoring aliases). Anchors `./`, `../`, and `@self/` requires inside MAIN. | +| `-- !!LUABUNDLE:ALIAS ` | per mapping | Prefix remapping applied to require keys at compile time, after absolutization; longest component-prefix match wins. See [ALIAS](#alias-non-canonical-prefixes). | | `-- !!LUABUNDLE:BODY` | yes | Separates the header from MAIN's source body. Everything between BODY and the first MODULE marker (or EOF) is MAIN. | | `-- !!LUABUNDLE:MODULE ` | per module | Marks each dependency. The canonical key identifies the module for runtime `require()` lookup. | **Other rules:** -- Lines matching `^-- *!!LUABUNDLE:` in user source are rejected at bundle time. Users may not author lines that look like bundle directives. +- Lines matching `^--\s*!!LUABUNDLE:` in user source are rejected at bundle time. Users may not author lines that look like bundle directives. - Generally the user only sees and directly edits the `MAIN` body of the bundle. - Users may define their own aliases (via `.luaurc`) referring to libs on disk; see [Configuration & Project Layout](#configuration--project-layout). @@ -157,8 +224,14 @@ SLua reuses Luau ecosystem conventions for project configuration. **No SLua-spec ``` Project-internal modules don't need a user alias — the built-in `@root/` covers `project_root`. `.luaurc` is for -declaring external aliases (Wally packages, vendored libs, virtual-source resolvers, etc.). The alias name `root` is -reserved. +declaring external aliases (Wally packages, vendored libs, virtual-source resolvers, etc.). The alias names `root`, +`self`, and `sl` are reserved (any casing); alias names are case-insensitive (see [Path Resolution](#path-resolution)) +and restricted to upstream's charset (`isValidAlias` in Config.cpp): ASCII letters, digits, `-`, `_`, `.`; never `.`, +`..`, or anything containing a path separator. Declare names without a leading `@` - upstream tolerates one but then +stores the name verbatim so it can never match a require; SLua rejects it instead of inheriting the trap. +Like upstream's lexer-based parser, `//` and Lua-style `--` line comments, `--[[ ... ]]` long comments (including +`=`-leveled openers), and trailing commas are all accepted; an unterminated long comment is an error, and C block +comments (`/* */`) are rejected, exactly as upstream's lexer behaves. **SLua bundlers read only the `.luaurc` at `project_root`.** Nested `.luaurc` files deeper in the tree are ignored; files above `project_root` are never consulted. This deviates from upstream Luau's walking behavior on purpose: @@ -210,6 +283,10 @@ A bundler maps an alias path to source code at bundle time. The RFC commits only silent on the rest: - Bundlers **SHOULD** support `.luaurc`-based disk alias resolution. This is the Wally/Roblox compatibility floor. +- Disk resolvers **MUST** treat path casing as significant even on case-insensitive host filesystems (macOS, Windows): + scripts always run under Linux, so a require that resolves only via case insensitivity would produce a bundle whose + keys depend on which host built it. The reference `DiskFS` verifies exact on-disk casing and treats mismatches as + not-found, matching the Linux runtime. - Bundlers **MAY** support additional resolvers (inventory, marketplace, http registries, etc.). These are entirely an implementation choice; the RFC neither enumerates nor specifies them. - The bundle's embedded source is the **universal last-resort resolver**. Any bundler can rebundle a bundle it received - @@ -361,7 +438,9 @@ flowchart TD - Module results cached after first execution - Each module runs in sandboxed environment via `dangerouslyexecuterequiredmodule()` - - This function will be hidden from view and not directly usable. -- Simple string lookup - no alias resolution at runtime +- Simple string lookup - no alias resolution at runtime. Require strings are made absolute at **compile time** + (absolutize against the enclosing MODULE/MAIN key, then rewrite through the `ALIAS` table), so the runtime table is + strictly one key per module and lookups are exact string matches. ## Bytecode Extension @@ -387,17 +466,25 @@ p->source = protoSource; | Unknown alias | Compile | `unknown alias '@foo' in require` | | Require escapes alias | Compile | `require '' from : '..' traverses past alias root` | | Invalid path component | Compile | `component '' starts with '.'` / `component contains NUL` | -| Relative require without anchor | Compile | MAIN uses `./`, `../`, or `@self/` but the bundle has no `MAIN` directive | +| Reserved namespace require | Compile | A require uses the `@sl/` namespace, which is reserved for future use | | No covering alias | Bundle time | `no alias spans ; add a .luaurc entry covering it` (won't fire for files under `project_root` since `@root` always covers it) | -| Reserved alias | Bundle time | `.luaurc` declares an alias name reserved by the format (`root`, `self`) | -| Alias collision | Bundle time | Two `.luaurc` aliases point at the exact same directory; emitted as a warning, not an error | +| Reserved alias | Bundle time | `.luaurc` declares an alias name reserved by the format (`root`, `self`, `sl`; any casing) | +| Invalid alias name | Bundle time | `.luaurc` alias name outside upstream's charset (ASCII alphanumerics, `-`, `_`, `.`; no separators, no `.`/`..`, no leading `@`) | +| Invalid MAIN file | Bundle time | `main_path` does not exist, is not a regular file, or is not a `.luau` file | +| Invalid UTF-8 | Bundle time | A source or bundle file on disk is not valid UTF-8 (the format is UTF-8 everywhere) | +| Alias case-fold collision | Bundle time | Two `.luaurc` alias names collide after case folding (alias names are case-insensitive) | +| Alias collision | Bundle time | Two `.luaurc` aliases point at the exact same directory; emitted as a warning, not an error - the `ALIAS` mapping keeps both spellings working | +| MAIN outside project root | Bundle time | The entry script is not under `project_root`; choose a root containing it | +| Remap conflict | Bundle time | One syntactic prefix would map to two targets, or a remapped prefix collides with a module key; only possible when a fallback bundle's alias universe disagrees with the on-disk `.luaurc` | | Ambiguous file resolution | Bundle time | Both `.luau` and `/init.luau` exist; remove one to disambiguate | | No resolver succeeded | Bundle time | `cannot resolve module '': no resolver produced source and no copy in existing bundle` | | Circular dependency | Compile | `circular dependency: -> -> ` | | Delimiter in source | Compile | `source cannot contain '-- !!LUABUNDLE:'` | | Depth exceeded | Compile | `require depth exceeds maximum` | | Duplicate MODULE marker | Parse | Bundle contains two `MODULE` directives with the same canonical key | -| Malformed module key | Parse | A `MODULE` key does not begin with `@` | +| Malformed key | Parse | A `MODULE`/`MAIN`/`ALIAS` key does not begin with `@`, has an empty or non-lowercase alias, or uses `@self` | +| MAIN not under @root | Parse | The `MAIN` directive's key is not `@root` or `@root/...` | +| Invalid ALIAS | Parse | Wrong arity, bare-`@root` source, tailed target, self-mapping, self-nesting (`@b/x -> @b`), duplicate source, placement after `BODY`, or a mapping source shadowing a `MODULE` key | | Missing MAIN directive | Parse | Bundle has no `MAIN` directive | | Module not in bundle | Runtime | `module not found: ` | @@ -407,10 +494,26 @@ stand-in. ## Limits -- Max dependency depth: 100 +- Max dependency depth: 100 (the reference implementation measures BFS level from MAIN - shortest require chain, not + longest) - Max total modules: 1000 - No circular dependencies (no different from typical Luau here!) +## Reference implementation caveats + +The Python bundler scans for requires with a regex rather than a parser. Consequences, accepted for the prototype: + +- `require` matches inside comments and long strings are traced, which can over-bundle or surface a spurious + "no resolver" error from commented-out requires. +- Dynamic requires (`require(expr)`) are invisible to the regex, so the "dynamic require" compile error cannot fire in + the reference implementation; the production AST-based compiler rejects them. +- Both quote styles and Luau's paren-less call sugar (`require "./x"`) are matched. + +The production compiler resolves requires from the AST and has none of these limitations. + +SLua resolves `.luau` files only, deliberately. Upstream delegates extension policy to the embedder (the `luau` CLI +also tries `.lua`); SLua picks a single extension so canonical keys are unambiguous. + ## Future Work - Tree shaking (eliminate unused exports) diff --git a/tools/slua_bundle/README.md b/tools/slua_bundle/README.md index 3fec4d80..21c2d8d5 100644 --- a/tools/slua_bundle/README.md +++ b/tools/slua_bundle/README.md @@ -24,7 +24,7 @@ Either form puts a `slua-bundle` command on `$PATH`. ### `slua-bundle bundle` -Bundle a project tree into a single text artifact. Project-internal modules canonicalize under the built-in `@root` alias. `--project` is an optional advisory project name emitted as the bundle's `PROJECT` directive (for viewer-side linkage to a disk project); it does not affect canonicalization. +Bundle a project tree into a single text artifact. Project-internal modules canonicalize under the built-in `@root` alias. `--project` is an optional advisory project name emitted as the bundle's `PROJECT` directive (for viewer-side linkage to a disk project); it does not affect canonicalization. Before writing, the produced bundle is self-checked with the simulate walker: every require must resolve through the bundle alone, and cycles are surfaced. ``` slua-bundle bundle --root ./src ./src/Main.luau -o bundle.lua @@ -37,7 +37,7 @@ Pass `--input-bundle PATH` to use an existing bundle as a last-resort resolver: ### `slua-bundle inspect` -Show a bundle's structure: format version, advisory project name (if any), MAIN entry point, and a per-module byte breakdown sorted by canonical key. +Show a bundle's structure: format version, advisory project name (if any), MAIN entry point, ALIAS prefix remappings (if any), and a per-module byte breakdown sorted by canonical key. ``` slua-bundle inspect bundle.lua @@ -45,7 +45,7 @@ slua-bundle inspect bundle.lua ### `slua-bundle extract` -Reverse a bundle into a project tree. `@root` modules land flat under `/`, non-root aliases under `//`. A `.luaurc` is generated when non-root aliases are present. Re-bundling the extracted tree reproduces the original bundle byte-for-byte. +Reverse a bundle into a project tree. `@root` modules land flat under `/`, non-root aliases under `//` (nested-alias `ALIAS` lines pin an alias's directory inside another's). A `.luaurc` declaring every alias -- including extra names from `ALIAS` lines -- is generated when needed. Re-bundling the extracted tree reproduces the original bundle byte-for-byte. ``` slua-bundle extract -o ./extracted bundle.lua diff --git a/tools/slua_bundle/slua_bundle/__init__.py b/tools/slua_bundle/slua_bundle/__init__.py index 6e794e59..119c740f 100644 --- a/tools/slua_bundle/slua_bundle/__init__.py +++ b/tools/slua_bundle/slua_bundle/__init__.py @@ -1,26 +1,30 @@ from .bundler import ( AmbiguousResolutionError, DepthExceededError, + MainFileError, + MainOutsideRootError, MarkerInjectionError, ModuleCountExceededError, NoResolverError, + RemapConflictError, bundle, ) from .canonicalize import ( AliasCollisionWarning, NoCoveringAliasError, - ReservedAliasError, canonicalize, ) -from .errors import BundleError -from .fs import DiskFS, FSBackend, MemoryFS +from .errors import BundleError, ReservedAliasError +from .fs import DiskFS, FSBackend, MemoryFS, SourceDecodeError from .luaurc import InvalidLuaurcError from .resolver import ( BareIdentifierError, InvalidPathComponentError, + MalformedKeyError, RelativeRequireWithoutAnchorError, RequireEscapesAliasError, UnknownAliasError, + apply_remap, resolve, ) from .runtime import ( @@ -45,6 +49,9 @@ "FSBackend", "InvalidLuaurcError", "InvalidPathComponentError", + "MainFileError", + "MainOutsideRootError", + "MalformedKeyError", "MarkerInjectionError", "MemoryFS", "ModuleCountExceededError", @@ -52,9 +59,12 @@ "NoResolverError", "ParsedBundle", "RelativeRequireWithoutAnchorError", + "RemapConflictError", "RequireEscapesAliasError", "ReservedAliasError", + "SourceDecodeError", "UnknownAliasError", + "apply_remap", "bundle", "canonicalize", "parse_bundle", diff --git a/tools/slua_bundle/slua_bundle/bundler.py b/tools/slua_bundle/slua_bundle/bundler.py index 19e1b8d7..f8ed2d88 100644 --- a/tools/slua_bundle/slua_bundle/bundler.py +++ b/tools/slua_bundle/slua_bundle/bundler.py @@ -4,10 +4,10 @@ from collections import deque from pathlib import PurePath -from .canonicalize import build_alias_map, canonicalize +from .canonicalize import build_alias_map, canonicalize, key_from_relpath from .errors import BundleError from .fs import FSBackend, normalize -from .resolver import _split_canonical, resolve +from .resolver import _join_canonical, _split_canonical, apply_remap, iter_requires, resolve from .runtime import parse_bundle BUNDLE_VERSION = 1 @@ -15,7 +15,6 @@ MAX_DEPTH = 100 MAX_MODULES = 1000 -REQUIRE_RE = re.compile(r'\brequire\s*\(\s*(?:"([^"]*)"|\'([^\']*)\')\s*\)') _MARKER_RE = re.compile(r"^--\s*!!LUABUNDLE:", re.MULTILINE) @@ -23,6 +22,20 @@ class MarkerInjectionError(BundleError): pass +class MainOutsideRootError(BundleError): + """main_path is not under project_root; MAIN keys are always @root-relative.""" + + +class MainFileError(BundleError): + """main_path is missing, not a regular file, or not a .luau file.""" + + +class RemapConflictError(BundleError): + """A syntactic prefix would remap to two different targets, or a remapped + prefix collides with a real module key. Only possible when a fallback + bundle's alias universe disagrees with the on-disk .luaurc.""" + + class AmbiguousResolutionError(BundleError): """Both .luau and /init.luau exist for a resolved require.""" @@ -62,33 +75,60 @@ def bundle( .luaurc only (nested .luaurc files are ignored for reproducibility); the built-in @root alias always covers project_root. + MAIN must live under project_root and is keyed by its root-relative + path, ignoring aliases -- "MAIN is under @root" is a format invariant. + + Modules are keyed canonically (most-specific covering alias of the + physical file). Whenever a require string's syntactic key -- what the + server computes from the string and the requirer's anchor, with no + .luaurc -- diverges from the canonical key, the divergence factors into + a prefix mapping emitted as a header ALIAS directive; the server + rewrites absolutized require keys through that table at compile time. + project_name, if provided, is emitted as the bundle's PROJECT directive (advisory viewer-linkage metadata) but is not used for canonicalization. existing_bundle, if provided, acts as the universal last-resort resolver: when an alias has no on-disk source (or no .luaurc entry), the bundler - falls back to the source already embedded in that bundle. Disk wins - when both are available. + falls back to the source already embedded in that bundle, applying that + bundle's own ALIAS table first. Disk wins when both are available. """ project_root = normalize(project_root) main_path = normalize(main_path) + if not vfs.is_file(main_path): + raise MainFileError(f"MAIN {main_path} does not exist or is not a file") + if main_path.suffix != ".luau": + raise MainFileError(f"MAIN {main_path} must be a .luau file") + alias_map = build_alias_map(vfs, project_root) - main_key = canonicalize(vfs, main_path, project_root) + try: + main_rel = main_path.relative_to(project_root) + except ValueError: + raise MainOutsideRootError( + f"MAIN {main_path} is not under project root {project_root}; " + "choose a project root that contains the entry script" + ) from None + main_key = key_from_relpath("root", main_rel) main_source = vfs.read(main_path) _check_no_marker_injection(str(main_path), main_source) fallback_modules: dict[str, str] = {} + fallback_remap: dict[str, str] = {} if existing_bundle is not None: parsed = parse_bundle(existing_bundle) fallback_modules.update(parsed.modules) fallback_modules[parsed.fields["main"]] = parsed.main_source + fallback_remap = parsed.remap known_aliases = set(alias_map.keys()) for key in fallback_modules: known_aliases.add(key[1:].split("/", 1)[0]) + for frm in fallback_remap: + known_aliases.add(frm[1:].split("/", 1)[0]) visited: dict[str, str] = {} + remap: dict[str, str] = {} queue: deque[tuple[str, str, int]] = deque() queue.append((main_key, main_source, 0)) enqueued: set[str] = {main_key} @@ -103,12 +143,18 @@ def bundle( continue visited[key] = source - for dq, sq in REQUIRE_RE.findall(source): - req_str = dq or sq - target_key, target_source = _resolve_source( - vfs, alias_map, fallback_modules, project_root, + for req_str in iter_requires(source): + target_key, target_source, syntactic_key = _resolve_source( + vfs, alias_map, fallback_modules, fallback_remap, project_root, key, req_str, known_aliases, ) + if syntactic_key != target_key: + frm, to = _derive_remap(syntactic_key, target_key) + prior = remap.setdefault(frm, to) + if prior != to: + raise RemapConflictError( + f"prefix {frm} would remap to both {prior} and {to}" + ) if target_key in enqueued: continue _check_no_marker_injection(target_key, target_source) @@ -119,12 +165,26 @@ def bundle( enqueued.add(target_key) queue.append((target_key, target_source, depth + 1)) + # Module keys are canonical and therefore never live under a remapped + # prefix within one alias universe; a violation means the fallback + # bundle's universe disagrees with the on-disk .luaurc. MAIN is exempt: + # it is root-relative by fiat and never a lookup target. + for frm in remap: + prefix = frm + "/" + for key in visited: + if key != main_key and (key == frm or key.startswith(prefix)): + raise RemapConflictError( + f"remapped prefix {frm} collides with module key {key}" + ) + main_body = visited.pop(main_key) parts: list[str] = [f"-- !!LUABUNDLE:VERSION {BUNDLE_VERSION}\n"] if project_name is not None: parts.append(f"-- !!LUABUNDLE:PROJECT {project_name}\n") parts.append(f"-- !!LUABUNDLE:MAIN {main_key}\n") + for frm in sorted(remap): + parts.append(f"-- !!LUABUNDLE:ALIAS {frm} {remap[frm]}\n") parts.append("-- !!LUABUNDLE:BODY\n") parts.append(_terminate(main_body)) for key, source in visited.items(): @@ -133,24 +193,48 @@ def bundle( return "".join(parts) +def _derive_remap(syntactic_key: str, canonical_key: str) -> tuple[str, str]: + """Factor a syntactic-vs-canonical divergence into a prefix mapping. + + canonical = @B/tail and syntactic = @A/p1/../pk/tail share their tail + (both spell the same file relative to B's directory), so the mapping is + (syntactic minus the tail) -> @B. Strip by count: suffix matching would + over-strip when an alias is named after its own directory. + """ + syn_alias, syn_parts = _split_canonical(syntactic_key) + canon_alias, canon_parts = _split_canonical(canonical_key) + cut = len(syn_parts) - len(canon_parts) + if cut < 0 or syn_parts[cut:] != canon_parts: + raise RemapConflictError( + f"cannot remap {syntactic_key} to {canonical_key}: keys do not " + "share a tail (conflicting alias universes?)" + ) + return _join_canonical(syn_alias, syn_parts[:cut]), f"@{canon_alias}" + + def _resolve_source( vfs: FSBackend, alias_map: dict[str, PurePath], fallback_modules: dict[str, str], + fallback_remap: dict[str, str], project_root: PurePath, requirer_anchor_key: str, require_str: str, known_aliases: set[str], -) -> tuple[str, str]: - """Resolve a require() string to (canonical_key, source). +) -> tuple[str, str, str]: + """Resolve a require() string to (canonical_key, source, syntactic_key). - Disk resolver wins; the fallback bundle's embedded copy is the universal - last resort (RFC: 'Resolver Behaviour'). When the alias isn't even known - to disk, fall back directly. + The syntactic key is what resolve() derives from the require string and + the requirer's anchor alone -- exactly what the server-side compiler + computes, with no .luaurc in sight. The canonical key re-derives from + the physical path so colliding aliases de-dup to a single key (and the + collision warning fires from canonicalize()). The caller records a + prefix mapping whenever the two differ. - On disk hits, re-canonicalize from the physical path so colliding aliases - (two .luaurc entries targeting the same directory) de-dup to a single - canonical key and the collision warning fires from canonicalize(). + Disk resolver wins; the fallback bundle's embedded copy is the universal + last resort (RFC: 'Resolver Behaviour'). Fallback lookup goes through + the old bundle's own ALIAS table, so any spelling under a remapped + prefix resolves -- not just spellings observed when it was built. """ target_key = resolve(require_str, requirer_anchor_key, known_aliases) alias, rel_parts = _split_canonical(target_key) @@ -158,11 +242,12 @@ def _resolve_source( if alias in alias_map: target_path = _luau_resolve_to_file(vfs, alias_map[alias], rel_parts) if target_path is not None: - canonical_key = canonicalize(vfs, target_path, project_root) - return canonical_key, vfs.read(target_path) + canonical_key = canonicalize(vfs, target_path, project_root, alias_map) + return canonical_key, vfs.read(target_path), target_key - if target_key in fallback_modules: - return target_key, fallback_modules[target_key] + fb_key = apply_remap(target_key, fallback_remap) + if fb_key in fallback_modules: + return fb_key, fallback_modules[fb_key], target_key raise NoResolverError( f"cannot resolve module '{require_str}' to '{target_key}': " @@ -210,8 +295,9 @@ def _luau_resolve_to_file( def _terminate(content: str) -> str: - """Force exactly one trailing newline so the next marker lands at column 0. + """Ensure a trailing newline so the next marker lands at column 0. - Body content is otherwise preserved byte-for-byte. + Body content is otherwise preserved byte-for-byte; existing trailing + newlines are kept as-is. """ return content if content.endswith("\n") else content + "\n" diff --git a/tools/slua_bundle/slua_bundle/canonicalize.py b/tools/slua_bundle/slua_bundle/canonicalize.py index 544b29b7..16ec0c00 100644 --- a/tools/slua_bundle/slua_bundle/canonicalize.py +++ b/tools/slua_bundle/slua_bundle/canonicalize.py @@ -3,21 +3,17 @@ import warnings from pathlib import PurePath -from .errors import BundleError +from .errors import BundleError, ReservedAliasError from .fs import FSBackend, normalize from .luaurc import load_config -RESERVED_ALIASES = frozenset({"root", "self"}) +RESERVED_ALIASES = frozenset({"root", "self", "sl"}) class NoCoveringAliasError(BundleError): pass -class ReservedAliasError(BundleError): - pass - - class AliasCollisionWarning(UserWarning): pass @@ -36,7 +32,7 @@ def build_alias_map( Nested .luaurc files are ignored: alias set must be a function of the project tree alone for bundle reproducibility. Reserved alias names - (currently just 'root') cannot be declared in .luaurc. + cannot be declared in .luaurc. """ project_root = normalize(project_root) aliases = load_config(vfs, project_root) @@ -49,15 +45,30 @@ def build_alias_map( return aliases +def key_from_relpath(alias_name: str, rel: PurePath) -> str: + """Build a canonical key from an alias name and a path relative to its dir. + + Strips the .luau suffix and an `init` leaf (a directory's init.luau is + the module named by the directory itself). + """ + rel_parts = list(rel.with_suffix("").parts) + if rel_parts and rel_parts[-1] == "init": + rel_parts.pop() + if not rel_parts: + return f"@{alias_name}" + return f"@{alias_name}/" + "/".join(rel_parts) + + def canonicalize( vfs: FSBackend, file_path: PurePath, project_root: PurePath, + alias_map: dict[str, PurePath] | None = None, ) -> str: file_path = normalize(file_path) project_root = normalize(project_root) - aliases = build_alias_map(vfs, project_root) + aliases = build_alias_map(vfs, project_root) if alias_map is None else alias_map covering = [(target, name) for name, target in aliases.items() if _is_prefix(target, file_path)] if not covering: raise NoCoveringAliasError( @@ -86,11 +97,4 @@ def canonicalize( else: alias_name = most_specific[0][1] - rel = file_path.relative_to(target_dir) - rel_parts = list(rel.with_suffix("").parts) - if rel_parts and rel_parts[-1] == "init": - rel_parts.pop() - - if not rel_parts: - return f"@{alias_name}" - return f"@{alias_name}/" + "/".join(rel_parts) + return key_from_relpath(alias_name, file_path.relative_to(target_dir)) diff --git a/tools/slua_bundle/slua_bundle/cli.py b/tools/slua_bundle/slua_bundle/cli.py index ebf7e5da..3e377e37 100644 --- a/tools/slua_bundle/slua_bundle/cli.py +++ b/tools/slua_bundle/slua_bundle/cli.py @@ -19,8 +19,8 @@ from .bundler import bundle from .errors import BundleError from .extractor import extract_to_dir -from .fs import DiskFS -from .runtime import parse_bundle +from .fs import DiskFS, SourceDecodeError +from .runtime import parse_bundle, simulate def _build_parser() -> argparse.ArgumentParser: @@ -44,9 +44,16 @@ def _build_parser() -> argparse.ArgumentParser: return parser +def _read_text_utf8(p: pathlib.Path) -> str: + try: + return p.read_text(encoding="utf-8") + except UnicodeDecodeError as e: + raise SourceDecodeError(f"{p}: not valid UTF-8 ({e})") from e + + def _cmd_bundle(args: argparse.Namespace) -> int: disk = DiskFS(args.root) - existing = args.input_bundle.read_text() if args.input_bundle else None + existing = _read_text_utf8(args.input_bundle) if args.input_bundle else None text = bundle( disk, project_root=args.root.resolve(), @@ -54,8 +61,12 @@ def _cmd_bundle(args: argparse.Namespace) -> int: project_name=args.project, existing_bundle=existing, ) + # Self-check before the artifact ships: every require in the emitted + # bundle must resolve through the bundle alone (the server has nothing + # else), and the walk also surfaces cycles the BFS absorbs silently. + simulate(text) if args.output: - args.output.write_text(text) + args.output.write_text(text, encoding="utf-8") else: sys.stdout.write(text) return 0 @@ -64,7 +75,7 @@ def _cmd_bundle(args: argparse.Namespace) -> int: def _read_bundle_text(arg: pathlib.Path | None) -> str: if arg is None: return sys.stdin.read() - return arg.read_text() + return _read_text_utf8(arg) def _cmd_inspect(args: argparse.Namespace) -> int: @@ -79,6 +90,8 @@ def _cmd_inspect(args: argparse.Namespace) -> int: print(f"MAIN {main_key} ({main_size} bytes)") else: print(f"MAIN ({main_size} bytes)") + for frm, to in sorted(parsed.remap.items()): + print(f"ALIAS {frm} -> {to}") modules = sorted(parsed.modules.items()) total = sum(len(s.encode("utf-8")) for _, s in modules) print(f"MODULES ({len(modules)}, total {total} bytes):") @@ -88,7 +101,7 @@ def _cmd_inspect(args: argparse.Namespace) -> int: def _cmd_extract(args: argparse.Namespace) -> int: - text = args.bundle_file.read_text() + text = _read_text_utf8(args.bundle_file) parsed = parse_bundle(text) extract_to_dir(parsed, DiskFS(args.output), args.output.resolve()) n = 1 + len(parsed.modules) diff --git a/tools/slua_bundle/slua_bundle/errors.py b/tools/slua_bundle/slua_bundle/errors.py index c4ee25dd..615a9794 100644 --- a/tools/slua_bundle/slua_bundle/errors.py +++ b/tools/slua_bundle/slua_bundle/errors.py @@ -10,3 +10,11 @@ class BundleError(Exception): pass + + +class ReservedAliasError(BundleError): + """A reserved alias name (root, self, sl) was declared or required. + + Lives here rather than next to one raise site: both .luaurc loading + (declaration) and require resolution (@sl use) raise it. + """ diff --git a/tools/slua_bundle/slua_bundle/extractor.py b/tools/slua_bundle/slua_bundle/extractor.py index 82bc77be..2139b2a3 100644 --- a/tools/slua_bundle/slua_bundle/extractor.py +++ b/tools/slua_bundle/slua_bundle/extractor.py @@ -1,9 +1,11 @@ """Reverse a parsed bundle into a self-contained on-disk project tree. Layout convention: @root/... files land at /, files under other -aliases at //. A .luaurc declaring every non-root alias is -written when any are present. The extracted tree is hermetic: re-bundling -it with `--root ` reproduces the original bundle byte-for-byte. +aliases at // -- except aliases pinned inside another by a +tailed ALIAS mapping. A .luaurc declaring every alias (including extra +names from bare ALIAS mappings) is written when any are needed. The +extracted tree is hermetic: re-bundling it with `--root ` +reproduces the original bundle byte-for-byte. """ from __future__ import annotations @@ -38,6 +40,15 @@ class ExtractUnsafeKeyError(ExtractError): otherwise produce an unsafe path.""" +class ExtractAliasError(ExtractError): + """The bundle's ALIAS table cannot be expressed in the extract layout.""" + + +class ExtractAmbiguityError(ExtractError): + """Extracting would write .luau alongside /init.luau, making + requires of that module ambiguous on re-bundle.""" + + _UNSAFE_COMPONENTS = frozenset({"", ".", ".."}) @@ -60,21 +71,106 @@ def _validate_key_parts(canonical_key: str, alias: str, parts: list[str]) -> Non def physical_path_for_key( canonical_key: str, output: PurePath, + alias_dirs: dict[str, tuple[str, ...]] | None = None, ) -> PurePath: """Map @alias/path -> on-disk path under . @root files go flat under ; other aliases go under a same-named - subdir. Bare alias keys (stripped init.luau) become init.luau in the + subdir unless alias_dirs pins them elsewhere (nested-alias ALIAS lines + do that). Bare alias keys (stripped init.luau) become init.luau in the appropriate dir. """ alias, parts = _split_canonical(canonical_key) _validate_key_parts(canonical_key, alias, parts) - base = output if alias == "root" else output / alias + if alias == "root": + base = output + elif alias_dirs is not None and alias in alias_dirs: + base = output.joinpath(*alias_dirs[alias]) + else: + base = output / alias if not parts: return base / "init.luau" return base.joinpath(*parts[:-1], f"{parts[-1]}.luau") +def _plan_aliases( + parsed: ParsedBundle, +) -> tuple[dict[str, tuple[str, ...]], dict[str, str]]: + """Place every alias directory and derive the .luaurc to write, such + that re-bundling the extracted tree re-derives the bundle's ALIAS table. + + Returns (alias_dirs, declared): alias_dirs maps each module-key alias + (not root) to its directory parts under ; declared maps each + .luaurc alias name to its target string. + + A bare mapping `@x -> @y` declares x as another name for y's directory. + A tailed mapping `@a/p -> @b` pins b's directory INSIDE a's at offset p, + so most-specific-alias canonicalization re-keys files under a/p as @b + on re-bundle. Placements that contradict each other (an alias pinned in + two places, or a placement cycle) are not expressible -> ExtractAliasError. + """ + real_aliases = {_split_canonical(key)[0] for key in parsed.modules} - {"root"} + + bare: dict[str, str] = {} + pin_by_target: dict[str, tuple[str, list[str]]] = {} + for frm, to in parsed.remap.items(): + frm_alias, frm_parts = _split_canonical(frm) + to_alias, _ = _split_canonical(to) + _validate_key_parts(frm, frm_alias, frm_parts) + if to_alias != "root" and to_alias not in real_aliases: + raise ExtractAliasError( + f"ALIAS {frm} -> {to}: target alias has no modules in this bundle" + ) + if not frm_parts: + bare[frm_alias] = to_alias + continue + if to_alias == "root": + raise ExtractAliasError( + f"ALIAS {frm} -> {to}: @root cannot live inside another alias" + ) + if to_alias in pin_by_target: + prior = _join(pin_by_target[to_alias]) + raise ExtractAliasError( + f"alias @{to_alias} is pinned both inside {prior} and inside " + f"{frm}; layout is not expressible" + ) + pin_by_target[to_alias] = (frm_alias, frm_parts) + + def dir_of(alias: str, seen: tuple[str, ...] = ()) -> tuple[str, ...]: + if alias == "root": + return () + if alias in seen: + raise ExtractAliasError( + f"circular ALIAS placement involving @{alias}" + ) + if alias in bare: + return dir_of(bare[alias], (*seen, alias)) + pin = pin_by_target.get(alias) + if pin is None: + return (alias,) + frm_alias, frm_parts = pin + return dir_of(frm_alias, (*seen, alias)) + tuple(frm_parts) + + alias_dirs = {a: dir_of(a) for a in real_aliases} + + # .luaurc must declare every alias a require string can spell: module-key + # aliases, plus every remap source's alias (bare or tailed). + names = real_aliases | set(bare) + for frm_alias, _parts in pin_by_target.values(): + if frm_alias != "root": + names.add(frm_alias) + declared: dict[str, str] = {} + for name in sorted(names): + d = dir_of(name) + declared[name] = "/".join(d) if d else "." + return alias_dirs, declared + + +def _join(pin: tuple[str, list[str]]) -> str: + frm_alias, frm_parts = pin + return "@" + "/".join([frm_alias, *frm_parts]) + + def extract_to_dir( parsed: ParsedBundle, out_fs: FSBackend, @@ -91,8 +187,37 @@ def extract_to_dir( sources: dict[str, str] = {main_key: parsed.main_source} sources.update(parsed.modules) + alias_dirs, declared = _plan_aliases(parsed) + + # A @root file landing inside an alias's directory would re-key under + # that alias on re-bundle (most-specific alias wins), breaking the + # byte-for-byte round-trip. Such bundles arise only from relocation: + # the original alias dir was elsewhere; the extract layout creates the + # overlap. Not expressible -> error. The check runs over every alias + # the generated .luaurc declares -- a tailed remap source gets a + # directory without owning any module keys, so alias_dirs alone is + # not enough. MAIN is exempt: the bundler keys it root-relative by + # fiat, so it never re-keys under an alias. + shadow_dirs = { + name: tuple(target.split("/")) + for name, target in declared.items() + if target != "." + } + for key in sources: + if key == main_key: + continue + alias, parts = _split_canonical(key) + if alias != "root": + continue + for owner, d in shadow_dirs.items(): + if len(parts) > len(d) and tuple(parts[: len(d)]) == d: + raise ExtractAliasError( + f"@root key {key} extracts inside alias @{owner}'s " + f"directory {'/'.join(d)}; re-bundling would re-key it" + ) + layout: dict[str, PurePath] = { - key: physical_path_for_key(key, output) + key: physical_path_for_key(key, output, alias_dirs) for key in sources } @@ -106,17 +231,23 @@ def extract_to_dir( ) seen[path] = key + # .luau next to /init.luau makes a re-bundle's require of + # that leaf hit AmbiguousResolutionError; the layout is not hermetic. + paths = set(layout.values()) + for path in paths: + init_twin = path.with_suffix("") / "init.luau" + if init_twin in paths: + raise ExtractAmbiguityError( + f"extract would write both {path} and {init_twin}; " + "requires of that module become ambiguous on re-bundle" + ) + luaurc_path = output / ".luaurc" - aliases_for_luaurc = sorted({ - _split_canonical(key)[0] - for key in sources - if _split_canonical(key)[0] != "root" - }) pre_existing: list[PurePath] = [ path for path in layout.values() if out_fs.is_file(path) ] - if aliases_for_luaurc and out_fs.is_file(luaurc_path): + if declared and out_fs.is_file(luaurc_path): pre_existing.append(luaurc_path) if pre_existing: listing = ", ".join(str(p) for p in pre_existing) @@ -127,9 +258,6 @@ def extract_to_dir( for key, path in layout.items(): out_fs.write(path, sources[key]) - if aliases_for_luaurc: - body = json.dumps( - {"aliases": {a: a for a in aliases_for_luaurc}}, - indent=2, - ) + "\n" + if declared: + body = json.dumps({"aliases": declared}, indent=2) + "\n" out_fs.write(luaurc_path, body) diff --git a/tools/slua_bundle/slua_bundle/fs.py b/tools/slua_bundle/slua_bundle/fs.py index 4efbcaf5..916445bf 100644 --- a/tools/slua_bundle/slua_bundle/fs.py +++ b/tools/slua_bundle/slua_bundle/fs.py @@ -21,6 +21,12 @@ from pathlib import PurePath, PurePosixPath from typing import ClassVar, Iterator +from .errors import BundleError + + +class SourceDecodeError(BundleError): + """A file on disk is not valid UTF-8.""" + def _is_anchor(s: str) -> bool: return s in ("/", "\\") or s.endswith((":\\", ":/")) @@ -146,10 +152,46 @@ def _coerce(self, path: PurePath | str) -> pathlib.Path: return pathlib.Path(*path.parts) def is_file(self, path: PurePath | str) -> bool: - return self._coerce(path).is_file() + target = self._coerce(path) + return target.is_file() and self._matches_disk_case(target) + + def _matches_disk_case(self, path: pathlib.Path) -> bool: + """Scripts always run under Linux, where paths are case-sensitive. + + A require that only resolves because the host filesystem is + case-insensitive (macOS, Windows) must behave as not-found here + too, or the bundle's keys depend on which host built it. Verified + by exact-name membership in each parent's directory listing, + walking the components below the FS root; the root itself is + taken as the user spelled it. Paths outside the root (absolute + .luaurc alias targets) are checked from their anchor. + """ + try: + base, parts = self._root, path.relative_to(self._root).parts + except ValueError: + if path.is_absolute(): + base, parts = pathlib.Path(path.anchor), path.parts[1:] + else: + base, parts = pathlib.Path("."), path.parts + for part in parts: + try: + entries = os.listdir(base) + except OSError: + # Unreadable parent: leave the verdict to is_file(). + return True + if part not in entries: + return False + base = base / part + return True def read(self, path: PurePath | str) -> str: - return self._coerce(path).read_text() + # Explicit utf-8: locale default (cp1252 on Windows) silently + # corrupts or rejects UTF-8 sources. + target = self._coerce(path) + try: + return target.read_text(encoding="utf-8") + except UnicodeDecodeError as e: + raise SourceDecodeError(f"{target}: not valid UTF-8 ({e})") from e def is_dir(self, path: PurePath | str) -> bool: return self._coerce(path).is_dir() @@ -170,4 +212,4 @@ def iter_files(self) -> Iterator[pathlib.Path]: def write(self, path: PurePath | str, content: str) -> None: target = self._coerce(path) target.parent.mkdir(parents=True, exist_ok=True) - target.write_text(content) + target.write_text(content, encoding="utf-8") diff --git a/tools/slua_bundle/slua_bundle/luaurc.py b/tools/slua_bundle/slua_bundle/luaurc.py index 17bac870..ebc6ca73 100644 --- a/tools/slua_bundle/slua_bundle/luaurc.py +++ b/tools/slua_bundle/slua_bundle/luaurc.py @@ -6,6 +6,7 @@ from .errors import BundleError from .fs import FSBackend, normalize +from .resolver import ascii_lower CONFIG_NAME = ".luaurc" @@ -13,26 +14,97 @@ class InvalidLuaurcError(BundleError): """A .luaurc file exists but is not valid JSONC.""" -# Match Luau's actual .luaurc parser (Config/src/Config.cpp): // line -# comments and trailing commas in objects/arrays. Block comments -# (`/* */`) are NOT accepted by Luau and stay in the stream so -# json.loads fails on them. -# -# Two passes so a trailing comma hidden behind a line comment isn't -# missed -- a single combined regex consumes the comment and never -# revisits the comma underneath. String literals are preserved by the -# alternation in each pattern. -_LINE_COMMENT_RE = re.compile(r'"(?:\\.|[^"\\])*"|//[^\n]*') -_TRAILING_COMMA_RE = re.compile(r'"(?:\\.|[^"\\])*"|,(?=\s*[}\]])') +# Mirror upstream isValidAlias (Config.cpp:166): ASCII alphanumerics +# plus '-', '_', '.'; never '.', '..', or anything with a path +# separator. Upstream also tolerates a leading '@' but then stores the +# name verbatim, so such an alias never matches a require -- reject it +# outright instead of inheriting the trap. +_ALIAS_NAME_RE = re.compile(r"[A-Za-z0-9._-]+\Z") -def _strip_jsonc(s: str) -> str: - def keep_strings(m: re.Match[str]) -> str: - text = m.group(0) - return text if text.startswith('"') else "" - s = _LINE_COMMENT_RE.sub(keep_strings, s) - s = _TRAILING_COMMA_RE.sub(keep_strings, s) - return s +# Match Luau's actual .luaurc parser (Config/src/Config.cpp), which +# tokenizes with the Luau lexer: // and Lua-style -- line comments and +# --[[ ]] long comments (any =-level, same closer-matching rule as the +# Lua lexer) are skipped, and trailing commas in objects/arrays are +# allowed. C block comments (`/* */`) are NOT accepted by Luau and stay +# in the stream so json.loads fails on them. +_LONG_OPENER_RE = re.compile(r"--\[(=*)\[") + + +def _strip_jsonc(s: str, context: str = CONFIG_NAME) -> str: + """Reduce JSONC-with-Lua-comments to plain JSON for json.loads. + + A single-pass character scanner, mirroring upstream's lexer-based + consumption. Whitespace and newlines are preserved (long comments + are replaced by their newlines) so JSONDecodeError line numbers + still point at the real source. A comma is held back until the next + significant character: dropped if that closes a container (trailing + comma), emitted otherwise -- this also handles commas separated from + their closer by comments. + """ + out: list[str] = [] + comma_held = False + i = 0 + n = len(s) + while i < n: + c = s[i] + + if c in " \t\r\n": + out.append(c) + i += 1 + continue + + if c == "/" and s.startswith("//", i): + j = s.find("\n", i) + i = n if j == -1 else j + continue + + if c == "-" and s.startswith("--", i): + opener = _LONG_OPENER_RE.match(s, i) + if opener is None: + j = s.find("\n", i) + i = n if j == -1 else j + continue + closer = "]" + opener.group(1) + "]" + end = s.find(closer, opener.end()) + if end == -1: + raise InvalidLuaurcError( + f"{context}: unterminated long comment (--[[ without matching ]])" + ) + stop = end + len(closer) + newlines = s.count("\n", i, stop) + out.append("\n" * newlines if newlines else " ") + i = stop + continue + + if c == ",": + if comma_held: + # ",," is invalid JSON regardless; pass the first through. + out.append(",") + comma_held = True + i += 1 + continue + + if comma_held: + comma_held = False + if c not in "}]": + out.append(",") + + if c == '"': + j = i + 1 + while j < n and s[j] != '"': + j += 2 if s[j] == "\\" else 1 + j = min(j + 1, n) + out.append(s[i:j]) + i = j + continue + + out.append(c) + i += 1 + + if comma_held: + out.append(",") + return "".join(out) def load_config(vfs: FSBackend, config_dir: PurePath) -> dict[str, PurePath]: @@ -40,7 +112,7 @@ def load_config(vfs: FSBackend, config_dir: PurePath) -> dict[str, PurePath]: if not vfs.is_file(config_path): return {} try: - raw = json.loads(_strip_jsonc(vfs.read(config_path))) + raw = json.loads(_strip_jsonc(vfs.read(config_path), str(config_path))) except json.JSONDecodeError as e: raise InvalidLuaurcError( f"{config_path}: invalid JSONC ({e.msg} at line {e.lineno}, column {e.colno})" @@ -48,11 +120,25 @@ def load_config(vfs: FSBackend, config_dir: PurePath) -> dict[str, PurePath]: aliases = raw.get("aliases", {}) out: dict[str, PurePath] = {} for name, target in aliases.items(): + if name in (".", "..") or not _ALIAS_NAME_RE.fullmatch(name): + raise InvalidLuaurcError( + f"{config_path}: invalid alias name {name!r} (allowed: ASCII " + "letters, digits, '-', '_', '.'; no path separators; declare " + "without a leading '@')" + ) + # Alias names are case-insensitive, matching upstream Luau + # (Config.cpp folds to lowercase at load). + folded = ascii_lower(name) + if folded in out: + raise InvalidLuaurcError( + f"{config_path}: alias {name!r} collides with another alias " + "after case folding (alias names are case-insensitive)" + ) target_path = vfs.Path(target) if target_path.is_absolute(): - out[name] = normalize(target_path) + out[folded] = normalize(target_path) else: - out[name] = normalize(config_dir / target_path) + out[folded] = normalize(config_dir / target_path) return out diff --git a/tools/slua_bundle/slua_bundle/resolver.py b/tools/slua_bundle/slua_bundle/resolver.py index 85745483..dc3ccad3 100644 --- a/tools/slua_bundle/slua_bundle/resolver.py +++ b/tools/slua_bundle/slua_bundle/resolver.py @@ -1,6 +1,9 @@ from __future__ import annotations -from .errors import BundleError +import re +from typing import Iterator + +from .errors import BundleError, ReservedAliasError class BareIdentifierError(BundleError): @@ -23,8 +26,45 @@ class InvalidPathComponentError(BundleError): pass +class MalformedKeyError(BundleError): + """A canonical key does not have the @alias[/...] shape.""" + + +# Matches require with a string-literal argument, parenthesized or via Luau's +# call sugar (`require "./x"`). Each alternative captures the string body in +# its own group; iter_requires() picks whichever matched. Known limitations +# (documented in the RFC's reference-implementation caveats): matches inside +# comments and long strings, and cannot see dynamic requires at all. +REQUIRE_RE = re.compile( + r"\brequire\s*" + r"(?:\(\s*(?:\"([^\"]*)\"|'([^']*)')\s*\)" + r"|\"([^\"]*)\"" + r"|'([^']*)')" +) + + +def iter_requires(source: str) -> Iterator[str]: + """Yield the string argument of every require() found in source.""" + for m in REQUIRE_RE.finditer(source): + for group in m.groups(): + if group is not None: + yield group + break + + +def ascii_lower(s: str) -> str: + """ASCII-only lowercasing, matching upstream Luau's alias folding + (Config.cpp lowercases A-Z only; non-ASCII alias names are unspecified). + + bytes.lower() only touches A-Z, and UTF-8 multibyte sequences are all + >= 0x80, so non-ASCII round-trips untouched. + """ + return s.encode("utf-8").lower().decode("utf-8") + + def _split_canonical(key: str) -> tuple[str, list[str]]: - assert key.startswith("@"), f"canonical key must start with @: {key}" + if not key.startswith("@"): + raise MalformedKeyError(f"canonical key must start with @: {key!r}") rest = key[1:] if "/" in rest: alias, tail = rest.split("/", 1) @@ -32,6 +72,23 @@ def _split_canonical(key: str) -> tuple[str, list[str]]: return rest, [] +def apply_remap(key: str, remap: dict[str, str]) -> str: + """Rewrite key through a bundle's ALIAS table ({from: to}). + + Longest component-prefix match wins. A single application suffices: + every mapping's target is a tiebreak-winner alias, already canonical. + """ + if not remap: + return key + alias, parts = _split_canonical(key) + for i in range(len(parts), -1, -1): + target = remap.get(_join_canonical(alias, parts[:i])) + if target is not None: + to_alias, to_parts = _split_canonical(target) + return _join_canonical(to_alias, to_parts + parts[i:]) + return key + + def _join_canonical(alias: str, parts: list[str]) -> str: if not parts: return f"@{alias}" @@ -71,11 +128,21 @@ def resolve(require_str: str, anchor_key: str | None, known_aliases: set[str]) - else: alias, tail = rest, "" + # Alias names are case-insensitive, matching upstream Luau + # (RequireNavigator lowercases before matching). Canonical keys use + # the lowercased spelling. + alias = ascii_lower(alias) + + if alias == "sl": + raise ReservedAliasError( + f"require '{require_str}': the '@sl' namespace is reserved for future use" + ) + if alias == "self": if anchor_key is None: raise RelativeRequireWithoutAnchorError( f"require '{require_str}' uses '@self' but the requiring module has no " - "anchor key (set 'main=' in the BUNDLE header for MAIN)" + "anchor key (MAIN's anchor comes from the MAIN directive)" ) # Match Luau's RequireNavigator: @self resets to the requirer's # *module path* and navigates from there. The module path is the @@ -108,7 +175,7 @@ def resolve(require_str: str, anchor_key: str | None, known_aliases: set[str]) - if anchor_key is None: raise RelativeRequireWithoutAnchorError( f"relative require '{require_str}' has no anchor " - "(set 'main=' in the BUNDLE header for MAIN)" + "(MAIN's anchor comes from the MAIN directive)" ) anchor_alias, anchor_parts = _split_canonical(anchor_key) anchor_dir_parts = anchor_parts[:-1] if anchor_parts else [] diff --git a/tools/slua_bundle/slua_bundle/runtime.py b/tools/slua_bundle/slua_bundle/runtime.py index 1560a4ea..0b51401e 100644 --- a/tools/slua_bundle/slua_bundle/runtime.py +++ b/tools/slua_bundle/slua_bundle/runtime.py @@ -1,16 +1,15 @@ from __future__ import annotations import re -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Iterator from .errors import BundleError -from .resolver import resolve +from .resolver import apply_remap, ascii_lower, iter_requires, resolve SUPPORTED_VERSIONS = frozenset({1}) MARKER_RE = re.compile(r"^--\s*!!LUABUNDLE:(\S+)(?:\s+(.+?))?\s*$") -REQUIRE_RE = re.compile(r'\brequire\s*\(\s*"([^"]*)"\s*\)') class BundleParseError(BundleError): @@ -26,6 +25,9 @@ class ParsedBundle: fields: dict[str, str] main_source: str modules: dict[str, str] + # ALIAS directives: syntactic prefix -> canonical prefix. Applied to + # require keys after absolutization, longest component-prefix first. + remap: dict[str, str] = field(default_factory=dict) @dataclass(frozen=True) @@ -55,6 +57,7 @@ def __init__(self, text: str) -> None: self.fields: dict[str, str] = {} self.main_source: str = "" self.modules: dict[str, str] = {} + self.remap: dict[str, str] = {} def parse(self) -> ParsedBundle: self._consume_version() @@ -63,10 +66,12 @@ def parse(self) -> ParsedBundle: raise BundleParseError("missing MAIN directive") self.main_source, trailing = self._consume_body() self._consume_modules(trailing) + self._check_remap_shadowing() return ParsedBundle( fields=self.fields, main_source=self.main_source, modules=self.modules, + remap=self.remap, ) def _consume_version(self) -> None: @@ -103,6 +108,9 @@ def _consume_header(self) -> None: self._set_unique(line, "project") elif kind == "MAIN": self._set_unique(line, "main") + self._validate_main_key(line) + elif kind == "ALIAS": + self._add_alias(line) elif kind == "BODY": if line.marker_arg: raise BundleParseError(f"line {line.lineno}: BODY takes no argument") @@ -110,10 +118,89 @@ def _consume_header(self) -> None: else: raise BundleParseError( f"line {line.lineno}: unknown header directive {kind} " - "(at this VERSION, only PROJECT, MAIN, BODY are valid)" + "(at this VERSION, only PROJECT, MAIN, ALIAS, BODY are valid)" ) raise BundleParseError("missing BODY marker") + def _validate_key(self, line: _Line, key: str) -> None: + if not key.startswith("@"): + raise BundleParseError( + f"line {line.lineno}: key must start with @: {key!r}" + ) + alias = key[1:].split("/", 1)[0] + if not alias: + raise BundleParseError( + f"line {line.lineno}: key has an empty alias: {key!r}" + ) + if alias in ("self", "sl"): + raise BundleParseError( + f"line {line.lineno}: key may not use the reserved alias @{alias}: {key!r}" + ) + if alias != ascii_lower(alias): + raise BundleParseError( + f"line {line.lineno}: key alias must be lowercase canonical " + f"form: {key!r}" + ) + + def _validate_main_key(self, line: _Line) -> None: + main = self.fields["main"] + self._validate_key(line, main) + if main != "@root" and not main.startswith("@root/"): + raise BundleParseError( + f"line {line.lineno}: MAIN key must be under @root, got {main!r}" + ) + + def _add_alias(self, line: _Line) -> None: + args = line.marker_arg.split() + if len(args) != 2: + raise BundleParseError( + f"line {line.lineno}: ALIAS takes exactly two keys (from, to)" + ) + frm, to = args + self._validate_key(line, frm) + self._validate_key(line, to) + if frm == "@root": + raise BundleParseError( + f"line {line.lineno}: ALIAS may not remap @root itself" + ) + if "/" in to[1:]: + raise BundleParseError( + f"line {line.lineno}: ALIAS target must be a bare @alias, got {to!r}" + ) + if frm == to: + raise BundleParseError( + f"line {line.lineno}: ALIAS maps {frm} to itself" + ) + if frm[1:].split("/", 1)[0] == to[1:]: + # Same alias on both sides with a tail on the source: claims + # the alias's directory contains itself. + raise BundleParseError( + f"line {line.lineno}: ALIAS {frm} -> {to} nests an alias inside itself" + ) + if frm in self.remap: + raise BundleParseError( + f"line {line.lineno}: duplicate ALIAS for {frm}" + ) + self.remap[frm] = to + + def _check_remap_shadowing(self) -> None: + """No module key may live under a remapped prefix. + + Module keys are canonical; a remapped prefix only redirects spellings + that would otherwise dangle. MAIN is exempt: it is root-relative by + fiat (and never a lookup target -- requiring it is always a cycle), + so a deeper alias over MAIN's directory legitimately remaps a prefix + of MAIN's own key. + """ + for frm in self.remap: + prefix = frm + "/" + for key in self.modules: + if key == frm or key.startswith(prefix): + raise BundleParseError( + f"ALIAS {frm} shadows module key {key}; a remapped " + "prefix cannot contain real modules" + ) + def _set_unique(self, line: _Line, field_name: str) -> None: if not line.marker_arg: raise BundleParseError( @@ -152,6 +239,7 @@ def _consume_modules(self, first_marker: _Line | None) -> None: raise BundleParseError( f"line {current.lineno}: MODULE requires a canonical key" ) + self._validate_key(current, key) if key in self.modules: raise BundleParseError( f"line {current.lineno}: duplicate MODULE marker for {key}" @@ -187,16 +275,18 @@ def simulate(text: str) -> dict[str, int]: all_modules = dict(parsed.modules) if main_anchor in all_modules: raise BundleParseError( - f"BUNDLE main={main_anchor} collides with a module key in the same bundle" + f"MAIN {main_anchor} collides with a module key in the same bundle" ) all_modules[main_anchor] = parsed.main_source + # The parser guarantees every key is @-prefixed. Remap sources count as + # known aliases too: a require spelled through one must still resolve + # before the rewrite redirects it. known_aliases: set[str] = {"root"} for key in all_modules: - if not key.startswith("@"): - raise BundleParseError(f"module key does not start with @: {key}") - alias = key[1:].split("/", 1)[0] - known_aliases.add(alias) + known_aliases.add(key[1:].split("/", 1)[0]) + for frm in parsed.remap: + known_aliases.add(frm[1:].split("/", 1)[0]) body_runs: dict[str, int] = {} cached: set[str] = set() @@ -211,8 +301,8 @@ def run(key: str) -> None: if key not in all_modules: raise BundleParseError(f"required module {key} not in bundle") in_progress.append(key) - for req in REQUIRE_RE.findall(all_modules[key]): - target = resolve(req, key, known_aliases) + for req in iter_requires(all_modules[key]): + target = apply_remap(resolve(req, key, known_aliases), parsed.remap) run(target) in_progress.pop() body_runs[key] = body_runs.get(key, 0) + 1 diff --git a/tools/slua_bundle/tests/test_bundler.py b/tools/slua_bundle/tests/test_bundler.py index af69e4bd..fd46a7c9 100644 --- a/tools/slua_bundle/tests/test_bundler.py +++ b/tools/slua_bundle/tests/test_bundler.py @@ -9,6 +9,8 @@ AliasCollisionWarning, AmbiguousResolutionError, DepthExceededError, + MainFileError, + MainOutsideRootError, MarkerInjectionError, MemoryFS, ModuleCountExceededError, @@ -76,6 +78,7 @@ def test_bundle_alias_pointing_outside_project_root_is_fine(): def test_bundle_includes_wally_package(): # Bundler reads only the project-root .luaurc. Wally writes its alias # declarations there, which is sufficient for MAIN to resolve @SomeLib. + # Alias names are case-insensitive; canonical keys use the folded form. vfs = MemoryFS.from_dict({ "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), "/project/src/Main.luau": 'require("@SomeLib/util")', @@ -93,7 +96,7 @@ def test_bundle_includes_wally_package(): -- !!LUABUNDLE:MAIN @root/src/Main -- !!LUABUNDLE:BODY require("@SomeLib/util") --- !!LUABUNDLE:MODULE @SomeLib/util +-- !!LUABUNDLE:MODULE @somelib/util return {} """ @@ -303,15 +306,103 @@ def test_bundle_two_user_aliases_at_identical_target_warns(): "/project/src/Main.luau": 'require("@Alpha/util")', "/elsewhere/util.luau": "return {}", }) - with pytest.warns(AliasCollisionWarning, match="Alpha"): + with pytest.warns(AliasCollisionWarning, match="alpha"): text = bundle( vfs, PurePosixPath("/project"), PurePosixPath("/project/src/Main.luau"), "myhud", ) - assert "-- !!LUABUNDLE:MODULE @Alpha/util" in text - assert "-- !!LUABUNDLE:MODULE @Zeta/util" not in text + assert "-- !!LUABUNDLE:MODULE @alpha/util" in text + assert "@zeta" not in text + # The require already spells the winning alias; no remap needed. + assert "ALIAS" not in text + + +# ---- ALIAS emission and the MAIN @root invariant ----------------------------- + + +def test_bundle_emits_alias_line_for_root_shadowed_spelling(): + """The require's syntactic key diverges from the canonical key; the + divergence is emitted as one header ALIAS prefix mapping.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"myproj": "."}), + "/project/Main.luau": 'require("@myproj/util")', + "/project/util.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + ) + assert text == """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:ALIAS @myproj @root +-- !!LUABUNDLE:BODY +require("@myproj/util") +-- !!LUABUNDLE:MODULE @root/util +return {} +""" + + +def test_bundle_main_outside_project_root_rejected(): + """MAIN keys are @root-relative by invariant; an entry script outside + project_root (even one covered by an alias) is a misconfigured root.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"shared": "/elsewhere"}), + "/elsewhere/Main.luau": "return 1", + }) + with pytest.raises(MainOutsideRootError): + bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/elsewhere/Main.luau"), + ) + + +def test_bundle_missing_main_rejected(): + """A nonexistent entry script is a clean BundleError, not a traceback.""" + vfs = MemoryFS.from_dict({}) + with pytest.raises(MainFileError, match="does not exist"): + bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + ) + + +def test_bundle_non_luau_main_rejected(): + """The format resolves .luau only; a .lua MAIN would silently mutate to + .luau on an extract round-trip.""" + vfs = MemoryFS.from_dict({ + "/project/Main.lua": "return 1", + }) + with pytest.raises(MainFileError, match="must be a .luau file"): + bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.lua"), + ) + + +def test_bundle_main_under_deeper_alias_stays_root_relative(): + """A deeper alias over MAIN's directory does not re-key MAIN; the body's + relative requires diverge syntactic-vs-canonical instead, handled by the + uniform ALIAS rule.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"src": "src"}), + "/project/src/Main.luau": 'require("./lib/foo")', + "/project/src/lib/foo.luau": "return {}", + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/src/Main.luau"), + ) + assert "-- !!LUABUNDLE:MAIN @root/src/Main" in text + assert "-- !!LUABUNDLE:ALIAS @root/src @src" in text + assert "-- !!LUABUNDLE:MODULE @src/lib/foo" in text # ---- Last-resort resolver (existing_bundle fallback) ------------------------ @@ -349,7 +440,7 @@ def test_existing_bundle_used_when_alias_missing_from_luaurc(): -- !!LUABUNDLE:MAIN @root/Main -- !!LUABUNDLE:BODY require("@SomeLib/util") --- !!LUABUNDLE:MODULE @SomeLib/util +-- !!LUABUNDLE:MODULE @somelib/util return "vendored" """ vfs = MemoryFS.from_dict({ @@ -361,7 +452,7 @@ def test_existing_bundle_used_when_alias_missing_from_luaurc(): PurePosixPath("/project/Main.luau"), existing_bundle=prior, ) - assert "-- !!LUABUNDLE:MODULE @SomeLib/util" in text + assert "-- !!LUABUNDLE:MODULE @somelib/util" in text assert 'return "vendored"' in text @@ -381,6 +472,35 @@ def test_disk_preferred_over_existing_bundle_when_both_present(): assert 'return "from prior bundle"' not in text +def test_existing_bundle_alias_table_covers_new_spellings(): + """Fallback lookup goes through the prior bundle's ALIAS table as a + prefix mapping, so spellings never observed at original bundle time + still resolve.""" + prior = """\ +-- !!LUABUNDLE:VERSION 1 +-- !!LUABUNDLE:MAIN @root/Main +-- !!LUABUNDLE:ALIAS @zeta @alpha +-- !!LUABUNDLE:BODY +require("@zeta/util") +-- !!LUABUNDLE:MODULE @alpha/util +return "util" +-- !!LUABUNDLE:MODULE @alpha/other +return "other" +""" + vfs = MemoryFS.from_dict({ + "/project/Main.luau": 'require("@zeta/other")', + }) + text = bundle( + vfs, + PurePosixPath("/project"), + PurePosixPath("/project/Main.luau"), + existing_bundle=prior, + ) + assert "-- !!LUABUNDLE:MODULE @alpha/other" in text + assert "-- !!LUABUNDLE:ALIAS @zeta @alpha" in text + assert 'return "other"' in text + + def test_no_resolver_succeeded_raises(): """Neither disk nor existing bundle has the required module.""" vfs = MemoryFS.from_dict({ diff --git a/tools/slua_bundle/tests/test_canonicalize.py b/tools/slua_bundle/tests/test_canonicalize.py index 804d4777..ee07ea3e 100644 --- a/tools/slua_bundle/tests/test_canonicalize.py +++ b/tools/slua_bundle/tests/test_canonicalize.py @@ -1,5 +1,6 @@ """Canonicalize physical paths to alias-prefixed canonical keys.""" +import json import warnings from pathlib import PurePosixPath @@ -40,10 +41,11 @@ def test_init_luau_strips_to_directory(): def test_wally_alias_at_project_root_beats_root(): - """Top-level .luaurc declares @SomeLib; files inside it canonicalize under @SomeLib. + """Top-level .luaurc declares @SomeLib; files inside it canonicalize under @somelib. Most-specific covering alias wins (more path parts), so files under - /project/Packages/SomeLib/ get @SomeLib/... rather than @root/Packages/SomeLib/... + /project/Packages/SomeLib/ get @somelib/... rather than @root/Packages/SomeLib/... + Alias names fold to lowercase in canonical keys. """ vfs = MemoryFS.from_dict({ "/project/.luaurc": luaurc({"SomeLib": "Packages/SomeLib"}), @@ -52,8 +54,8 @@ def test_wally_alias_at_project_root_beats_root(): "/project/src/HudController.luau": "", }) project_root = PurePosixPath("/project") - assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/util.luau"), project_root) == "@SomeLib/util" - assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/init.luau"), project_root) == "@SomeLib" + assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/util.luau"), project_root) == "@somelib/util" + assert canonicalize(vfs, PurePosixPath("/project/Packages/SomeLib/init.luau"), project_root) == "@somelib" assert canonicalize(vfs, PurePosixPath("/project/src/HudController.luau"), project_root) == "@root/src/HudController" @@ -113,6 +115,56 @@ def test_jsonc_line_comments_and_trailing_commas_supported(): assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@shared/util" +def test_jsonc_lua_line_comments_supported(): + """Upstream tokenizes .luaurc with the Luau lexer, which skips Lua-style + -- line comments; mirror that.""" + config = """ + { + -- Lua-style comment + "aliases": { + "shared": "shared-modules", -- trailing comment after value + }, + } + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/shared-modules/util.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@shared/util" + + +def test_jsonc_lua_long_comments_supported(): + """The upstream lexer skips --[[ ]] long comments; mirror it, including + the =-level rule (`]]` does not close a `--[==[` comment) and comments + spanning lines or containing quotes.""" + config = """ + {--[[ multi + line " quote // inside ]] + "aliases": {--[==[ level ]] still open ]==] "shared": "shared-modules",--[[x]] + }, + } + """ + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/shared-modules/util.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@shared/util" + + +def test_jsonc_unterminated_long_comment_rejected(): + """An unterminated --[[ is a lexer error, not a silent strip-to-EOL.""" + config = '{"aliases": {}} --[[ oops' + vfs = MemoryFS.from_dict({ + "/project/.luaurc": config, + "/project/Main.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.raises(InvalidLuaurcError, match="unterminated long comment"): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) + + def test_jsonc_block_comments_rejected(): """Luau's parser does not accept /* */; the prototype mirrors that.""" config = '{"aliases": {/* not allowed */ "shared": "shared-modules"}}' @@ -147,6 +199,17 @@ def test_reserved_self_alias_in_luaurc_rejected(): canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) +def test_reserved_sl_alias_in_luaurc_rejected(): + """.luaurc may not declare `sl`; the @sl namespace is reserved for future use.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"sl": "shared-modules"}), + "/project/Main.luau": "", + }) + project_root = PurePosixPath("/project") + with pytest.raises(ReservedAliasError): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), project_root) + + def test_user_alias_at_project_root_loses_to_root_silently(): """User declares an alias targeting project_root; @root wins canonicalization, no warning.""" vfs = MemoryFS.from_dict({ @@ -160,14 +223,62 @@ def test_user_alias_at_project_root_loses_to_root_silently(): def test_two_user_aliases_at_identical_target_warns_and_picks_ascii_first(): - """Two non-root aliases at the same target dir: ASCII-first wins, warning emitted.""" + """Two non-root aliases at the same target dir: ASCII-first wins (over + folded names), warning emitted.""" vfs = MemoryFS.from_dict({ "/project/.luaurc": luaurc({"Zeta": "/elsewhere", "Alpha": "/elsewhere"}), "/elsewhere/util.luau": "", }) project_root = PurePosixPath("/project") - with pytest.warns(AliasCollisionWarning, match="Alpha"): - assert canonicalize(vfs, PurePosixPath("/elsewhere/util.luau"), project_root) == "@Alpha/util" + with pytest.warns(AliasCollisionWarning, match="alpha"): + assert canonicalize(vfs, PurePosixPath("/elsewhere/util.luau"), project_root) == "@alpha/util" + + +def test_reserved_alias_check_is_case_insensitive(): + """'Root', 'SELF', and 'SL' fold to the reserved names and are rejected.""" + for name in ("Root", "SELF", "SL"): + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({name: "shared-modules"}), + "/project/Main.luau": "", + }) + with pytest.raises(ReservedAliasError): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), PurePosixPath("/project")) + + +def test_aliases_colliding_after_case_fold_rejected(): + """Two .luaurc spellings of one (case-insensitive) alias name is a config error.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"Pkg": "a", "pkg": "b"}), + "/project/Main.luau": "", + }) + with pytest.raises(InvalidLuaurcError, match="case folding"): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), PurePosixPath("/project")) + + +def test_alias_name_charset_matches_upstream(): + """Mirror upstream isValidAlias (Config.cpp): ASCII alphanumerics plus + '-', '_', '.'; no path separators, no '.'/'..'; the leading '@' that + upstream tolerates-but-breaks-on is rejected outright. Also covers + json.loads' lone-surrogate escapes, which fail the charset before the + case fold could choke on them.""" + bad_names = ["my proj", "wei/rd", "back\\slash", ".", "..", "@Pkg", "", "café", "bad\ud800name"] + for name in bad_names: + vfs = MemoryFS.from_dict({ + "/project/.luaurc": '{"aliases": {' + json.dumps(name) + ': "shared-modules"}}', + "/project/Main.luau": "", + }) + with pytest.raises(InvalidLuaurcError, match="invalid alias name"): + canonicalize(vfs, PurePosixPath("/project/Main.luau"), PurePosixPath("/project")) + + +def test_alias_name_allows_upstream_charset(): + """Dots, dashes, underscores, digits are all legal alias-name chars.""" + vfs = MemoryFS.from_dict({ + "/project/.luaurc": luaurc({"pkg-v2.1_beta": "shared-modules"}), + "/project/shared-modules/util.luau": "", + }) + project_root = PurePosixPath("/project") + assert canonicalize(vfs, PurePosixPath("/project/shared-modules/util.luau"), project_root) == "@pkg-v2.1_beta/util" def test_unnormalized_project_root_normalizes(): diff --git a/tools/slua_bundle/tests/test_disk_fs.py b/tools/slua_bundle/tests/test_disk_fs.py index fde6dfe7..b7ef844a 100644 --- a/tools/slua_bundle/tests/test_disk_fs.py +++ b/tools/slua_bundle/tests/test_disk_fs.py @@ -2,7 +2,9 @@ import pathlib -from slua_bundle import DiskFS, bundle +import pytest + +from slua_bundle import DiskFS, NoResolverError, SourceDecodeError, bundle def _write(root: pathlib.Path, rel: str, content: str) -> pathlib.Path: @@ -37,6 +39,46 @@ def test_bundle_smoke(tmp_path: pathlib.Path): """ +def test_read_write_utf8_round_trip(tmp_path: pathlib.Path): + """DiskFS pins utf-8 explicitly; the locale default (cp1252 on Windows) + must not influence what lands on disk.""" + fs = DiskFS(tmp_path) + content = 'return "café ☃"\n' + fs.write(tmp_path / "x.luau", content) + assert (tmp_path / "x.luau").read_bytes() == content.encode("utf-8") + assert fs.read(tmp_path / "x.luau") == content + + +def test_read_invalid_utf8_raises_bundle_error(tmp_path: pathlib.Path): + """A non-utf-8 source surfaces as a BundleError, not a raw traceback.""" + bad = tmp_path / "bad.luau" + bad.write_bytes(b'return "\xff\xfe"') + fs = DiskFS(tmp_path) + with pytest.raises(SourceDecodeError, match="not valid UTF-8"): + fs.read(bad) + + +def test_is_file_requires_exact_case(tmp_path: pathlib.Path): + """Scripts run under Linux; a wrong-case path must behave as not-found + even when the host filesystem (macOS, Windows) would open it.""" + _write(tmp_path, "lib/foo.luau", "return 1") + fs = DiskFS(tmp_path) + assert fs.is_file(tmp_path / "lib" / "foo.luau") + assert not fs.is_file(tmp_path / "lib" / "Foo.luau") + assert not fs.is_file(tmp_path / "Lib" / "foo.luau") + + +def test_bundle_wrong_case_require_is_not_found(tmp_path: pathlib.Path): + """require("./lib/Foo") with foo.luau on disk fails resolution on every + host, matching the Linux runtime.""" + _write(tmp_path, "src/Main.luau", 'require("./lib/Foo")') + _write(tmp_path, "src/lib/foo.luau", "return 1") + fs = DiskFS(tmp_path) + project_root = (tmp_path / "src").resolve() + with pytest.raises(NoResolverError): + bundle(fs, project_root, project_root / "Main.luau") + + def test_iter_files_skips_dotfiles_except_luaurc(tmp_path: pathlib.Path): """Hidden files (other than .luaurc) and hidden directories are pruned.""" _write(tmp_path, "src/Main.luau", "return 1") diff --git a/tools/slua_bundle/tests/test_e2e.py b/tools/slua_bundle/tests/test_e2e.py index 8f6c0e4c..1fcb6cb6 100644 --- a/tools/slua_bundle/tests/test_e2e.py +++ b/tools/slua_bundle/tests/test_e2e.py @@ -69,8 +69,8 @@ def test_wally_style_dedup(): "myhud", ) body_runs = simulate(text) - assert body_runs["@SomeLib/util"] == 1 - assert body_runs["@SomeLib"] == 1 + assert body_runs["@somelib/util"] == 1 + assert body_runs["@somelib"] == 1 assert body_runs["@root/src/Main"] == 1 diff --git a/tools/slua_bundle/tests/test_extractor.py b/tools/slua_bundle/tests/test_extractor.py index 644c599e..3110b8b4 100644 --- a/tools/slua_bundle/tests/test_extractor.py +++ b/tools/slua_bundle/tests/test_extractor.py @@ -11,6 +11,8 @@ parse_bundle, ) from slua_bundle.extractor import ( + ExtractAliasError, + ExtractAmbiguityError, ExtractClobberError, ExtractCollisionError, ExtractMissingMainError, @@ -117,13 +119,155 @@ def test_extract_round_trips_with_external_alias_in_memory(): out = MemoryFS() extract_to_dir(parse_bundle(original), out, PurePosixPath("/extracted")) - assert out.is_file(PurePosixPath("/extracted/SomeLib/util.luau")) + assert out.is_file(PurePosixPath("/extracted/somelib/util.luau")) assert out.is_file(PurePosixPath("/extracted/.luaurc")) rebuilt = bundle(out, PurePosixPath("/extracted"), PurePosixPath("/extracted/Main.luau"), "myhud") assert rebuilt == original +def test_extract_root_shadow_alias_round_trips(): + """A bare `ALIAS @myproj @root` extracts as the .luaurc entry + {"myproj": "."} and re-bundles byte-for-byte.""" + src = MemoryFS.from_dict({ + "/proj/.luaurc": json.dumps({"aliases": {"myproj": "."}}), + "/proj/Main.luau": 'require("@myproj/util")\n', + "/proj/util.luau": "return {}\n", + }) + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/Main.luau")) + assert "-- !!LUABUNDLE:ALIAS @myproj @root" in original + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/x")) + luaurc = json.loads(out.read(PurePosixPath("/x/.luaurc"))) + assert luaurc == {"aliases": {"myproj": "."}} + + rebuilt = bundle(out, PurePosixPath("/x"), PurePosixPath("/x/Main.luau")) + assert rebuilt == original + + +def test_extract_losing_alias_round_trips(): + """`ALIAS @zeta @alpha` becomes a second .luaurc name for alpha's dir; + re-bundling re-derives the same mapping (with the same collision + warning the original bundle produced).""" + src = MemoryFS.from_dict({ + "/proj/.luaurc": json.dumps({"aliases": {"zeta": "/elsewhere", "alpha": "/elsewhere"}}), + "/proj/Main.luau": 'require("@zeta/util")\n', + "/elsewhere/util.luau": "return {}\n", + }) + with pytest.warns(UserWarning): + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/Main.luau")) + assert "-- !!LUABUNDLE:ALIAS @zeta @alpha" in original + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/x")) + luaurc = json.loads(out.read(PurePosixPath("/x/.luaurc"))) + assert luaurc == {"aliases": {"alpha": "alpha", "zeta": "alpha"}} + assert out.is_file(PurePosixPath("/x/alpha/util.luau")) + + with pytest.warns(UserWarning): + rebuilt = bundle(out, PurePosixPath("/x"), PurePosixPath("/x/Main.luau")) + assert rebuilt == original + + +def test_extract_nested_alias_pin_round_trips(): + """A tailed `ALIAS @alpha/sub @util` pins util's extract dir inside + alpha's so most-specific-alias canonicalization re-derives it.""" + src = MemoryFS.from_dict({ + "/proj/.luaurc": json.dumps({"aliases": {"alpha": "/e", "util": "/e/sub"}}), + "/proj/Main.luau": 'require("@alpha/x")\n', + "/e/x.luau": 'require("./sub/helpers")\n', + "/e/sub/helpers.luau": "return {}\n", + }) + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/Main.luau")) + assert "-- !!LUABUNDLE:ALIAS @alpha/sub @util" in original + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/x")) + luaurc = json.loads(out.read(PurePosixPath("/x/.luaurc"))) + assert luaurc == {"aliases": {"alpha": "alpha", "util": "alpha/sub"}} + assert out.is_file(PurePosixPath("/x/alpha/x.luau")) + assert out.is_file(PurePosixPath("/x/alpha/sub/helpers.luau")) + + rebuilt = bundle(out, PurePosixPath("/x"), PurePosixPath("/x/Main.luau")) + assert rebuilt == original + + +def test_extract_main_under_deeper_alias_round_trips(): + """MAIN stays root-relative even when an alias covers its directory; + extraction places it at its root-relative path and the ALIAS line + re-derives on re-bundle.""" + src = MemoryFS.from_dict({ + "/proj/.luaurc": json.dumps({"aliases": {"src": "src"}}), + "/proj/src/Main.luau": 'require("./lib/foo")\n', + "/proj/src/lib/foo.luau": "return {}\n", + }) + original = bundle(src, PurePosixPath("/proj"), PurePosixPath("/proj/src/Main.luau")) + assert "-- !!LUABUNDLE:MAIN @root/src/Main" in original + assert "-- !!LUABUNDLE:ALIAS @root/src @src" in original + + out = MemoryFS() + extract_to_dir(parse_bundle(original), out, PurePosixPath("/x")) + assert out.is_file(PurePosixPath("/x/src/Main.luau")) + assert out.is_file(PurePosixPath("/x/src/lib/foo.luau")) + + rebuilt = bundle(out, PurePosixPath("/x"), PurePosixPath("/x/src/Main.luau")) + assert rebuilt == original + + +def test_extract_rejects_root_key_inside_alias_dir(fs: MemoryFS): + """A @root key whose path passes through an alias's extract dir would + re-key under that alias on re-bundle; relocation made the layout + inexpressible.""" + parsed = ParsedBundle( + fields={"version": "1", "main": "@root/Main"}, + main_source="\n", + modules={ + "@root/pkg/a": "A\n", + "@pkg/b": "B\n", + }, + ) + with pytest.raises(ExtractAliasError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_rejects_root_key_inside_declared_nonmodule_alias_dir(fs: MemoryFS): + """A tailed remap source alias gets a .luaurc directory without owning + any module keys; @root files landing inside it would still re-key on + re-bundle, so the shadow check must cover declared dirs, not just + module-key aliases.""" + parsed = ParsedBundle( + fields={"version": "1", "main": "@root/Main"}, + main_source="\n", + modules={ + "@util/helpers": "H\n", + "@root/zeta/x": "X\n", + }, + remap={"@zeta/sub": "@util"}, + ) + with pytest.raises(ExtractAliasError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + +def test_extract_rejects_leaf_and_init_ambiguity(fs: MemoryFS): + """@root/pkg (-> pkg.luau) plus bare @pkg (-> pkg/init.luau): a re-bundle + require of @root/pkg would hit AmbiguousResolutionError, so the layout + is not hermetic.""" + parsed = ParsedBundle( + fields={"version": "1", "main": "@root/Main"}, + main_source="\n", + modules={ + "@root/pkg": "leaf\n", + "@pkg": "init\n", + }, + ) + with pytest.raises(ExtractAmbiguityError): + extract_to_dir(parsed, fs, OUT) + assert list(fs.iter_files()) == [] + + def test_extract_refuses_collision(fs: MemoryFS): """Bare alias key and explicit `init` leaf both target init.luau.""" parsed = ParsedBundle( diff --git a/tools/slua_bundle/tests/test_resolver.py b/tools/slua_bundle/tests/test_resolver.py index 8f1c01ba..156b4af5 100644 --- a/tools/slua_bundle/tests/test_resolver.py +++ b/tools/slua_bundle/tests/test_resolver.py @@ -7,17 +7,39 @@ InvalidPathComponentError, RelativeRequireWithoutAnchorError, RequireEscapesAliasError, + ReservedAliasError, UnknownAliasError, resolve, ) -KNOWN = {"myhud", "SomeLib"} +KNOWN = {"myhud", "somelib"} def test_absolute_alias_passthrough(): assert resolve("@myhud/lib/foo", "@myhud/Main", KNOWN) == "@myhud/lib/foo" +def test_alias_matching_folds_case(): + """Alias names are case-insensitive (upstream Config.cpp / + RequireNavigator.cpp fold to lowercase); the resolved key is folded.""" + assert resolve("@SomeLib/util", None, KNOWN) == "@somelib/util" + assert resolve("@MyHud/lib/foo", "@root/Main", KNOWN) == "@myhud/lib/foo" + + +def test_self_matching_folds_case(): + """@SELF folds to the reserved @self before comparison.""" + assert resolve("@SELF/lib/foo", "@myhud", KNOWN) == "@myhud/lib/foo" + + +def test_sl_namespace_reserved(): + """@sl/ is reserved for future use -- rejected in any casing, even when + a 'sl' alias somehow reaches the known set.""" + with pytest.raises(ReservedAliasError): + resolve("@sl/x", "@root/Main", KNOWN | {"sl"}) + with pytest.raises(ReservedAliasError): + resolve("@SL/x", None, KNOWN) + + def test_self_from_init_module_resolves_to_alias_root(): """@self/x from an init.luau-style anchor (no leaf in the key) lands at the alias root. This is the realistic / useful case -- @self in an init.luau is sibling-like. @@ -27,7 +49,7 @@ def test_self_from_init_module_resolves_to_alias_root(): def test_self_from_alias_root_with_no_leaf(): """Same as above with a different alias -- explicit pin on Luau's behavior.""" - assert resolve("@self/util", "@SomeLib", KNOWN) == "@SomeLib/util" + assert resolve("@self/util", "@somelib", KNOWN) == "@somelib/util" def test_self_from_leaf_includes_filename_in_path(): @@ -78,7 +100,9 @@ def test_escape_via_self_dotdot(): def test_relative_without_anchor_rejected(): - """MAIN without main= cannot use relative requires.""" + """resolve() with no anchor key rejects relative requires. Library-level + invariant only: the bundle parser mandates MAIN, so every require in a + parsed bundle has an anchor.""" with pytest.raises(RelativeRequireWithoutAnchorError): resolve("./x", None, KNOWN) diff --git a/tools/slua_bundle/tests/test_runtime.py b/tools/slua_bundle/tests/test_runtime.py index 3391df9c..564cf793 100644 --- a/tools/slua_bundle/tests/test_runtime.py +++ b/tools/slua_bundle/tests/test_runtime.py @@ -30,13 +30,13 @@ def _v1_header(project: str | None = "myhud", main: str | None = None) -> tuple[ def test_parse_bundle_extracts_fields_and_modules(): text = _make( - *_v1_header(project="myhud", main="@myhud/Main"), + *_v1_header(project="myhud", main="@root/Main"), "main body line 1", "-- !!LUABUNDLE:MODULE @myhud/lib/foo", "foo body", ) parsed = parse_bundle(text) - assert parsed.fields == {"version": "1", "project": "myhud", "main": "@myhud/Main"} + assert parsed.fields == {"version": "1", "project": "myhud", "main": "@root/Main"} assert parsed.main_source == "main body line 1\n" assert parsed.modules == {"@myhud/lib/foo": "foo body\n"} @@ -76,8 +76,8 @@ def test_parse_bundle_rejects_duplicate_main(): text = _make( "-- !!LUABUNDLE:VERSION 1", "-- !!LUABUNDLE:PROJECT myhud", - "-- !!LUABUNDLE:MAIN @myhud/A", - "-- !!LUABUNDLE:MAIN @myhud/B", + "-- !!LUABUNDLE:MAIN @root/A", + "-- !!LUABUNDLE:MAIN @root/B", "-- !!LUABUNDLE:BODY", ) with pytest.raises(BundleParseError, match="duplicate MAIN"): @@ -132,7 +132,7 @@ def test_parse_bundle_rejects_missing_main(): def test_parse_bundle_rejects_unexpected_marker_after_body(): text = _make( - *_v1_header(main="@myhud/Main"), + *_v1_header(main="@root/Main"), "main body", "-- !!LUABUNDLE:FUTURE-THING something", "-- !!LUABUNDLE:MODULE @myhud/x", @@ -154,25 +154,25 @@ def test_parse_bundle_rejects_body_before_body_marker(): def test_simulate_runs_each_module_body_once_for_dedup(): - """Dedup via canonical key. MAIN is init.luau-style (canonical @myhud, no leaf) + """Dedup via canonical key. MAIN is init.luau-style (canonical @root, no leaf) so @self/x is sibling-like; bar uses ./foo to reach the same module.""" text = _make( - *_v1_header(project="myhud", main="@myhud"), + *_v1_header(project="myhud", main="@root"), 'require("@self/lib/foo"); require("@self/lib/bar")', - "-- !!LUABUNDLE:MODULE @myhud/lib/foo", + "-- !!LUABUNDLE:MODULE @root/lib/foo", 'return "foo"', - "-- !!LUABUNDLE:MODULE @myhud/lib/bar", + "-- !!LUABUNDLE:MODULE @root/lib/bar", 'require("./foo"); return "bar"', ) body_runs = simulate(text) - assert body_runs["@myhud/lib/foo"] == 1 - assert body_runs["@myhud/lib/bar"] == 1 - assert body_runs["@myhud"] == 1 + assert body_runs["@root/lib/foo"] == 1 + assert body_runs["@root/lib/bar"] == 1 + assert body_runs["@root"] == 1 def test_simulate_circular_dependency_detected(): text = _make( - *_v1_header(project="p", main="@p/Main"), + *_v1_header(project="p", main="@root/Main"), 'require("@p/A")', "-- !!LUABUNDLE:MODULE @p/A", 'require("@p/B")', @@ -188,10 +188,10 @@ def test_simulate_circular_dependency_detected(): def test_simulate_main_as_import_target_is_a_cycle(): """A module requiring MAIN's canonical key creates a cycle.""" text = _make( - *_v1_header(project="p", main="@p/Main"), + *_v1_header(project="p", main="@root/Main"), 'require("@p/A")', "-- !!LUABUNDLE:MODULE @p/A", - 'require("@p/Main")', + 'require("@root/Main")', ) with pytest.raises(CircularDependencyError): simulate(text) @@ -200,30 +200,30 @@ def test_simulate_main_as_import_target_is_a_cycle(): def test_simulate_main_with_anchor_accepts_relative_and_absolute(): """init.luau-style MAIN: @self/x and ./x both land at the alias root and dedup.""" text = _make( - *_v1_header(project="p", main="@p"), + *_v1_header(project="p", main="@root"), 'require("@self/lib/foo"); require("./lib/foo")', - "-- !!LUABUNDLE:MODULE @p/lib/foo", + "-- !!LUABUNDLE:MODULE @root/lib/foo", 'return "foo"', ) body_runs = simulate(text) - assert body_runs["@p/lib/foo"] == 1 + assert body_runs["@root/lib/foo"] == 1 def test_simulate_traverses_single_quoted_requires(): """simulate() walks single-quoted requires, same as the bundler.""" text = _make( - *_v1_header(project="p", main="@p/Main"), + *_v1_header(project="p", main="@root/Main"), "require('./lib/foo')", - "-- !!LUABUNDLE:MODULE @p/lib/foo", + "-- !!LUABUNDLE:MODULE @root/lib/foo", 'return "foo"', ) body_runs = simulate(text) - assert body_runs.get("@p/lib/foo") == 1 + assert body_runs.get("@root/lib/foo") == 1 def test_simulate_bare_identifier_rejected(): text = _make( - *_v1_header(project="p", main="@p/Main"), + *_v1_header(project="p", main="@root/Main"), 'require("foo")', ) with pytest.raises(BareIdentifierError): @@ -232,8 +232,220 @@ def test_simulate_bare_identifier_rejected(): def test_simulate_unknown_alias_rejected(): text = _make( - *_v1_header(project="p", main="@p/Main"), + *_v1_header(project="p", main="@root/Main"), 'require("@nope/x")', ) with pytest.raises(UnknownAliasError): simulate(text) + + +# ---- MAIN/MODULE key validation ---------------------------------------------- + + +def test_parse_bundle_rejects_main_not_under_root(): + """MAIN is @root-relative by format invariant.""" + text = _make( + *_v1_header(main="@app/Main"), + "main body", + ) + with pytest.raises(BundleParseError, match="must be under @root"): + parse_bundle(text) + + +def test_parse_bundle_rejects_module_key_without_at(): + text = _make( + *_v1_header(main="@root/Main"), + "main body", + "-- !!LUABUNDLE:MODULE lib/foo", + "foo body", + ) + with pytest.raises(BundleParseError, match="must start with @"): + parse_bundle(text) + + +def test_parse_bundle_rejects_module_key_with_uppercase_alias(): + """Canonical keys carry the case-folded alias spelling.""" + text = _make( + *_v1_header(main="@root/Main"), + "main body", + "-- !!LUABUNDLE:MODULE @SomeLib/util", + "util body", + ) + with pytest.raises(BundleParseError, match="lowercase canonical"): + parse_bundle(text) + + +def test_parse_bundle_rejects_module_key_with_reserved_self(): + text = _make( + *_v1_header(main="@root/Main"), + "main body", + "-- !!LUABUNDLE:MODULE @self/x", + "x body", + ) + with pytest.raises(BundleParseError, match="reserved alias @self"): + parse_bundle(text) + + +def test_parse_bundle_rejects_module_key_with_reserved_sl(): + """The @sl namespace is reserved for future use; no key may claim it.""" + text = _make( + *_v1_header(main="@root/Main"), + "main body", + "-- !!LUABUNDLE:MODULE @sl/x", + "x body", + ) + with pytest.raises(BundleParseError, match="reserved alias @sl"): + parse_bundle(text) + + +# ---- ALIAS directive ---------------------------------------------------------- + + +def _alias_header(*alias_lines: str) -> tuple[str, ...]: + return ( + "-- !!LUABUNDLE:VERSION 1", + "-- !!LUABUNDLE:MAIN @root/Main", + *alias_lines, + "-- !!LUABUNDLE:BODY", + ) + + +def test_simulate_resolves_through_alias_remap(): + """A require spelled through a remapped prefix lands on the canonical module.""" + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @myproj @root"), + 'require("@myproj/util")', + "-- !!LUABUNDLE:MODULE @root/util", + "return {}", + ) + body_runs = simulate(text) + assert body_runs == {"@root/Main": 1, "@root/util": 1} + + +def test_simulate_alias_remap_longest_prefix_wins(): + text = _make( + *_alias_header( + "-- !!LUABUNDLE:ALIAS @a @b", + "-- !!LUABUNDLE:ALIAS @a/sub @c", + ), + 'require("@a/x"); require("@a/sub/y")', + "-- !!LUABUNDLE:MODULE @b/x", + "return 1", + "-- !!LUABUNDLE:MODULE @c/y", + "return 2", + ) + body_runs = simulate(text) + assert body_runs["@b/x"] == 1 + assert body_runs["@c/y"] == 1 + + +def test_parse_bundle_rejects_alias_with_wrong_arity(): + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @myproj"), + "main body", + ) + with pytest.raises(BundleParseError, match="exactly two keys"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_involving_self(): + for line in ( + "-- !!LUABUNDLE:ALIAS @self @root", + "-- !!LUABUNDLE:ALIAS @self/x @root", + "-- !!LUABUNDLE:ALIAS @myproj @self", + ): + text = _make(*_alias_header(line), "main body") + with pytest.raises(BundleParseError, match="reserved alias @self"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_remapping_bare_root(): + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @root @elsewhere"), + "main body", + ) + with pytest.raises(BundleParseError, match="may not remap @root"): + parse_bundle(text) + + +def test_parse_bundle_allows_alias_under_root_as_source(): + """@root/sub as a remap source is the relative-crossover case.""" + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @root/sub @util"), + 'require("./sub/helpers")', + "-- !!LUABUNDLE:MODULE @util/helpers", + "return {}", + ) + body_runs = simulate(text) + assert body_runs["@util/helpers"] == 1 + + +def test_parse_bundle_rejects_alias_with_tailed_target(): + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @myproj @root/sub"), + "main body", + ) + with pytest.raises(BundleParseError, match="bare @alias"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_mapping_to_itself(): + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @a @a"), + "main body", + ) + with pytest.raises(BundleParseError, match="to itself"): + parse_bundle(text) + + +def test_parse_bundle_rejects_self_nesting_alias(): + """@b/x -> @b claims b's directory contains itself; unsatisfiable.""" + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @b/x @b"), + "main body", + ) + with pytest.raises(BundleParseError, match="nests an alias inside itself"): + parse_bundle(text) + + +def test_parse_bundle_rejects_duplicate_alias_source(): + text = _make( + *_alias_header( + "-- !!LUABUNDLE:ALIAS @a @b", + "-- !!LUABUNDLE:ALIAS @a @c", + ), + "main body", + ) + with pytest.raises(BundleParseError, match="duplicate ALIAS"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_with_uppercase_key(): + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @MyProj @root"), + "main body", + ) + with pytest.raises(BundleParseError, match="lowercase canonical"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_shadowing_module_key(): + """A remapped prefix cannot contain real modules.""" + text = _make( + *_alias_header("-- !!LUABUNDLE:ALIAS @lib @other"), + "main body", + "-- !!LUABUNDLE:MODULE @lib/x", + "x body", + ) + with pytest.raises(BundleParseError, match="shadows module key"): + parse_bundle(text) + + +def test_parse_bundle_rejects_alias_after_body(): + text = _make( + *_v1_header(main="@root/Main"), + "main body", + "-- !!LUABUNDLE:ALIAS @a @b", + ) + with pytest.raises(BundleParseError, match="unexpected marker"): + parse_bundle(text) From 7c7eca1a024eb31c70ef763cf86cd65f4648ffd9 Mon Sep 17 00:00:00 2001 From: Harold Cindy <120691094+HaroldCindy@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:08:50 -0700 Subject: [PATCH 7/7] Appease linter again --- tools/slua_bundle/tests/test_bundler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/slua_bundle/tests/test_bundler.py b/tools/slua_bundle/tests/test_bundler.py index fd46a7c9..755ae885 100644 --- a/tools/slua_bundle/tests/test_bundler.py +++ b/tools/slua_bundle/tests/test_bundler.py @@ -378,7 +378,7 @@ def test_bundle_non_luau_main_rejected(): vfs = MemoryFS.from_dict({ "/project/Main.lua": "return 1", }) - with pytest.raises(MainFileError, match="must be a .luau file"): + with pytest.raises(MainFileError, match=r"must be a \.luau file"): bundle( vfs, PurePosixPath("/project"),