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
| Status | Meaning |
|---|---|
AUTO | A machine check fails the build on violation. No judgment involved. |
AUTO-PARTIAL | A machine check covers the common/mechanical violations; a named residue needs review. The residue is spelled out. |
REVIEW-ONLY | No 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
| # | Invariant | Mechanism | Gate check | Status |
|---|---|---|---|---|
| I1 | Layer imports match the layer map exactly | dependency-cruiser graph — one forbidden rule per absent arrow; no-restricted-imports mirror for editor speed | boundaries, lint | AUTO |
| I2 | No module-level singletons; two Gantt instances coexist | Two-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 tests | test:dom, lint | AUTO |
| I3 | scheduling/ contains no recursive propagation | freegantt/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 yet | lint, test:node | PLANNED (S7) |
| I4 | schedule() never mutates input; policy never moves a proposed field | fast-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() itself | test:node | AUTO |
| I5 | Hot path allocates nothing, never rebuilds a frame | Proxies (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 none | test:dom, perf (non-blocking) | PLANNED (S3) |
| I6 | One transaction per gesture, at commit | Interaction 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 pointerup | test:dom | PLANNED (S3) |
| I7 | Undo reverts user + engine effects atomically | fast-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 participate | test:node | PLANNED (S2/S7) |
| I8 | Bar.id deterministic across layout passes | Layout test: run computeFrame twice over identical input plus once after an unrelated mutation; assert id sets and per-bar ids stable; snapshot the frame | test:node | AUTO |
| I9 | Grid and timeline share one row geometry | Pixel-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, lint | AUTO-PARTIAL |
| I10 | No time math outside time/; no magic time constants | Three rules: no-magic-time-constants, no-date-outside-time, and no-instant-arithmetic (type-aware) | lint | AUTO |
| I11 | Public .d.ts contains nothing unimplemented | api-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 yet | api-report, lint, test:dom | AUTO-PARTIAL |
| I12 | All pixels-from-time via TimeScale; all scroll via a bound ScrollAxis | Scroll 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 `/px | width | left |
| I13 | Renderer output text-safe by default | Default 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 yet | test:dom; lint PLANNED (S5) | AUTO-PARTIAL |
| I14 | Gesture arming, affordances and entries.update() come from one capability resolution | freegantt/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 has | lint, test:node, test:dom, test:e2e | AUTO-PARTIAL |
| I15 | Every reading door agrees on a committed row | src/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 row | test:node | AUTO-PARTIAL |
| I16 | Every sibling group holds exactly 0 through one less than its own size, with no gap and no repeat, after every commit | fast-check property over random add, remove, reparent and move sequences on a small tree, checked after every step: src/data/sibling-order.property.test.ts | test:node | AUTO |
2. CLAUDE.md hard rules not covered by a numbered invariant
| Rule (source) | Mechanism | Gate check | Status |
|---|---|---|---|
| Pure layers are DOM-free | no-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 construction | lint, test:node | AUTO |
scheduling/ ↮ render/view/interaction | dependency-cruiser rules in both directions | boundaries | AUTO |
Only api/ + model/ types public | Sealed exports map + a resolution test that asserts import('freegantt/data') and friends reject against the built package | build, test:node | AUTO |
model/ is types only, zero deps | dependency-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 spansTime | boundaries, lint | AUTO |
Storage half-open; no inline end - 1 | Covered by no-instant-arithmetic (any arithmetic on an Instant-typed value outside time/ is banned, which subsumes end - 1); lastCoveredInstant is the only sanctioned path | lint | AUTO |
| Every mutation through a transaction | freegantt/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.ts | lint, typecheck | AUTO |
| One transaction per gesture at commit | = I6 | test:dom | PLANNED (S3) |
| Renderer scope bounded (no lifecycle hooks) | freegantt/reconciler-scope — bans identifiers matching `/mount | unmount | willUpdate |
Entry.kind never derived; no if (kind === …) outside seams | freegantt/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 on | lint | PLANNED (S4) |
| Exactly one runtime dependency | Actually 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 yet | lint, test:node | AUTO-PARTIAL |
Greppable event pairs; every before* has a partner | Type-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:node | PLANNED (S2) |
| Every config key live-reconfigurable | Table-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 effect | test:dom | PLANNED (S1) |
No new Date() / Date.now() | no-date-outside-time | lint | AUTO |
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:node | AUTO |
| No persisting derived data | dependency-cruiser: data/serialization may not import layout/; freegantt/no-derived-in-json bans Row/Bar/GeometryFrame type references in src/data/serialization/** | boundaries, lint | AUTO |
layout/ must not import data/ | dependency-cruiser: layout-boundary allows layout/ → time, model only. Resolved columns and fieldCompares cross on LayoutInput | boundaries | AUTO |
| Rollup is a removable leaf | dependency-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 fails | boundaries | AUTO |
autoGroup is a removable leaf | 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 | — | RETIRED |
One sync(frame) per animation frame | freegantt/raf-single-owner — requestAnimationFrame allowed in exactly one file; test asserts N mutations in one tick produce one sync | lint, test:dom | PLANNED (S2) |
| Slice gates pass before next slice | scripts/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 CI | gate (local) | AUTO-PARTIAL (the "harness shows X" items stay human) |
| Hot path / structure inline-style split | freegantt/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 instead | lint | AUTO |
Published --fg-* tokens and .fg-* Parts match the sheet | test/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 source | guards | AUTO |
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 purpose | guards | AUTO-PARTIAL |
| Agent-written production functions stay under a CRAP ceiling | scripts/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 ratchet | check-crap | AUTO |
3. Honest gaps
Three things in this table are not fully mechanical, listed so they're consciously owned rather than assumed:
- I5 allocation-freedom — source-level allocation is lintable; engine-level allocation is not observable from a test without flaky heap probes. The proxies (no
computeFramecall, no source-level allocation, heap-growth ceiling) catch every design regression; a micro-regression inside V8 will not be caught, and that is acceptable. - 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 butTimeScalelegitimately owns the px-per-ms factor), so the residue is small and review-visible. - "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.