Skip to main content

FreeGantt — Guardrails Overview

Status: Shipped. This folder specifies the enforcement system that makes 14 custom rules in eslint/rules/, 15 guard suites in test/guards/, and the whole gate runs in CI on every change.

What this folder is: the answer to "how do we make the rules in CLAUDE.md deterministic — machine-checked, failing loudly, on every change — instead of things a reviewer has to remember."

DocContents
00-guardrails-overview.md (this)Philosophy, the five defense layers, what is deliberately not automated
01-invariant-guard-matrix.mdEvery rule in the spec → its mechanism → its CI job → honest status
02-lint-rules.mdSpec for each custom ESLint rule: what it bans, allowlist, message, fixtures
03-boundaries-and-config.mddependency-cruiser, tsconfig, exports map, Vitest projects, package checks
04-hooks-and-ci.mdGit hooks, Claude Code hooks, the CI pipeline, guard-test meta-suite
05-consumer-api.mdIndex for app authors — links README, glossary, export report
10-styling-and-theming.mdStyling reference — the --fg-* token levels, the Parts list, data-flag, date lines and the Today line
06-plugin-authoring.mdPlugin authoring guide — the two-halves shape, every registration seam, disposal
07-row-source-updates.mdHow to change one rowSource setting and keep the rest
08-a-bar-is-an-entry.mdA Bar is one child Entry by default
09-integration-pitfalls.mdIntegration reports that were misunderstandings — what the reader searched, and the real answer
edit-extension-flow.mdThe extension hook — flow and sample usage for data/edit-extension.ts

1. Principle: a rule that isn't executable is a wish​

The standard here is: "Every invariant must name the CI job that enforces it; an invariant without a job is a TODO, tracked in the table itself." This folder extends that standard from the 14 numbered invariants to every hard rule in CLAUDE.md, and adds three commitments:

  1. Every guard is itself tested. A lint rule with no failing fixture is indistinguishable from a lint rule that silently matches nothing. This is required for the layer boundary ("the red test — prove the gun is loaded"); we generalize it to all guards.
  2. Guards fail at the earliest layer that can catch them. A violation caught by the type system costs seconds; by an editor lint, a minute; by CI, ten minutes; by review, a day; by a user, a release. Push every rule down the stack as far as it will go.
  3. Where a rule genuinely cannot be automated, it is labeled REVIEW-ONLY in the matrix with a one-line reason. No rule is allowed to be silently unenforced. Two of them (I5 hot-path allocation, part of I12) are on that list today, with the partial mechanical proxies named.

2. The five layers of defense​

L4 is a convenience layer: it runs a fast subset of L2/L3 early, for the agent and for the human. It is never the only place a rule lives — a rule enforced solely by a git hook is a rule that dies the first time someone passes --no-verify. CI is the authority; hooks are the fast feedback.

3. Ownership of each spec source​

SourceRules it contributesPrimary mechanism
Layer mapLayer isolation, pure-layer DOM freedomdependency-cruiser + no-restricted-globals + Node-env test project
Time policyHalf-open storage3 lint rules (2 builtin-restriction, 1 type-aware custom)
DataTransactions-only mutation, no singletons2 custom lint rules + isolation test
SchedulingScheduling rulesrecursion lint rule + 5k fixture + fast-check purity property
Rendering / viewReconciler scope and rules3 custom lint rules + reconciler unit tests
Entry kindsNo if (kind === …) outside seamscustom lint rule with seam allowlist
InvariantsAll rulesthe matrix in 01-invariant-guard-matrix.md, which matrix-coverage.test.ts holds to the invariant list
API surfaceNaming pairs, no "not implemented", JSON contractevent-pair test + api-report diff + custom lint rule
DependenciesExactly one runtime deppackage-shape test + import allowlist
CLAUDE.mdVendor-name ban, workflow rulesrepo-wide grep check

4. What we deliberately do not automate​

Recording these so nobody later mistakes the gap for an oversight:

  • "Is this the right design?" Guards enforce the decided architecture; they cannot tell us a decision was wrong. When a guard is fighting the code, the correct first question is "is the code wrong?" and the correct second is "should the spec change?" — never "let me add an eslint-disable."
  • A global coverage percent as a quality target. One later gate still waits (>90% on scheduling/, slice S7). We do not fail the build on a repo-wide percent: that drives test-shaped noise. We do score each production function with CRAP (complexity mixed with that function's own coverage). crap.json holds the ceiling. Set "metric": "complexity" there to drop the coverage half and keep only McCabe.
  • Formatting debates. Prettier decides, nobody reviews it, --check in CI.
  • Performance budgets before measured spikes. Budgets that predate a measured spike are guesses. The jobs are scaffolded early and non-blocking; they gate after measurement.

5. Escape hatches, and their price​

There is exactly one sanctioned way to bypass a custom rule: an inline disable with a reason comment naming the spec section that permits it.

// eslint-disable-next-line freegantt/no-date-outside-time -- time/zone.ts is the sanctioned Intl boundary

A dedicated CI job (disables) collects every eslint-disable for a freegantt/* rule and fails if one lacks a -- reason. The count is printed in the job summary, so growth is visible in review. Disables of the layer graph (dependency-cruiser) are not available inline at all: the graph is edited in one file, in a reviewed commit, or not at all.

5.1 isDevMode() is not an escape hatch, and "warn in dev mode" is the phrase to distrust​

isDevMode() (src/data/dev-mode.ts) reads import.meta.env.DEV. That is not a runtime question. Vite replaces it with a literal when the code reading it is built, and the code reading it is this library — so the value is fixed when this repo builds dist/. A consumer's own dev server never re-evaluates it. Their development build and their production build both receive false.

Never gate anything a consumer needs to see.

A diagnostic behind this flag is not a warning that appears in development. It is a warning deleted from the product. It is worse than an omission, for three reasons that compound:

  • It reads as deliberate. A reviewer sees an intent that the mechanism does not deliver.
  • Our tests run with DEV === true, so the gated branch is the only branch they exercise. The suite is green and proves nothing about what a consumer gets.
  • Nobody reports the absence. A consumer cannot miss a line they have never seen.

This has bitten three times. 'scale-options-ignored' and the corrected-rollup report were both gated, and no consumer ever received one. 'variant-matched-twice' was written the same way and caught in review before it shipped. All three were specified as "warn in dev mode", which is why that phrase is the signal: it names an intent this flag cannot carry.

What it is legitimately for: making our own development stricter, at a cost we do not want to charge a consumer. transaction.ts deep-freezes a ChangeSet so our tests catch a mutation. build-commit-change-set.ts asserts an extension hook did not overwrite the body. Both would still be correct if they never ran anywhere else. The test is: would a consumer want this? If yes, it must not be gated.

The replacement, when the answer is yes: raise it through raiseError at severity: 'warning', in every build. When the real concern is cost rather than noise, remove the cost by not asking the question — produce-bars.ts stops its claim scan at the first match when no report sink is wired, rather than gating the report.

6. Implementation order​

Guardrails land before the code they guard. Concretely:

  1. tsconfig + prettier + eslint baseline (typescript-eslint recommended-type-checked)
  2. dependency-cruiser graph + its red test (before any src/ code)
  3. The local ESLint plugin with the 8 rules whose targets exist early + RuleTester fixtures
  4. Vitest pure/dom projects + the guard-test suite
  5. Git hooks + Claude Code hooks (cheap, once the commands exist)
  6. CI workflow wiring all of it — the same commands the hooks run

Rules whose subject matter doesn't exist yet (e.g. no-store-mutation-outside-transaction) are written when their slice starts, but their row in the matrix exists now so the gap is tracked rather than forgotten.