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.luaandNevermoreCLIManifestUtils). - ObjectValue cross-DataModel reparenting: When reparenting instances from one deserialized DataModel to another (e.g., in
combine-test-places.lua), 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. - luau-lsp only resolves a dotted module name off
scriptitself: a.globalmodule has a dot in its instance name, so it can only be required by bracket index.require(script["Foo.global"])resolves;require(script.Parent["Foo.global"])reportsTypeError: Unknown require: unsupported path, which failslint:luauunder--!strict. Make the module a child of the script that requires it (this is whyloader/init.luaandNevermoreTestRunnerUtils/init.luaboth own theirs), or cast the call as(require :: any)(...)the way string requires already are. - 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
-
npm installcan't install@quenty/*packages in a pnpm project: some published packages still carry aworkspace:*range in theirdevDependencies(the release tooling rewritesdependenciesbut notdevDependencies), and npm rejects that protocol outright withEUNSUPPORTEDPROTOCOL — Unsupported URL Type "workspace:". pnpm ignores it. This is whynevermore installdetects the project's package manager instead of always shelling out to npm, and why the game/plugin templates install with pnpm. -
Registry search only returns 250 packages per page: there are 300+ published
@quenty/*packages, so a single unpaginatedregistry.npmjs.org/-/v1/searchcall silently misses the tail. Anything validating a package name against that list needs to page withfrom, or query the package directly atregistry.npmjs.org/@quenty%2f<name>. -
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. -
How Open Cloud delivers a task's logs and return value is asserted, not written down here.
tools/nevermore-cli/src/utils/open-cloud/open-cloud-logs.characterization.test.tsruns real tasks against a real place and pins every claim we depend on: that a small run arrives whole and in order, that a large one is truncated to a contiguous tail by a ring buffer whose capacity does not grow with output, that page size counts log entries so no request parameter recovers what was dropped, and that an oversize return value fails the whole task rather than arriving cut short. Run it withNEVERMORE_CHARACTERIZATION=1 npm test -- open-cloud-logsfromtools/nevermore-cli; it is skipped by default because it spends Open Cloud quota. Do not restate its numbers in prose — a measurement copied into a document cannot notice when Roblox changes it, and several of the numbers that used to live here were already stale. The one design consequence worth stating: anything that must be read back exactly belongs in the script's return value, never in its output. -
How jest-lua reports a run is asserted in
src/nevermore-test-runner/src/Server/NevermoreTestResults.spec.lua. Three bugs shipped green out of this logic — counts read offrunCLI's wrapper instead of the innerAggregatedResult, jest-lua'sAggregatedResult.successbeing set to the failure condition rather than negated, andnumFailedTestSuitesdouble-counted by addingnumRuntimeErrorTestSuitesto it. Each is now a test, along with the rule that made them survivable: validate the counts with the invarianttotal == passed + failed + skippedrather than a list of field names, and fail closed on a shape you cannot read, because a missing count reads as0and zero failures over zero tests is spelled exactly like a clean run. -
Documenting a local Luau function with
--[=[ … ]=]breakslint:moonwave. moonwave-extractor treats any--[=[block as a doc comment and rejects one it cannot attach to a class:error: Function requires @within tag. It also aborts on the first diagnostic, so one bad block hides every other. Use plain--[[ … ]]for local helpers and keep--[=[ … ]=]for public members (or add@withinexplicitly, as@propblocks do). -
A structured channel that is plumbed but not flowing looks exactly like one that works. The first version of the results-return path shipped inert: the runner returned its table, the batch runner captured it, the parser preferred it — and because the counts were all zero and nothing said where they came from, the verdict silently fell back to log scraping and every check passed. Both readers now state provenance unconditionally (
countsSource, plus aninfoline naming how many packages returned counts), warn when a run fell back to scraping, and warn when the two channels disagree about the same run's totals. When adding a channel that has a fallback, make using the fallback louder than using the channel. -
--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.