Skip to main content

FreeGantt — Boundaries, Types, and Test Rig

The guards that aren't lint rules: the layer graph, the type-system settings that make whole bug classes unrepresentable, the sealed package surface, and the Vitest project split that proves the purity claims by construction.


1. dependency-cruiser — the layer map as executable config​

The layer map is a diagram with 16 arrows. The config transcribes it literally and by name, so a reviewer can verify it line by line.

1.1 Approach: allowlist, not denylist​

A denylist ("scheduling must not import render") only forbids the violations we thought of. We instead declare, per layer, the complete set of layers it may reach, and forbid everything else:

// .dependency-cruiser.cjs (excerpt — shape, not the full file)
const ALLOWED = {
model: [],
time: ['model'],
data: ['model', 'scheduling', 'time'],
scheduling: ['model', 'time'],
layout: ['model', 'time'],
render: ['model', 'time', 'layout'],
view: ['model', 'time', 'layout', 'data', 'render'],
interaction: ['model', 'time', 'data', 'view'],
extensions: ['model', 'time', 'data', 'view', 'interaction'],
api: ['model', 'time', 'data', 'view', 'interaction', 'extensions'],
};

From that one object the config generates:

RuleSeverityWhat it catches
layer-<x>-may-only-reach (one per layer)errorAny arrow absent from the layer map
no-circularerrorCycles between or within layers
no-orphanswarnDead modules (excluding model/, type-only files)
not-to-dev-deperrorA runtime import of a devDependency
only-data-imports-reactive-deperroralien-signals reached from anywhere but src/data/reactivity.ts
no-deprecated-coreerrorNode builtins in src/ (the library must run in a browser)
pure-may-not-reach-dom-layerserrorPure layers may not reach rendering or interaction layers
harness-public-api-onlyerrorharness/, e2e/ or fixtures/ reaching an internal under src/ by a relative path

The pure-may-not-reach-dom-layers rule states the boundary between pure and visual layers in the diagram's own vocabulary for a clear error message.

harness-public-api-only is the one rule in this file scoped outside src/: it treats harness/, e2e/ and fixtures/ as the library's first consumers, so a workaround inside them meets the same sealed exports map a real published package meets, and cannot silently reach an internal (CLAUDE.md's stop rule). What it actually enforces is narrower than "never a relative path." dependency-cruiser matches resolved paths, and the freegantt alias (vite.config.ts) resolves to src/api/index.ts — the same file a hand-written '../src/api/index.js' resolves to. So pathNot: '^src/api/index\.ts$', the clause that lets the alias through, lets that hand-written path through with it; the rule catches a relative reach past the index, not a relative path naming the index. Closing that second case is eslint.config.js's no-restricted-imports/no-restricted-syntax block, scoped to the same three folders — it reads the specifier text an author wrote, which is the thing a resolved-path tool cannot see. The two guards are belt and braces; pnpm boundaries scans all four folders for the first, pnpm lint for the second, and scripts/guard-red-test.mjs proves both actually block a violation.

1.2 Deep-import discipline​

Modules import through their layer's index.ts barrel only (layout/index.ts, not layout/rows/sort.ts); enforced by a forbidden rule on cross-layer paths with more than one segment. Intra-layer imports go direct — barrels inside a layer create cycles.

1.3 The red test​

A CI step that proves the guard is armed:

scripts/guard-red-test.mjs
1. write src/scheduling/__redtest__.ts containing: import '../render/dom/index.js'
2. run depcruise --config .dependency-cruiser.cjs src → must exit non-zero
3. run eslint on that file → must exit non-zero (B11 mirror)
4. delete the file; fail the job if either exited zero

This is generalized to all guards in 04-hooks-and-ci.md; the layer boundary keeps its own dedicated step for early detection.


2. tsconfig.json — bug classes made unrepresentable​

The following TypeScript configuration flags enforce key guarantees:

SettingWhy, for this codebase
strict: truebaseline
noUncheckedIndexedAccessentriesById[id] is Entry | undefined. The normalized stores are index-lookup-heavy; this is the flag that stops the "it was there a frame ago" class
exactOptionalPropertyTypesa changeset's from: undefined (field was absent) and an absent from key are different facts. Without this flag they collapse
verbatimModuleSyntaxtype imports never emit runtime imports — load-bearing for model/ being types-only and for tree-shaking
isolatedModuleskeeps every file independently transpilable (Vite/esbuild parity)
noPropertyAccessFromIndexSignatureprops.team on an unknown-shaped TProps must be deliberate
skipLibCheck, forceConsistentCasingInFileNames, resolveJsonModuleordinary hygiene; no invariant rides on them

Not set, although this page once claimed them: noImplicitOverride, noFallthroughCasesInSwitch and erasableSyntaxOnly are absent from tsconfig.json, and Nothing in src/ depends on them today. Turning them on is a reasonable small change; claiming them while they are off is not.

Two type-level guards worth calling out as design, not config:

  • The Instant brand is what makes no-instant-arithmetic possible at all. It is a type-only construct with zero runtime cost, and it is the reason time/ can be the sole owner of zone-aware date math.
  • The TxToken makes "mutation outside a transaction" a type error, not just a lint error. Same trick: a non-exported branded type minted by exactly one function.

typecheck runs tsc --noEmit over src/, harness/, test/, fixtures/, e2e/ and playwright.config.ts — the whole include list. Tests are not excused from strictness, because a test that compiles under looser rules proves less than it claims.


3. The sealed public surface​

3.1 exports map​

Only . resolves for imports:

{
"exports": { ".": { "types": "./dist/api/index.d.ts", "import": "./dist/api/index.js" } },
"sideEffects": ["src/view/styles.ts"],
"type": "module"
}

sideEffects is a one-file allowlist, not false: view/styles.ts injects the base stylesheet on import, so a bundler that drops it as dead code ships an unstyled Gantt. Everything else in the package is side-effect free, which is what keeps tree-shaking real. test/guards/package-shape.test.ts holds the array to that one file.

3.2 Tests that keep it sealed​

CheckMechanism
Internals unreachabletest:node resolution test: import('freegantt/data'), freegantt/dist/data/index.js, freegantt/src/* — each must reject. Run against a pnpm packed tarball installed into a temp dir, not against the source tree (only the packed artifact tells the truth)
Public surface unchanged without noticeapi-extractor report (etc/freegantt.api.md) committed; CI regenerates and fails on diff. The diff is the semver conversation (I11)
Nothing unimplemented in the surfaceno-not-implemented lint (B8) + a smoke test constructing Dataset/Gantt and invoking every zero-arg public method
Runtime deps stay at twoPackage-shape test: dependencies deep-equals { 'alien-signals': <range>, 'temporal-polyfill': <range> }; peerDependencies/optionalDependencies absent; bundledDependencies absent. The test does not exist yet — 01-invariant-guard-matrix.md's own row says so; no-external-runtime-import is what holds the line today
Tree-shakeabilitysize-limit entry importing only Dataset must not pull in interaction/ or extensions/

4. Vitest projects — purity by construction​

The pure layers are tested in an environment where the DOM does not exist. This is a critical test-rig decision.

// vitest.config.ts (shape)
export default defineConfig({
test: {
projects: [
{ test: { name: 'pure', environment: 'node',
include: ['src/{model,time,data,scheduling,layout}/**/*.test.ts', 'test/pure/**/*.test.ts'],
setupFiles: ['test/setup/assert-no-dom.ts'] } },
{ test: { name: 'dom', environment: 'happy-dom',
include: ['src/{render,view,interaction,extensions,api}/**/*.test.ts', 'test/dom/**/*.test.ts'] } },
],
},
});

assert-no-dom.ts does one thing: define document/window/navigator as getters that throw with a message citing D4. In plain Node they'd be undefined and a violation would surface as a confusing TypeError; this way the failure names the rule.

Consequence worth stating plainly: a pure module that acquires a DOM dependency fails its own unit tests immediately, before lint, before CI, before review. That is the earliest possible layer, and it costs one setup file.

4.1 Standing tests that live outside any one slice​

These are guard tests, not feature tests; they belong to the guardrail system:

TestInvariant
test/guards/two-gantt-isolation.test.tsI2
test/guards/bar-identity.test.tsI8
test/guards/package-shape.test.tsone runtime dep, exports sealed
test/guards/null-backend-in-node.test.tspure pipeline runs headless
test/guards/schedule-purity.property.tsI4
test/guards/undo-roundtrip.property.tsI7
test/guards/chain-5000.test.tsI3
test/guards/row-geometry-parity.test.tsI9
test/guards/event-pair-bijection.test.tsevent naming rules
test/guards/live-reconfigure.test.tsevery config key live
test/guards/hot-path-no-frame.test.tsI5
test/guards/capability-single-resolution.test.tsI14

Keeping them in test/guards/ rather than beside their modules is deliberate: they are the executable form of the invariant list, and a reviewer should be able to read that directory as the complete set.


5. Fixtures as guards​

Two fixtures carry enforcement weight beyond being test data:

  • fixtures/chain-5000.ts — a 5,000-link dependency chain. Its only job is to blow the stack if propagation ever becomes recursive (I3). It is generated by a function, committed as a generator not as data.
  • fixtures/golden/*.json — scheduling request/result pairs. A policy change that alters a golden result is allowed, but it must show up as a reviewed diff to the committed expectation — the engine may never change behavior invisibly (principle 6, diagnostics over silent fixes).

Golden files are regenerated with pnpm test:golden --update, which is deliberately not run in CI.