Tooling Gotchas
Would this save someone real debugging time? If you wouldn't warn a teammate about it, don't add it here.
When a section grows to 10+ items, graduate it to its own doc.
Lune
- No
--separator: When spawninglune run script.luau arg1 arg2, do NOT use--between the script path and arguments. Lune passes--through toprocess.args, shifting all arguments by one. - DataModel attributes:
roblox.deserializePlace()returns a DataModel.SetAttributemust be called on a child service (e.g.,game:GetService("Workspace")), not on the DataModel root. - Number attributes serialize as float32:
SetAttribute(name, 123456789)followed byserializePlace()truncates the value to float32, so any integer above 2^24 comes back rounded (123456789 → 123456792). Real Roblox stores number attributes as float64, so this only bites when a value passes through Lune serialization — which the deploy pipeline does. Store IDs and other large integers as string attributes and convert withtonumberon read (seetransform-inject-deploy-metadata.luauandNevermoreCLIManifestUtils). - ObjectValue cross-DataModel reparenting: When reparenting instances from one deserialized DataModel to another (e.g., in
combine-test-places.luau), ObjectValues (which are links to other instances) may or may not survive the move. Reparenting a whole subtree as a unit preserves intra-subtree ObjectValue references in practice, but this behavior is not explicitly guaranteed by Lune's@lune/robloxAPI. If batch tests start failing with nil references, this is the first thing to investigate — the fallback is to resolve broken ObjectValues after reparenting by rebuilding them from Name/path lookups.
Symlinks
- Each package under
src/has anode_modules/directory that is symlinked and recursive. Regex searching or recursive file operations (grep -r,rg,find) can consume excessive memory. Always use--ignoreflags to excludenode_modules, or use targeted file paths.
Linter CLI Tools
- Per-package execution: moonwave-extractor, selene, and other linters run via
npx lerna exec --parallelmust be run per-package, not repo-wide. The recursive symlinkednode_modulesundersrc/will cause them to traverse infinitely and freeze. This is whypackage.jsonusesnpx lerna exec --parallelrather than running the tools at the repo root. Same caution applies when debugging locally. - selene needs the repo config, or every file "fails to parse": running bare
selene srcfrom a package directory reports hundreds ofparse_errors pointing at ordinary Luau type syntax (local x = y :: any→ "expected identifier after:"). The errors are an artifact of the missing config, not of the code. Pass it the way CI does — from the package directory,selene --no-summary --num-threads=1 --config=../../selene.toml src— and runselene generate-roblox-stdfirst if the std file is stale. - CI annotations: The
linting.ymlworkflow emits GitHub Actions annotations vianevermore tools post-lint-results. For the luau-lsp job (which already has pnpm), annotations run in-job. For stylua/selene/moonwave (lightweight Aftman-only jobs), output is uploaded as artifacts and a separatelint-annotationsjob processes them. GitHub caps annotations at 10 per step and 50 per run — the job summary serves as a fallback for large lint failures. - Template CI annotations: Game and plugin templates use a simpler pattern — every linter job posts annotations inline via
npx @quenty/nevermore-cli tools post-lint-results. No artifact relay or separatelint-annotationsjob needed, sincesetup-nodeis sufficient to runnpx(no pnpm install required in the annotation step).
nevermore-cli
-
The watch service and the lock file do not share a version vocabulary. A watch's
baselineVersion, and thecurrentVersionit reports back, are the asset-delivery content hash for the place — that's what the service's Roblox driver reads, and it compares them by string equality.deploy.nevermore.lock.jsonholds the Open Cloud place version (an integer). They are never equal, so handing the lock's number over as a baseline reads as drift on the very first poll and dispatches a rebuild of the build that just shipped. The CLI therefore sends no baseline at all — the service's first poll adopts what it sees, which is what a baseline was for — and treats every version the service reports as an opaque "something moved" token, asking Open Cloud what the place is actually at before deciding to rebuild. Anything comparing a service version against a lock version is wrong even when the types line up. -
--script-textloses everything after the first line when invoked throughnpxon Windows: thenpx.cmdshim truncates a multi-line argument, sonevermore test --cloud --script-text '<line 1>\n<line 2>'silently runs only line 1 (and prints(no output)when line 1 produced none). Either write the script as a single line with;separators, or bypass the shim:node tools/nevermore-cli/dist/nevermore.js test --cloud --script-text '...', which passes newlines through intact.
Claude Code hooks
Committed Claude Code hooks live in .claude/settings.json, backed by scripts in .claude/hooks/. They run only for contributors using Claude Code — not for manual git usage or CI.
- stylua auto-format on edit (
PostToolUse→stylua-format.mjs): after any.lua/.luauedit, the file is formatted in place withstylua.toml. - prettier auto-format on edit (
PostToolUse→prettier-format.mjs): after editing a.ts/.tsx/.js/.jsxfile undertools/, it is formatted in place, matchingnpm run format:ts(root prettier config,--ignore-path .gitignore). - luau type check before push (
PreToolUse(Bash)→luau-lint-before-push.mjs): agit pushrunsnpm run lint:luaufirst and is blocked if it fails. The matcher only fires on a push in command position (start of line or after;/&&/||/|/().
The push hook blocks on any non-zero exit from lint:luau, including luau-lsp lint warnings like LocalShadow — so the tree must stay lint-clean for Claude-driven pushes to succeed.
Rojo
- Nevermore uses a custom fork of Rojo that understands symlinks and turns them into ObjectValues. This is required for development but not for consuming packages.
- Symlink deduplication: When multiple
$pathentries resolve to the same physical filesystem path (common with pnpm workspace links wheresrc/A/node_modules/@quenty/loaderandsrc/B/node_modules/@quenty/loaderboth symlink tosrc/loader), rojo only includes the content once — under whichever tree entry it processes first. The second entry's subtree silently loses those dependencies. This means you cannot combine multiple packages into a single rojo project if they share workspace-linked dependencies. The workaround is to build each package individually with rojo, then merge the outputs using Lune's@lune/robloxAPI (reparenting whole subtrees preserves ObjectValue references within each package). - A package that gains its first dependency needs
src/node_modules.project.json: that nested project is the only thing that pulls a package's ownnode_modulesinto the build. Packages with no dependencies beyondloader/nevermore-test-runnerdon't have the file, so it's easy to add a dependency to one and never notice the file is missing. Nothing catches it:pnpm installlinks the dependency, luau-lsp resolves it off the filesystem, and the build succeeds — the package just ships without its dependencies. The loader then walks up the tree at runtime and usually finds them anyway, because some ancestor package happens to depend on the same thing. The failure only appears when that coincidence ends, and it appears in an unrelated package as[Loader] - "SomeModule" is not available. Copy the file verbatim from any package that has one, then confirm withrojo build <pkg>/default.project.json --output out.rbxlxand grep the output for an instance named after the dependency.