Skip to main content

FreeGantt — Invariant → Guard Matrix

The single table a reviewer (human or agent) checks against. Every rule in CLAUDE.md and this project's design appears exactly once, with the mechanism that proves it and the gate check that runs it. The gate is pnpm verify:full, and CI runs all of it in one job.

Status vocabulary

StatusMeaning
AUTOA machine check fails the build on violation. No judgment involved.
AUTO-PARTIALA machine check covers the common/mechanical violations; a named residue needs review. The residue is spelled out.
REVIEW-ONLYNo honest mechanical check exists. The reason is stated. Reviewers own it.
PLANNED (Sn) / PLANNED (#issue)The guard is designed here but lands with slice n, or with that issue's PR, because its subject doesn't exist yet.

1. The numbered invariants​

#InvariantMechanismGate checkStatus
I1Layer imports match the layer map exactlydependency-cruiser graph — one forbidden rule per absent arrow; no-restricted-imports mirror for editor speedboundaries, lintAUTO
I2No module-level singletons; two Gantt instances coexistTwo-Gantt isolation test (api/gantt.test.ts) mounting into two containers and asserting cross-independence of scroll, selection, and data revision. freegantt/no-module-level-state — eslint/rules/no-module-level-state.cjs, scoped to src/**/*.ts excluding teststest:dom, lintAUTO
I3scheduling/ contains no recursive propagationfreegantt/no-recursion-in-scheduling — self-call + intra-package call-graph cycle detection; 5,000-link chain fixture; dev-mode depth assert. scheduling/ is an empty stub today — nothing to guard yetlint, test:nodePLANNED (S7)
I4schedule() never mutates input; policy never moves a proposed fieldfast-check property: deep-freeze the request, assert no throw + structural equality of a pre-call clone; second property asserts patch never contains a field present in proposed for that entry; dev-mode assert in schedule() itselftest:nodeAUTO
I5Hot path allocates nothing, never rebuilds a frameProxies (a) applyState never calls computeFrame — assertion via a spy in the hot-path test; (b) freegantt/no-allocation-in-hot-path bans object/array/new/.map/.filter/template literals inside functions in files marked /** @hot-path */; (c) perf test measures performance.measureUserAgentSpecificMemory-free proxy: N=10k applyState calls under a fixed heap-growth ceiling. Residue: V8 can allocate where source shows nonetest:dom, perf (non-blocking)PLANNED (S3)
I6One transaction per gesture, at commitInteraction test per controller: drive a synthetic pointer sequence, count change events (must be exactly 1) and assert its origin: 'user' and that no change fired before pointeruptest:domPLANNED (S3)
I7Undo reverts user + engine effects atomicallyfast-check: random mutation sequences over a fixture, undo-all, assert entries.all reads back identical to the start; a second property runs the same with dependencies present so engine cascades participatetest:nodePLANNED (S2/S7)
I8Bar.id deterministic across layout passesLayout test: run computeFrame twice over identical input plus once after an unrelated mutation; assert id sets and per-bar ids stable; snapshot the frametest:nodeAUTO
I9Grid and timeline share one row geometryPixel-equality test (view/gantt-shell.test.ts): both panes position from the same frame.rows/frame.bars at a fractional row height, read live from the DOM, asserted equal; plus freegantt/no-flow-layout-rows banning height measurement in pane code. Residue: the rule does not catch an assignment to style.height not sourced from frame.rows[i].height — same shape as I12's own residue, mitigated the same way (the layer graph makes a laundered value useless)test:dom, lintAUTO-PARTIAL
I10No time math outside time/; no magic time constantsThree rules: no-magic-time-constants, no-date-outside-time, and no-instant-arithmetic (type-aware)lintAUTO
I11Public .d.ts contains nothing unimplementedapi-extractor report committed to the repo (etc/freegantt.api.md); CI fails on diff. no-not-implemented (a builtin-vehicle no-restricted-syntax rule, not freegantt/*) banning throw new Error(/not.implemented|TODO|unsupported/i) repo-wide, tests exempt. A test that instantiates the public surface and calls every zero-arg method does not exist yetapi-report, lint, test:domAUTO-PARTIAL
I12All pixels-from-time via TimeScale; all scroll via a bound ScrollAxisScroll half is AUTO: freegantt/no-scroll-outside-scroll-attachment. Time→pixel half is AUTO-PARTIAL: freegantt/no-time-to-pixel-math flags arithmetic mixing an Instant/Duration-typed value with an identifier matching `/pxwidthleft
I13Renderer output text-safe by defaultDefault path is text-safe by construction today: render/dom/index.ts/sync-keyed.ts write via textContent/attrs only, proven by render/dom/index.test.ts's textContent assertions. freegantt/no-inner-html (02 §2 B5, PLANNED (S5)) and the opt-in raw-HTML path (src/render/dom/raw-html.ts) don't exist yettest:dom; lint PLANNED (S5)AUTO-PARTIAL
I14Gesture arming, affordances and entries.update() come from one capability resolutionfreegantt/editable-has-one-reader pins every read of Field.editable to data/fields/field-registry.ts, where editableOf resolves the aliases and the default; every other caller asks data/write-rule.ts's shared resolver — editableAnswerFor(), the same order assertFieldTakesWrite() throws in (#473), with fieldEditableRule() as the lock chain's own bottom occupant answering the Field's declared editable — so no door answers from a second table. The thresholds themselves are table-driven: view/capability.test.ts over the grid threshold (a locked Field closes the handle, the cell and the bar move alike), data/entry-store.mutation.test.ts over the API threshold ('never' throws, 'api' and an absent key write), and e2e/write-refusal.spec.ts drives both in a browser, entries.update() included. Residue: the lint reads syntax, so a Field spread into another object and read there escapes it — the same shape as I12's residue, and mitigated the same way, because data/write-rule.ts is the only import path a gesture or the write door haslint, test:node, test:dom, test:e2eAUTO-PARTIAL
I15Every reading door agrees on a committed rowsrc/data/fields/field-access.test.ts drives one table over all five doors — entry.read, ctx.read, toInput(), the stored value, and the ChangeSet — for every declared Field, core key and props key alike, and asserts one answer. src/api/hierarchy-source.test.ts and src/data/entry-reader.test.ts cover parentId, the key that motivated it. siblingIndex is the one exception the table states rather than hides: entry.read, ctx.read, the stored value, and the ChangeSet agree, but toInput() never names the key, because construction and load place the row, an input never authors its slot. Residue: a door added without a row in that table answers unchecked; the table is the list, so adding a door means adding a rowtest:nodeAUTO-PARTIAL
I16Every sibling group holds exactly 0 through one less than its own size, with no gap and no repeat, after every commitfast-check property over random add, remove, reparent and move sequences on a small tree, checked after every step: src/data/sibling-order.property.test.tstest:nodeAUTO

2. CLAUDE.md hard rules not covered by a numbered invariant​

Rule (source)MechanismGate checkStatus
Pure layers are DOM-freeno-restricted-globals (document, window, navigator, location, HTMLElement, …) scoped to src/{model,time,data,scheduling,layout}; no-restricted-types for DOM lib types; plus the test:node project running with no DOM env at all — a pure module touching the DOM fails by constructionlint, test:nodeAUTO
scheduling/ ↮ render/view/interactiondependency-cruiser rules in both directionsboundariesAUTO
Only api/ + model/ types publicSealed exports map + a resolution test that asserts import('freegantt/data') and friends reject against the built packagebuild, test:nodeAUTO
model/ is types only, zero depsdependency-cruiser: model/ may import nothing. freegantt/model-is-types-only — bans value declarations in src/model/** except the id/brand helper allowlist; errors.ts widens the allowlist to FreeGanttError and its subclasses, and entry.ts widens it to the span predicate spansTimeboundaries, lintAUTO
Storage half-open; no inline end - 1Covered by no-instant-arithmetic (any arithmetic on an Instant-typed value outside time/ is banned, which subsumes end - 1); lastCoveredInstant is the only sanctioned pathlintAUTO
Every mutation through a transactionfreegantt/no-store-mutation-outside-transaction + store mutators requiring a TxToken parameter that only data/transaction.ts can mint (types do half the work). Rule's allowed-caller set is entry-store.ts (the actual internal caller) and transaction.test.tslint, typecheckAUTO
One transaction per gesture at commit= I6test:domPLANNED (S3)
Renderer scope bounded (no lifecycle hooks)freegantt/reconciler-scope — bans identifiers matching `/mountunmountwillUpdate
Entry.kind never derived; no if (kind === …) outside seamsfreegantt/no-kind-conditional with a four-file seam allowlist. None of the four seam files exists yet — only 'span' renders today, so there is nothing to branch onlintPLANNED (S4)
Exactly one runtime dependencyActually two: alien-signals and temporal-polyfill. no-external-runtime-import — a builtin-vehicle rule (no-restricted-imports, not a freegantt/* custom rule) confining each package to its one façade file (src/data/reactivity.ts, src/time/zone.ts). A package-shape test asserting dependencies deep-equals { 'alien-signals': <range>, 'temporal-polyfill': <range> } and peerDependencies is absent does not exist yetlint, test:nodeAUTO-PARTIAL
Greppable event pairs; every before* has a partnerType-level: events are one exported union; a test derives Before<T>/notification pairings from the union and asserts bijection over the pair list, plus asserts each declared name is emitted somewhere in src/ (grep over emit sites)test:nodePLANNED (S2)
Every config key live-reconfigurableTable-driven test: for each key in the config type, assign a second valid value post-mount and assert no remount (mount spy) and an observable effecttest:domPLANNED (S1)
No new Date() / Date.now()no-date-outside-timelintAUTO
Determinism of schedule()no-date-outside-time + repo-wide Math.random ban + a property test asserting two runs over the same request give identical results, and that key insertion order doesn't change output (shuffled input arrays)lint, test:nodeAUTO
No persisting derived datadependency-cruiser: data/serialization may not import layout/; freegantt/no-derived-in-json bans Row/Bar/GeometryFrame type references in src/data/serialization/**boundaries, lintAUTO
layout/ must not import data/dependency-cruiser: layout-boundary allows layout/ → time, model only. Resolved columns and fieldCompares cross on LayoutInputboundariesAUTO
Rollup is a removable leafdependency-cruiser: rollup-is-removable — only data/build-commit-change-set.ts and data/transaction.ts may import data/rollup.ts. scripts/guard-red-test.mjs proves a second importer failsboundariesAUTO
autoGroup is a removable leaf RETIREDdependency-cruiser: autogroup-is-removable — only data/build-commit-change-set.ts and data/transaction.ts may import data/hierarchy.ts. scripts/guard-red-test.mjs proves a second importer fails — The module that inspired this rule was deleted, so the rule named a path that could never exist and the red test reported the guard broken. The invariant is not relaxed, it is fulfilled: the leaf is gone, so there is nothing left to keep removable. Rule and red test removed.—RETIRED
One sync(frame) per animation framefreegantt/raf-single-owner — requestAnimationFrame allowed in exactly one file; test asserts N mutations in one tick produce one synclint, test:domPLANNED (S2)
Slice gates pass before next slicescripts/slice-gate.mjs reads the current slice from .slice, runs its acceptance-linked jobs and prints a checklist. pnpm gate is run deliberately, at a slice boundary, so it sits outside verify and outside CIgate (local)AUTO-PARTIAL (the "harness shows X" items stay human)
Hot path / structure inline-style splitfreegantt/no-inline-style-outside-geometry — bans node.style.<prop> = … for prop outside { transform, width, height }, scoped to src/render/** + src/view/**; structure moves to view/styles.ts's base stylesheet insteadlintAUTO
Published --fg-* tokens and .fg-* Parts match the sheettest/guards/theming-contract.test.ts — every sheet token and class is in docs/05-consumer-api.md, internal names never leak into a consumer table, and documented token defaults match their real sourceguardsAUTO
Spec ids stay in plans/test/guards/spec-labels.test.ts — a slice decision id, a review finding id, or a question id on a line added since main's merge-base fails outside plans/ and docs/adr/. Residue: citations that already sit in src/, docs/, CONTEXT.md and harness/ until a sweep rewrites them; invariant ids (I1–I16) are out of scope on purposeguardsAUTO-PARTIAL
Agent-written production functions stay under a CRAP ceilingscripts/check-crap.mjs scores every non-test function under src/. Metric crap (the default in crap.json) is complexity² × (1 − coverage)³ + complexity, from a Vitest coverage JSON. Metric complexity drops coverage and scores McCabe only — that one field is the back-off. Threshold 36 is the measured current max (placeFrame), so the gate is a ratchetcheck-crapAUTO

3. Honest gaps​

Three things in this table are not fully mechanical, listed so they're consciously owned rather than assumed:

  1. I5 allocation-freedom — source-level allocation is lintable; engine-level allocation is not observable from a test without flaky heap probes. The proxies (no computeFrame call, no source-level allocation, heap-growth ceiling) catch every design regression; a micro-regression inside V8 will not be caught, and that is acceptable.
  2. I12 time→pixel — a determined author can launder a conversion through const a: number = someInstant; a * scale. The layer graph makes the result useless (nothing but TimeScale legitimately owns the px-per-ms factor), so the residue is small and review-visible.
  3. "Harness shows something a human can poke" — every slice's visible acceptance item is a human check by definition until Playwright lands; after that the harness pages get smoke tests, which converts most of these to AUTO.