npm run lint runs ESLint over the repository from eslint.config.mjs. The configuration is
self-contained: it runs from a bare clone with nothing above it. This document is where its reasons
live, because the configuration itself carries no comments.
The code carries no comments, with five exceptions. Anything else a comment would say belongs in
the code - a name, a named constant, a function whose name is the sentence - or in the documentation:
the package's CLAUDE.md, an ARCHITECTURE.md, API_DOCS_GUIDE.md, UNIT_TESTING_GUIDE.md, this
file, or the README beside a script. A note beside the logic is a second description of it that
nothing checks, so it drifts: the code changes, the note does not, and the next reader believes the
note.
-
JSDoc on the public API. In a published package, a JSDoc block is allowed as the last comment before a public declaration:
- an exported declaration, or one exported by name later in the file;
- a declaration inside an exported namespace;
- a member of an exported class that is not
privateor#private-protectedmembers are in the published declarations, and classes such as the draw helpers are extended in other packages - including a public or protected constructor parameter; - a member of an exported interface or type, or of a type written into a public signature;
- a member of an exported enum;
- a property of an exported constant object.
The DTOs are public API: the input classes in
lib/api/inputsand the models inlib/api/modelskeep their class, constructor and property JSDoc, tags included (@default,@optional,@minimum,@maximum,@step), because tools generate documentation from them.That JSDoc is not commentary. Tooling reads it: the API reference, the published declarations, the worker classes generated from the kernel, and the tags (
@default,@optional,@group,@shortnameand the rest) that tools read as data. The dotted paths it documents are persisted in users' scripts. A JSDoc block anywhere else is ordinary prose and is reported, and so is an earlier block stacked above the one that documents a declaration, which nothing reads. "Exported" means exported from its module: a public member of an exported class the package entry never re-exports still counts. Code that is no package API at all - scripts, tests, mocks, configuration and the MCP server - carries no JSDoc. -
Tool directives:
eslint-disable,@ts-expect-error, a coverage ignore and the like. They are instructions to a tool, and removing one changes behaviour. -
Notices: a licence or attribution notice that third-party material carries under its terms.
-
The three step markers of a test:
// Arrange,// Act,// Assert(and// Act & Assert,// Arrange & Act), each the whole comment. They are structure, not description. A clause after the marker is prose again; what a test is about goes in the name of itsit. -
Marker lines a generator reads: in
lib/api-hand/, the line above each member that places it in the generated worker class (// replaces <path>,// after <path>,// first,// last). Generated files are not linted for comments; each carries one// GENERATED by ...line naming its generator and how to regenerate it.
A hashbang is not a comment and is left alone.
The rule is the local bitbybit/no-loose-comments. It has no fixer on purpose: no --fix run may
rewrite a source file, so the JSDoc corpus can never be deleted by a tool. For the same reason
eslint-plugin-no-comments must never be loaded: it is auto-fixable, with an allow-list that replaces
its defaults, so one --fix run pointed at packages/dev would delete the public API's JSDoc.
- ESLint's recommended set and typescript-eslint's recommended and type-aware recommended sets. The
type-aware set reads the type graph, so it sees an unawaited promise, a method that lost its
thisor an assertion that does nothing. - House style: double quotes (single quotes to avoid escaping, template literals allowed) and semicolons.
- Strict equality and braces.
===and!==(eqeqeq), with== nullstill allowed, the one loose comparison that says exactly what it means: null or undefined. Everyif,elseand loop body is a block (curly), so a second statement added under a one-lineifcannot silently run unconditionally. - Type-only imports say so (
consistent-type-imports):import typefor what is only a type, so every compiler and bundler that sees one file at a time drops it without guessing, and a reader can tell which imports run code. A test or a mock may still writetypeof import("..."), the formvi.importActualtakes. - Return types, file length and numbers. A function states its return type
(
explicit-function-return-type, with typed callbacks and expressions exempt), a file holds at most 600 lines of code (max-lines, blank lines and comments not counted), and a number other than -1, 0, 1, 2 or 1024 has a name (no-magic-numbers; enum members, literal types, readonly and DTO field initial values and default parameters are names already). A file namedconstants.tsor*.constants.tsholds named numbers and is exempt, and so is the design package'sdigest.ts, a transcription of SHA-256 whose numbers are the standard's own. These hold the packages' code, not tests, and the findings that predate them are in the suppressions file. The suites that check a package's output with an outside tool or time it (packages/dev/*/validation,packages/dev/*/bench) keep the return types and the named object types but not the file length or the numbers: they are tables of fixtures, sizes and budgets, each read where it stands. base/lib/api/services/logic.tsmay compare loosely.compareoffers==and!=to its callers as operators of their own, beside===and!==; the loose forms are the feature.- Unused variables: a leading underscore marks an argument, a variable or a caught error as deliberately unused. Caught errors are included, otherwise a swallowed error could satisfy the rule only by losing its name.
- An empty
catchis allowed, and written bare. Disposing of kernel and engine objects is best-effort by necessity - a freed handle throws on every later call, and the same object can be reachable twice - so a cleanup sweep catches and ignores, and a catch that logged or rethrew would abort the sweep and leak everything after it. Other empty blocks are still reported. What is ignored and why belongs in the package'sARCHITECTURE.md, not in the block. - The type-aware project service places every file in its package's
tsconfig.json. Four tooling files sit in no package's compilation -packages/dev/vitest.shared.tsand the vitest configs ofcad-cloud-sdk,create-appandmcp, whose tsconfigs coversrcalone - so they are named as default-project files. The list is exact: naming a file the service can place is itself an error. no-unnecessary-type-assertionreads the loose project.typecheck:strictcompiles the strict overlay, where an index read isT | undefinedrather thanT, so an assertion this rule calls unnecessary can be load-bearing there. Five removals made on its advice once broke the strict build: take a removal it suggests, then runnpm run typecheck:strictbefore believing it.- Six
no-unsafe-*rules andno-redundant-type-constituentsare off. They fire wherever ananyflows, so they measure the debtno-explicit-anyalready counts, about ten times over: oneanyis re-reported at every member read, call, argument and return downstream of it. None of them can be fixed except by removing theany, so they come on as that count comes down. core/lib/api/bitbybit/json.tsmay useany. Its methods return a value parsed or queried out of an arbitrary structure, and only the caller knows its shape -parseisJSON.parsewith a path expression, which returnsanyfor the same reason. Returningunknownwould break every script that reads a property off the result. The inputs of those methods areunknown, and that is where narrowing belongs.
Every finding that existed when a rule landed is recorded in eslint-suppressions.json, written by
eslint --suppress-all from the repository root. ESLint fails on a new finding and on a suppression
that is no longer needed, so the count only goes down. Rewrite that file only to record a deliberate
fix, never to hide a new finding. An inline eslint-disable of a rule the ratchet counts hides debt
from it, so a fix removes the directive rather than adding one.
-
bitbybit/no-double-assertion. A double assertion -x as unknown as T, or the angle-bracket form - is not a narrowing. The first cast widens the value until the compiler has nothing left to check, and the second declares what it is, so the declared type can be wrong in every way and the build stays green. Try, in order: a type predicate (function isFoo(x: unknown): x is Foo), so the check and the type agree; a generic constrained to the union, so an argument narrows within it; fixing the type the cast argues with, which is usually the real finding. A singleasis untouched: it can only move within an assignable pair, so the compiler still holds one end of it. It is a rule of its own rather than ano-restricted-syntaxselector so the suppressions file budgets it under its own name. -
bitbybit/no-loose-comments. The comment policy above. -
bitbybit/no-input-writes. A kernel service method receives its caller's parameter object and must not write into it. A caller may call the same method again and again on the same object, so a default written back ontoinputsmakes the second call differ from the first, and a caller who reuses the object gets a value it never set. The default belongs in a local (const corners = inputs.corners ?? round) or a spread copy. Only a direct property of the parameter is reported; a write deeper down (inputs.mesh.position = ...) is the method changing an engine object the caller handed over to be changed. It covers the kernel services ofocct,jscad,manifoldandbase. The renderers' draw helpers are not held to it - writing the created mesh back ontoinputs.linesMeshis their documented handle for updating a mesh - and neither is a test, which owns the objects it builds. -
bitbybit/no-inline-object-types. An object type is named: an interface, or a type alias whose union or intersection it is a member of. Written inline in a parameter, a return type, a type predicate, a generic argument or a member, the same shape gets spelled out again at every place that hands it on, the copies drift apart, and a reader meets a list of fields where a name would have said what the value is. A discriminated union keeps its variants inline, because the alias names the whole and each variant is told apart by its tag. A name that is not part of a package's API stays beside the code that owns it, or in the module the folder shares its state through; it does not move into the published models. It covers the packages' code and their validation and bench suites, not tests, mocks or generated files, and the findings that predate it are in the suppressions file.
Each local rule has a RuleTester suite beside it, run by npm run test:scripts.
Build output, coverage, the documentation site's application, the example applications (each has its
own toolchain, and they are teaching material for the people who copy them), the scaffolder's templates
(they ship to users as they are), the projects the scaffolder's smoke lanes keep under .local, the
OCCT kernel glue, the API reports, declaration files (every .d.ts here is generated or vendored), the
four assembled inputs namespaces (their fragments are the linted source) and the generated files, which
are held to their generators by the check:* gates instead. The scripts that maintain the examples and
the documentation site, examples/scripts/ and docs/scripts/, are linted.