3.9 KiB
Architecture lint
Kandev turns a small set of accepted architecture boundaries into fast repository checks. The
pre-commit hook runs them automatically; use make lint-architecture to run them directly. CI runs
the same dependency-free Python linter against tracked files and reports path:line diagnostics.
Executable tooling lives under scripts/, durable rule configuration lives under config/, and
the GitHub Actions workflow only invokes those repository-owned entry points.
Enforced boundaries
| Rule | Boundary | Intended seam |
|---|---|---|
ARCH-RUNTIME-IMPORT |
Production Go outside internal/agent/runtime/ must not add direct imports of runtime/lifecycle or runtime/agentctl. |
Depend on internal/agent/runtime or an explicitly approved low-level adapter. |
ARCH-TASK-OFFICE-IMPORT |
Production Go under internal/task/ must not add imports of internal/office. |
The shared task model owns task concepts; Office consumes or adapts them. |
ARCH-FRONTEND-ROOT-STATE-CAST |
apps/web/lib/state/store.ts must not add as any or as unknown as escapes. |
Derive typed domain state, actions, and defaults. |
Each rule owns its exact grandfathered finding set under config/architecture-lint/. A current
finding absent from that rule's baseline fails. When cleanup removes a finding, the now-stale entry
also fails, so the same change must delete the exemption. CI compares each file with the pull
request base and rejects additions; a rule's baseline can only shrink after its initial rollout.
Normal lint never rewrites baselines.
To reduce the baseline:
- Remove the architecture violation.
- Run
python3 scripts/lint-architecture.py --allto see the stale entry. - Delete that exact entry from the rule's
config/architecture-lint/<rule>.jsonfile. - Run
make lint-architectureand the script tests.
Adding a rule
Rules are intentionally modular so progressive migrations do not grow one central linter or baseline file. A new rule adds:
- One scanner module under
scripts/architecture_lint/rules/, exporting aRulewith its stable ID, slug, baseline path, path predicate, and scanner. - One focused test module under
scripts/architecture_lint_tests/. - One exact baseline under
config/architecture-lint/. - One explicit import in the small
rules/__init__.pyregistry and one boundary row above.
The scanner owns its actionable migration message. It must name the intended replacement seam, not only report that a violation exists. Shared code owns git discovery, comparison, annotations, and ledger validation; it must not accumulate rule-specific conditions. A baseline absent from the target branch is treated as a new rule's reviewed bootstrap. After that first merge, CI permits only removals from that rule's baseline.
Compatibility ledger
Intentional compatibility aliases and fallbacks belong in
config/architecture-lint/compatibility-ledger.json. Add an entry in the same change that introduces
the compatibility behavior. Every entry has:
- a stable lowercase
id; - a tracked
locator.pathand exact, stablelocator.markernear the compatibility behavior; - a non-empty
reasonand accountableowner; - exactly one introduction date (
introduced_on) or SemVer (introduced_version); - a testable
removal_condition; - exactly one target removal date (
target_removal_date) or SemVer (target_removal_version).
Date targets fail after their target day. The linter also rejects invalid dates or versions, duplicate IDs, missing fields, untracked paths, and markers that disappeared. Removing the compatibility code therefore requires removing its ledger entry. Version targets remain explicit review checkpoints; use a date target when automatic calendar expiry is required.
The ledger is intentionally explicit. The linter does not guess from words such as legacy,
fallback, or alias, because those terms also describe permanent domain behavior and would
create noisy false positives.