Skip to main content

FreeGantt — Lint Rule Specifications

Twenty-three rules enforce the spec. The count has grown since the original nineteen as rules landed. Eleven are configuration of ESLint builtins (no-restricted-syntax, no-restricted-properties, no-restricted-globals, no-restricted-imports) scoped by directory — zero maintenance, no plugin code. Twelve need real AST logic and live in a local flat-config plugin. Prefer the builtin vehicle whenever it expresses the rule honestly: every custom rule is code we own, test, and debug.


1. Where the plugin lives​

eslint/
rules/index.cjs // the 14 shipped custom rules, exported as { rules: { … } }
rules/<rule-id>.cjs
rules/<rule-id>.test.cjs // RuleTester: ≥2 valid, ≥2 invalid per rule (mandatory, see 04-hooks-and-ci.md)
rules/fixtures/ // violation fixtures the red test reads
eslint.config.js // flat config: layered overrides per directory

Flat config, plugin inlined by object (no publishing, no eslint-plugin- package):

// eslint.config.js
import freegantt from './eslint/rules/index.cjs';

const PURE = ['src/model/**', 'src/time/**', 'src/data/**', 'src/scheduling/**', 'src/layout/**'];

export default [
{ plugins: { freegantt } },
{ files: ['src/**/*.ts'], languageOptions: { parserOptions: { projectService: true } },
rules: { /* builtin + custom baselines */ } },
{ files: PURE, rules: { 'no-restricted-globals': ['error', ...DOM_GLOBALS] } },
// …per-directory relaxations, each with a spec citation in a comment
];

Type-aware rules require projectService: true; that is also what makes typescript-eslint's recommended-type-checked set available, which we take wholesale as the baseline.

2. Rules expressed as builtin configuration​

Each row is a files-scoped override. The allowlist column names the only paths where the construct is legal.

#RuleVehicleFlagsAllowlistInvariant
B1no-magic-time-constantsno-restricted-syntax on Literal[value=86400000], 3600000, 604800000, 60000, 1000 (in binary expressions only)Numeric time constants used as durationssrc/time/**I10
B2no-date-outside-timeno-restricted-globals (Date) + no-restricted-properties (Date.now, Date.parse, Date.UTC, performance.now for wall-clock use)Any Date construction or readsrc/time/** (the sanctioned Intl/Date boundary)I10, determinism
B3no-randomno-restricted-properties (Math.random, crypto.randomUUID)Nondeterminism in pure layerstests only — no production id minter uses randomnessI4
B4no-scroll-outside-scroll-attachment (shipped as a custom rule — scrollLeft/scrollTop/scrollTo need AST-level filename exemption, past what no-restricted-properties alone expresses)eslint/rules/no-scroll-outside-scroll-attachment.cjsDirect scroll manipulationsrc/view/scroll-attachment.tsI12
B5no-inner-htmlno-restricted-properties (innerHTML, outerHTML, insertAdjacentHTML) + no-restricted-syntax on document.writeHTML injection pathssrc/render/dom/raw-html.ts (the opt-in flag path)I13
B6no-dom-in-pureno-restricted-globals (document, window, navigator, location, self, HTMLElement, Node, Element, requestAnimationFrame, getComputedStyle)DOM access below the line— (pure dirs only, no exceptions)I1, D4
B7no-external-runtime-importno-restricted-imports (alien-signals, temporal-polyfill, temporal-polyfill/*)Any runtime dep importsrc/data/reactivity.ts (alien-signals), src/time/zone.ts (temporal-polyfill)Runtime dependencies
B8no-not-implementedno-restricted-syntax on `ThrowStatement > NewExpression[callee.name='Error'] > Literal[value=/not.implementedTODOunsupported/i]`Dishonest public surface
B9no-derived-in-jsoneslint/rules/no-derived-in-json.cjs — bans Row/Bar/GeometryFrame type references and layout//view/ importsDerived types in serialization— (src/data/serialization/** only)authored/derived
B10raf-single-ownerno-restricted-globals (requestAnimationFrame, cancelAnimationFrame)Multiple rAF pipelinessrc/view/frame-scheduler.tsRendering
B11no-restricted-imports layer mirrorno-restricted-imports with per-directory patternsLayer violations (fast editor feedback)—I1 (backstop for the layer graph in 03-boundaries-and-config.md)

dependency-cruiser removable leaves (not ESLint rules)​

These live in .dependency-cruiser.cjs and are proved by scripts/guard-red-test.mjs:

RuleModuleAllowed importersInvariant
rollup-is-removablesrc/data/rollup.tsbuild-commit-change-set.ts, transaction.tsDelete the file and parents keep caller-assigned values
autogroup-is-removable RETIREDsrc/data/hierarchy.ts—The module that inspired this rule was deleted, so the rule's guarded file no longer exists. autoGroup is gone, not merely unreachable.
layout-boundarysrc/layout/**may import time/, model/ onlyI1 — layout/ never imports data/

(B11 duplicates dependency-cruiser deliberately: depcruise is the authority and understands the whole graph; the ESLint mirror gives the red squiggle in-editor and inside the Claude Code PostToolUse hook, where a full graph crawl would be too slow.)


3. Custom rules​

Every custom rule spec below is complete enough to implement. All report messages describe the rule and its rationale.

3.1 freegantt/no-instant-arithmetic — type-aware · I10​

Flags: binary + - * / % and compound assignment where either operand's type is (or resolves through an alias to) the Instant brand, or Duration. Comparison operators (< > <= >= === !==) are allowed — ordering instants is legitimate and unambiguous.

Also flags: Number(instant), +instant, instant++.

Allowlist: src/time/**.

Why type-aware: the whole point of the brand is that entry.end - 1 and someNumber - 1 look identical syntactically. Uses parserServices.getTypeAtLocation and checks for the __brand: 'Instant' property on the resolved type.

Message: Arithmetic directly on an Instant is banned outside time/ (plans/01 §5, I10). Use addMs/diffMs/etc. from time/.

Subsumes the "no inline end - 1" review rule from CLAUDE.md.

Fixtures: valid — a < b, time/add.ts doing math, plainNumber - 1. invalid — entry.end - 1, start + DAY, end -= 1, +instant.


3.2 freegantt/no-time-to-pixel-math — type-aware · I12 (partial)​

Flags: any binary arithmetic where one operand is Instant/Duration-typed and the other is an identifier/property matching /px|width|left|right|x|scale|zoom/i, outside src/time/**. Also flags a variable declaration whose initializer divides two Instants (the px-per-ms idiom).

Allowlist: src/time/scale.ts.

Known residue (documented in 01-invariant-guard-matrix.md §3): a conversion laundered through an untyped intermediate. Accepted.

Message: Time→pixel conversion outside TimeScale. Gantt instances bind to a TimeScale; nothing else may know px-per-ms.


3.3 freegantt/no-kind-conditional — syntactic​

Flags: comparisons (===, !==, switch discriminant, case) where one side is a member expression whose property is kind and the other is a string literal; and switch statements whose discriminant is *.kind.

Allowlist — exactly the four seams, one file each (planned paths, see the note below — three of these files were never created):

PathSeam
src/scheduling/policy/default-policy.tsschedule semantics per kind
src/layout/bars/produce-bars.tsitem production per kind
src/render/dom/renderer-registry.tsappearance per kind
src/interaction/capabilities.tsaffordances per kind

Message: kind is dispatched through a registry, never compared inline. Register behavior at the seam for this layer.

Note: the rule does not flag entry.kind ?? 'span' or passing kind to a registry lookup — only branching on its value.

Never shipped, and its premise is now superseded. The stored kind field was deleted, so derivation and lookup now follow structure and registered Variants instead of a kind comparison at any seam. This section stays as a historical record of the rule that was planned but never built.


3.3a freegantt/no-kind-literal — syntactic​

Narrower cousin of §3.3, landed early. The span rollup is the first kind-dependent behaviour in data/. Rather than ship a seam-allowlist rule against seams that don't exist yet, this lands the part that is checkable today: no file may compare a kind string literal against .kind at all, in src/data/** or src/layout/** (tests exempt — fixture setup legitimately writes { kind: 'group' }).

Flags: BinaryExpression (===/!==/==/!=) where one side is a *.kind member expression and the other a string literal, and switch (*.kind) with a literal case.

Allowed: entry.kind ?? 'span' (reading the stored default, not branching on behavior) and, by the same reasoning, a comparison against 'span' specifically — treated as a shape/omission decision (e.g. "is this the default, so can be omitted from a serialized document"), not the "different behavior per kind" chain the rule targets. A comparison against any other kind value still trips it.

Message: kind is dispatched through a lookup, never compared inline. Register behavior at the seam for this layer.

Resolved, not by folding into §3.3. The Entry.kind field was deleted, so the seam-allowlist rule was never built — there is no stored kind left to compare at any seam. This rule stays registered as a general backstop against inline .kind dispatch in data/ and layout/.


3.4 freegantt/no-module-level-state — syntactic · I2​

Flags, at module scope in src/**:

  • let / var declarations
  • const whose initializer is a NewExpression (new Map(), new WeakMap(), a class instance), an array/object literal that is not Object.freezed or as const, or a call expression other than an allowlisted pure factory (Symbol, Object.freeze, defineRegistry)
  • exported bindings mutated anywhere in the file

Allowed: frozen lookup tables, as const literals, primitive constants, type-only declarations, class/function declarations.

Message: Module-level mutable state makes two Gantt instances share it. Own it on the instance. (plans/01 §6, I2)

Fixtures: invalid — let cache = new Map(), export const registry = new Map(), const presets = { … } unfrozen. valid — const PRESETS = Object.freeze({…}), const SNAP = 14, export type X = ….


3.5 freegantt/no-recursion-in-scheduling — syntactic (call graph) · I3​

Two passes over src/scheduling/**:

  1. Self-recursion: a function whose body calls its own binding name (direct, or via arguments.callee-style aliasing).
  2. Mutual recursion within the module: build a per-file call graph of module-scope functions; report any cycle, naming the participants in the message.

Cross-file mutual recursion is out of reach for a lint rule; the 5,000-link chain fixture (test:node) covers it empirically — a real recursive propagation path blows the stack there.

Message: scheduling/ propagates via an explicit worklist; chain depth is unbounded by design. Cycle: a → b → a. (plans/01 §7, I3)


3.6 freegantt/no-store-mutation-outside-transaction — type-aware​

Flags: calls to store mutator methods (add, update, remove, set, clear) on a receiver whose type implements the internal MutableStore interface, outside src/data/entry-store.ts and src/data/transaction.test.ts. entry-store.ts is the one internal caller (through its own TxToken-gated methods), and the test file legitimately drives the gate directly.

Belt and braces: the mutators additionally take a TxToken parameter that only transaction.ts can construct (private constructor + non-exported type), so typecheck catches it too. The lint rule exists for the clearer message and because the token can be threaded around by a determined caller.

Message: Every mutation goes through dataset.transaction(): one scheduling pass, one changeset. (plans/01 §6, D10)


3.7 freegantt/model-is-types-only — syntactic​

Flags: in src/model/** (tests exempt), any value-producing declaration — function/class/variable — except an allowlist of id/brand helpers (brand, unbrand, entryId, dependencyId, rowId, barId, changeSetId) which must additionally be one-line, dependency-free identity casts, plus the BarId readers entryIdOfBar and partIndexOfBar, plus the span predicate spansTime, which must also state its whole answer in one return. Any import that is not import type is flagged.

Message: model/ is types only: zero runtime beyond id/brand helpers, zero dependencies. (plans/01 §1)


3.8 freegantt/require-invariant-header — syntactic​

Flags: a file listed in the rule's headers option whose first block comment does not contain the required invariant sentence.

Configured headers:

FileRequired text (substring match)
src/scheduling/propagate.tscontains no recursive call; chain depth is unbounded by design
src/render/dom/reconciler.tsattribute/class/style/text diffing and keyed child recycling only
src/scheduling/schedule.tsnever mutates its input
src/data/reactivity.tsthe only file that sees the reactive dependency
src/render/dom/apply-state.ts@hot-path

Why a rule and not a convention: these headers are the in-repo statement of the invariant that a reader meets before the code. A file that loses its header loses the explanation, and the next author doesn't know the rule exists.


3.9 freegantt/no-allocation-in-hot-path — syntactic · I5 (partial)​

Scope: files whose leading comment contains @hot-path, and functions annotated /** @hot-path */.

Flags: NewExpression, array/object literals, spread, template literals, .map/.filter/.slice/.concat/Object.keys/Object.entries/JSON.*, string concatenation with +, and closure creation (arrow/function expressions) inside the annotated scope.

Allowed: class toggles, style.transform assignment, numeric locals, for loops over pre-existing arrays.

Message: Hot path is class toggles and transforms only: zero allocation, no frame rebuild. (plans/01 §3, I5)


3.10 freegantt/no-flow-layout-rows — syntactic · I9 · AUTO-PARTIAL​

Flags: in src/view/** and src/render/dom/** — reads of offsetHeight/clientHeight and calls to getBoundingClientRect().

Exempt: pane-layout.ts and pane-size-attachment.ts, by filename. Both legitimately read clientWidth/clientHeight to measure the pane's own box (CONTEXT.md's "Pane size") — a different concept from row height. Banning that would break the synchronous first measurement PaneLayout.measureTimelinePane() needs.

Residue: this rule does not catch an assignment to style.height that is not sourced from a frame.rows[i].height expression — too fragile to express syntactically, the same AUTO-PARTIAL shape as no-time-to-pixel-math (§3.2). Mitigated the same way: the layer graph makes a laundered value useless (only layout/ legitimately owns row height), so the residue is small and review-visible.

Message: Both panes position rows absolutely from frame.rows. Neither measures nor computes a height. (plans/01 §4, I9)


3.11 freegantt/no-inline-style-outside-geometry — syntactic​

Flags: in src/render/** and src/view/** — any node.style.<prop> = … assignment where <prop> is not transform, width, or height.

Allowed: transform/width/height — the three properties that carry a live per-frame or per-instance number (row/bar position, grid width, header spacer height). Everything structural (display, overflow, position, flexDirection, cursor, colors, …) moves to the base stylesheet ensureBaseStyles injects (src/view/styles.ts).

Scope: src/render/** + src/view/** today (eslint.config.js). Expected to widen to src/interaction/** once gesture previews need the same per-frame allowance — not a gap today, just not yet applicable.

Message: Structure moves to the base stylesheet; inline styles are for live per-frame/per-instance geometry only (transform/width/height). (plans/s1.10-theming-and-a11y/README.md D-S1.10-6)

3.12 freegantt/editable-has-one-reader — syntactic · I14​

Flags: in src/**/*.ts outside src/data/fields/field-registry.ts and outside test files — any read of an editable member: field.editable, field['editable'], and const { editable } = field.

Allowed: the declaration itself ({ key: 'start', editable: 'anywhere' } is a Property, not a read), and field-registry.ts, where editableOf resolves the boolean aliases and the absent-key default. Every other caller asks src/data/write-rule.ts's shared resolver instead: editableAnswerFor(field, declared, query, lockRule) — 'never' for an undeclared or compute Field, the lock rule's own answer otherwise (#473), with fieldEditableRule as that lock chain's own bottom occupant, answering the Field's declared editable when nothing above it has an opinion.

Why: this is the check that catches a split reader. view/capability.ts read field.editable === true while entries.update() read nothing at all, so one key had two answers: the grid hid a handle over a write that still landed. A second reader of the raw key is how that split comes back.

Message: `Field.editable` is read in `data/fields/field-registry.ts` only (I14, ADR 0015). Ask `editableAnswerFor(field, declared, query, lockRule)` — one resolver, every reader.


4. Message discipline​

Every custom-rule message follows one shape: what is wrong · what to do instead · the spec citation. This matters more than usual here, because the primary consumer of these messages is often an agent editing the file, and a message that ends in a spec citation sends it to the governing text instead of to a workaround.

5. Phasing​

A rule lands with the code it can govern. A rule with no code to govern has no fixture to prove it works, so it waits for that code.

Active: B1, B2, B4, B5, B6, B7, B8, B9, B10, B11, 3.1, 3.2, 3.3a, 3.4, 3.6, 3.7, 3.8, 3.10, 3.11, 3.12, and the dependency-cruiser rules rollup-is-removable and layout-boundary (proved by scripts/guard-red-test.mjs).

autogroup-is-removable retired on 2026-09-11 — see §2's table.

Not yet active, because the code each one governs does not exist yet: B3 (no production id minter needs it — see 01-invariant-guard-matrix.md's I4 row), 3.3 (its four seam files), 3.5 no-recursion-in-scheduling (I3), and 3.9 no-allocation-in-hot-path (I5).

A rule that waits still exists in eslint.config.js, pointed at its (empty) target directory: it costs nothing and it fires the moment the first violating file appears.