The house style for DependencyControl, for human contributors and AI agents alike. It records how the code is written and structured so new code reads like the code already here. Examples are MoonScript, the language the module sources are written in.
Rules are numbered (ER1, NM3, …) so a review can cite them. The keywords MUST, MUST NOT, SHOULD, and MAY are used in the RFC 2119 sense: MUST is a hard requirement, SHOULD is a strong default with rare and justified exceptions, MAY is a genuine option. Where a rule carries a "why", follow the rule — the rationale is there to help you apply it to cases it doesn't name, not to reopen it.
Scope: this file is about the code. Contributor and agent workflow (verifying changes, changelog entries, dependency installs) and the writing pitfalls agents fall into live in AGENTS.md, which imports this file so both are always in view.
- FMT1. Indentation MUST be two spaces per level, spaces only (no tabs), with LF line endings, a trailing newline, and no trailing whitespace. An
.editorconfigencodes this. - FMT2. Doc comments MUST NOT put a space after the marker:
---Text, not--- Text. Intentional structure inside a doc comment is exempt — bulleted lists (--- * item), indented code examples (--- code), and aligned continuations keep their spacing. - FMT3. Code MUST NOT be column-aligned. Do not pad assignments, table values, or trailing comments into columns; use a single space. Alignment rots the moment a longer entry is added and turns a one-line change into a whole-block diff, and nothing in the toolchain maintains it.
- FMT4. For string composition,
"template"\format argsagainst amsgstemplate SHOULD be the default;"#{...}"interpolation is for ad-hoc composition.string.formatand..concatenation are rare and usually avoidable. - FMT5. In Markdown files (README, docs, this file), each paragraph and list item MUST be a single unwrapped line — no manual line breaks within a paragraph. Hard wrapping produces noisy, ragged diffs on later edits.
- MS1. Prefer the language's idioms, as the existing sources do:
unlessoverif not; postfix conditionals and loop modifiers; list/table comprehensions;x or= valuefor lazy init;@fieldcapture in constructor signatures;tbl[#tbl + 1] = vovertable.insert;withblocks for record mutation;!for nullary calls. - MS2.
or=is a statement, not an expression: a method whose whole body is@field or= valuereturnsnil. You MUST add an explicitreturn @fieldafter a lazy-initor=when the caller expects a value. - MS3.
@@field arg/@field argcompile to colon calls (self.__class:field(arg)/self:field(arg)), passing an implicit first argument. When the field holds a constructor or plain function you want to call plainly, you MUST write@@.field arg/@.field arg(or bind it to a local first). - MS4. A constructor's return value is discarded —
Cls(...)always yields the instance, so areturn nil, errinsidenewis dead code. Validation a caller can trip MUST happen before or around construction (in the factory), not insidenew.
A cdef describes a raw C call, so one that misstates the real function's ABI produces silently wrong results, often on one platform only.
- FFI1. A variadic C function MUST be
cdef'd as variadic (int open(const char*, int, ...)), never with its optional arguments written out as fixed parameters. A fixed prototype is silently correct on x86-64, where varargs share the integer registers with fixed args, but wrong on the arm64 Apple ABI, which passes varargs on the stack, so the argument is read from garbage. It surfaces only on Apple Silicon, as a wrong file mode or a semaphore stuck at value 0 that hangs the first wait. - FFI2. An integer passed in a variadic position MUST be a typed cdata (
ffi.new "int", mode), never a bare Lua number, because LuaJIT passes a number vararg as adoublethat ava_arg(ap, int)callee misreads on every platform. Wrap the value once — a module constant or a thin wrapper likeffiPosix.open— so call sites still pass plain numbers.
- MOD1. A
.moonfile that defines and returns a single class MUST be named after that class in PascalCase (FeedTrust.moon). Any other file — a helper returning a function or table, an FFI shim — MUST be kebab-case (resolve-host.moon,config-schema.moon). Test files mirror the name of what they test. - MOD2. Requires go at the top: stdlib /
ffi/lfs/jsonfirst, thenl0.DependencyControl.*. Larger blocks SHOULD be alphabetized with the=column aligned; small files needn't. - MOD3. Inheritance SHOULD be rare and thin; composition and constructor injection are the norm.
- MOD4. A module MUST end with an explicit
returnof the value it exports — the class it defines, the table it returns, or thewithTestExports/Accessors.installresult — never an implicit class-statement or final-expression return. Explicit is the one form that stays valid when a post-class attachment is added later, so the file's ending never has to change.
- CT1. Reach for a
classonly when its instances will carry state or behavior — when the type is genuinely constructed, now or foreseeably. A module that is only a namespace of stateless functions (optionally withEnums or constants) MUST be a plain table the file returns, not a class. Aclassadvertises a constructor to every caller and to tooling; using one for a never-constructed namespace hands users the wrong mental model. - CT2. When instances are only a maybe-someday, SHOULD ship the table and upgrade later. The upgrade is caller-compatible in one direction: a function called
Mod.fn(args)becomes a static@fn = (args) ->with the same signature, and gaining a class merely adds a constructor. Retiring a class callers already construct is the breaking direction. The upgrade renames the file and require path (hash.moon→Hash.moon), but those are internal, not a published surface. - CT3. A table module MUST still carry a
---@class Nameon the value it returns, purely as a type name; with no constructor behind it, nothing suggests the type is constructable. (Some existing all-static classes predate this — treat them as legacy to migrate, not precedent.) - CT4. A module's public re-export MUST use the PascalCase name the module would carry as a class, even while it is a table (
DependencyControl.Hash, not.hash). The re-export is a published surface; pinning it now means a later table→class upgrade never renames the accessor. - CT5. Call form follows the binding, not taste —
=>/colon means "receivesself",->/dot means "plain function", so a member's calling convention is fixed by what it needs:- Instance methods are fat-arrow, colon-called (
obj\method). - A class static that touches class state (
@/@@) is fat-arrow, colon-called (Class\name); a plain static helper is thin-arrow, dot-called (Class.name), as are all functions on a table module. - You MUST NOT push a table's functions into the colon form to chase uniformity — it misrepresents them as instance-bound and breaks the upgrade path (CT2). Preferring table modules for stateless namespaces keeps most "static utilities" dot-called, leaving colon for genuine class-state methods.
- Instance methods are fat-arrow, colon-called (
- CT6. Bind a table module to a
camelCaselocal at each import (fileOps = require "l0.DependencyControl.file-ops"); the module's own returned table and its public re-export stay on the PascalCase type name (CT4). The lowercase local marks a namespace rather than a constructor, and is the one binding a later table→class upgrade would rename. Keep a PascalCase local only where the camelCase form would shadow a value —Hash, sincehashroutinely names a digest.
- NM1. Casing MUST be:
camelCasefor methods, fields, and locals;PascalCasefor classes andEnummembers;UPPER_SNAKEfor module-level constants.snake_caseappears only at the Aegisub API and C FFI boundaries, never in project-native code. A compound camelCase name MUST show its word boundaries rather than run the words together —feedLoader, notfeedloader. Each segment SHOULD be a whole word, not a truncation (digestLenoverdlen), though short conventional abbreviations likeid,ns,len, andmsgare fine. A name that mirrors an external source — a C symbol, a grammar node type — keeps that source's spelling. - NM2. Name a member by its role, not a part-of-speech rule. Commands (do work, have effects) take a verb (
getEffectiveSource,persistSource); predicates useis/has/should(isBlocked,hasTeardown); value producers may lead with a preposition or copula (toString,fromJSON,withoutInstall). - NM3. A member that acts MUST NOT have a bare-noun name (
failures,scriptTypesread as fields). Litmus: read the name as a field access; if it does work but looks like stored data, it's wrong. Two fixes, and the choice matters: a command takes a verb (getFailures); a derived value with no side effects SHOULD instead become an actual read-only computed property so the noun becomes honest (filter.scriptTypes,timer.elapsed). A sampling or effectful call stays a verb-named method. - NM4. These naming rules apply to test code and to identifier-like string literals — fixture names, temp-path segments, stand-in namespaces (
"l0.noFiles", not"l0.nofiles") — not only to production identifiers. A throwaway test string is still read by people and tools; being a placeholder is not license to skip the conventions.
-
DOC1. All doc comments MUST use LuaCATS, never legacy ldoc:
ldoc (never) LuaCATS (always) -- @tparam string x desc---@param x string desc-- @treturn boolean---@return boolean-- @classmod Foo---@class Foo-- @param[opt] x---@param x? type -
DOC2. Every parameter and return MUST be documented; optional params use
---@param name? type. A---@classsits immediately before the class, or on the table a module returns. -
DOC3. Plain data shapes SHOULD get standalone
---@class/---@aliasblocks even with no runtime class (RecordArgs,SemverInterval,FeedFileData). -
DOC4. A doc comment states the contract — what the caller passes, what comes back, any side effect — at the highest altitude that still informs. It MUST NOT restate the body: describe the outcome a caller relies on, not the branches that produce it.
-
DOC5. Every
---@returnMUST carry a description, not just a type. State the concrete shape and the non-obvious edge conditions the summary omits: the nil case, ordering, emptiness.
-
EN1. Enumerated value domains MUST use the
Enumclass (Enum "Name", {Key: value}), not a plain table — it gives immutability and value/key reverse lookup. -
EN2. Because
Enuminstances are built at runtime (so---@enumcan't apply), each MUST be documented by an---@aliasplaced directly above it, one---| <value> # <Key>: <description>line per member. Keep the descriptions in the alias (single source of truth), leave the runtime table bare, and reference the alias in annotations:---@alias UpdaterTrustBand ---| 1 # DeclaredDirect: the declared/own feed, trusted, offering the module by name ---| 2 # TrustedDirect: another trusted feed, offering the module by name TrustBand = Enum "UpdaterTrustBand", { DeclaredDirect: 1 TrustedDirect: 2 } ---@param band UpdaterTrustBand
-
EN3. Presentation maps (dialog label/glyph tables and the like) are not value domains and MUST stay plain tables, not
Enums (see DLG).
-
CP1. A field whose read or write must run code — a derived value, or a stored value needing conversion — is a computed property and MUST be declared with the
Accessorsmodule, not a hand-written__index/__newindex. Mark it in the class body, then callAccessors.installonce after the body (and, for a subclass, after the parent'sinstall):Accessors = require "l0.DependencyControl.Accessors" class PackageRecord version: Accessors.property get: => @semanticVersion\toPacked! set: (value) => @semanticVersion = SemanticVersion.fromPacked SemanticVersion\toPacked value Accessors.install PackageRecord
-
CP2. Omit
setfor a read-only property (a write then raises). A readable property appears inpairs(instance)and runs its getter, so you MUST initialize the backing field before the property can be read, and MUST NOTpairsa class's__base(its getters have no instance). -
CP3. Annotate a property's type with a
---@fieldon the---@class, not through theproperty{…}call — the accessor is invisible plumbing. LuaCATS has no read-only marker, so note read-only in the@fielddescription and letinstallenforce it. -
CP4. Not every derived read is a property: a property's getter MUST NOT reach off the machine or sample changing state. It may read in-memory values, the local filesystem, or a bounded read-only local subprocess (a
gitquery), and SHOULD memoize a lazy result into a backing field. A read that resolves a hostname, fetches over the network, or returns a value that differs between calls stays aget*method instead — the!at that call site deliberately signals work that can hang, fail, or change, and hiding it behind a field access is what to avoid. Local, so properties:timer.elapsed,record.version. Off-machine or sampling, so methods:host\resolvesToPrivate!(DNS),feedTrust\getTrustedFeeds!(loads a feed),lock\getActiveHolder!(samples the holder).---@class PackageRecord ---@field version integer Packed-integer view over `semanticVersion`; assignable from a string, packed int, or instance. Read-only.
- CM1. Use an inline
--comment only when the why is non-obvious: a hidden constraint, a subtle invariant, a language gotcha. It MUST NOT describe what the code does — well-named identifiers already do that. - CM2. A comment MAY state a non-obvious constraint or design choice when it directly affects how callers use the API. "Automation scripts have their tests registered when they call
registerMacros" is a behavioral contract and belongs; "this replaces the old per-environment scheduling" is history and does not. - CM3. Design rationale, comparisons to a previous version, and the story of how the code got here MUST NOT live in a comment; they go in the commit message or PR description. (Agents are especially prone to this — see AGENTS.md
WR.) - CM4. Substantial "why" blocks above non-obvious code are welcome (the long-path detection in FileOps, the SSRF notes in Downloader). Region banners (
-- Shared Functions) SHOULD be used only in the largest files.
- PV1. Anything not meant to be called from outside its own class MUST be marked
---@privateand prefixed__(the type generator also treats a leading__as private). The language server then flags externalobj.__method()access while allowing@method/@@methodinside. - PV2. A member is public if it's a safe, stable building block a third party building on the framework could reasonably use — whether or not anything here calls it yet. Mark
---@privateonly genuine implementation details; an existing external caller proves a method public, its absence does not prove it private. You MUST NOT narrow a member that was public in a prior release — that is a silent breaking change. Tests are exempt (they drive internals via the stub-self pattern). - PV3. A single leading
_is a deliberate middle tier, not an under-marked private: the internal contract shared within a subsystem (a mixin and its subclasses, a manager and its injectable collaborators) but not general public API —EventEmitter._emit, theDownload↔runner callbacks. Use_for those and__for what only the defining class touches, and state the distinction in a header comment where a file leans on it. - PV4. A mock-worthy dependency — an injected collaborator that is a test seam (TS5) — MUST live in a
---@privateclass field (@@__dep/@__dep) that a test can swap, not a module-local upvalue nothing outside the file can replace. - PV5. A module-private sentinel or marker key MUST be a namespaced string built from the shared prefix (
"#{constants.DEPCTRL_PRIVATE_GLOBAL_VAR_PREFIX}SomeName"), not a bare{}or plain name, so it stays collision-free while remaining inspectable and serializable.
-
TX1. To read a private value from a test, you MUST NOT add a test-only field to the public API; use the
UnitTestSuitetest-export helpers, which stash the exports on the module's metatable (off the normal indexing surface):-- end of MyModule.moon UnitTestSuite = require "l0.DependencyControl.UnitTestSuite" return UnitTestSuite\withTestExports MyModule, {:somePrivate} -- in the test file {:somePrivate} = UnitTestSuite\getTestExports require "l0.DependencyControl.MyModule"
-
TX2. A module that sits in
UnitTestSuite's own dependency chain (a low-level module likehash) MUST NOT requireUnitTestSuitefor this (it would create a load cycle); it exposes its test-only reference through a---@privatefield instead.
- ER1. Expected failures MUST return
nil, errMsg(or a status code).error()/assertare reserved for programmer error and constructor-contract violations. - ER2. A tri-state return MUST distinguish absent from failed:
nil, erris a hard error,falseis legitimately-not-there. The---@returnMUST document both cases. - ER3. Every user-facing string MUST live in a
msgstable of%stemplates and MUST NOT be an inline literal. Newmsgstables SHOULD be module-locals, keyed by method name (private methods included:msgs.__refreshFiles.noLocalPath). - ER4. Error strings SHOULD end in a period; progress messages SHOULD end in
…; messages MUST NOT use an"Error: "prefix. A lowercase fragment MAY appear only as a composable sub-message.
- LOG1. Loggers use numeric levels (fatal 0 → trace 5) with method sugar; a call passes the template plus args and the logger formats:
@logger\log msgs.run.starting, a, b. - LOG2.
logger\errorthrows by design (log-and-abort);logger\assert cond, msg, …is the standard guard. - LOG3. A logger SHOULD be injected with a default: a module-local
defaultLoggerplus a constructor parameter (@logger = defaultLogger), so tests can pass their own. - LOG4. A subsystem logger's
fileBaseNameMUST be"#{Constants.DEPCTRL_SHORT_NAME}.<Class>"— the shortDepCtrl.prefix, single-sourced. It's a display label that sits beside the hosting script's namespace in filenames and line prefixes, where the short form reads best. - LOG5. A programmer-error contract check uses
@logger\assert cond, template, …argswhere a logger is in scope, or bareassert cond, msgwhere none is yet (e.g. before a constructor assigns it). The message is amsgsentry, never an inline literal (ER3). Dynamic values pass as trailing args, never baked into the template, which@logger\assertre-formats. A failure a caller can hit at runtime is validated before construction and returned asnil, errinstead (MS4).
- ID1. Compose a string that encodes the module's own identity from the owning constant or class, never from a hardcoded literal that restates it. A temp-file name, working-path segment, logger label, or namespaced marker SHOULD derive its identifying prefix —
"#{constants.DEPCTRL_SHORT_NAME}-#{@@__name}-#{token}.json", not"depctrl-ziparchiver-#{token}.json". The literal drifts when the class is renamed; the derived form cannot. (LOG4 applies this tofileBaseName.)
- CFG1. Config MUST flow through
ConfigView: read/write@config.c.<section>.<key>, mutate, then@config\save!. - CFG2. A config default SHOULD live on its sole owning class (
FileCache.defaultMaxAge, read as… or @@defaultMaxAge); the centralconfig-schema.moontree is only for settings shared across subsystems. - CFG3. A key prefixed
_is excluded from serialization; use that prefix for transient state stamped onto config-backed tables.
- DLG1. Code MUST NOT branch on a raw dialog label. Map every acted-on label (button captions, dropdown values) to a stable key in one table, branch on
table.key, and build the widget's button list from that same table so caption and dispatch can't drift. - DLG2. These label tables are presentation maps and MUST stay plain tables, not
Enums. - DLG3. Expose a label map a test needs like other internals (
withTestExportsorregisterMacrosexports) and key the test off it (buttons.discover, not"Fetch/Discover"). A purely decorative map a test never inspects MAY stay local to its render function.
- TS1.
test.moonis a singleUnitTestSuiteimporting each class's test file viacontrols\requireTest "Name". A test file MUST open with a header comment (what it tests, how it's called) and return a table oftestName: (ut) ->entries, with optional_description/_setup/_teardown/_order/_condition. - TS2. Test names MUST follow
method_behavior(put_expiryFixedAtWriteTime,scheduleUpdate_withinIntervalSkips). - TS3. Drive an instance method without constructing the object via the stub-self pattern:
setmetatable {…fields…}, __index: Class.__base, thenClass.method(fakeSelf, …). - TS4. Mock via
ut\stub/ut\spy/ut\stubModule, and SHOULD exploit the injectable seams (clock, resolver, feed loader, transfer runner) rather than monkey-patching module internals. - TS5. A member is a test seam when a test must intercept what it does rather than let it run — I/O, filesystem or network access, global or config mutation, or non-determinism (time, randomness). A seam MUST be reachable for stubbing — an injectable
---@privatefield or a---@private __methodreachable via.__base, not a class-body-local or module-local function nothing outside the file can replace. A pure, deterministic helper is not a seam and stays a plain function.