chore: add architecture lint guardrails (#2168)

This commit is contained in:
Carlos Florêncio
2026-08-03 22:40:29 +01:00
committed by GitHub
parent 90da2d66f2
commit bb71233826
32 changed files with 2159 additions and 5 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Architecture Lint
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
# Pull requests can supersede one another safely. Pushes compare each commit
# with its own before-SHA, so never cancel a push run before that comparison
# completes.
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
lint:
name: Architecture boundaries do not regress
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
fetch-depth: 0
persist-credentials: false
- name: Run linter tests
run: python3 scripts/lint-architecture.test.py
- name: Lint pull request architecture
if: github.event_name == 'pull_request'
env:
ARCHITECTURE_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: >-
python3 scripts/lint-architecture.py --all
--baseline-base-ref "$ARCHITECTURE_BASE_REF"
--allow-missing-base-baseline
- name: Lint pushed architecture
if: github.event_name == 'push'
env:
ARCHITECTURE_BASE_REF: ${{ github.event.before }}
run: >-
python3 scripts/lint-architecture.py --all
--baseline-base-ref "$ARCHITECTURE_BASE_REF"
--allow-missing-base-baseline
+6
View File
@@ -18,6 +18,12 @@ repos:
language: system language: system
pass_filenames: true pass_filenames: true
- id: architecture-lint
name: Architecture lint and compatibility expiry
entry: python3 scripts/lint-architecture.py --all
language: system
pass_filenames: false
# Backend (Go) hooks # Backend (Go) hooks
- repo: local - repo: local
hooks: hooks:
+8 -1
View File
@@ -137,6 +137,7 @@ help:
@echo " lint Run linters for both components" @echo " lint Run linters for both components"
@echo " lint-backend Run Go linters" @echo " lint-backend Run Go linters"
@echo " lint-web Run ESLint" @echo " lint-web Run ESLint"
@echo " lint-architecture Enforce architecture budgets and compatibility expiry"
@echo " lint-format Check formatting with Prettier (web/cli/packages)" @echo " lint-format Check formatting with Prettier (web/cli/packages)"
@echo " fmt Format all code" @echo " fmt Format all code"
@echo " fmt-backend Format Go code" @echo " fmt-backend Format Go code"
@@ -497,6 +498,7 @@ test-scripts:
@bash scripts/opencode-code-review.test.sh @bash scripts/opencode-code-review.test.sh
@python3 scripts/opencode-code-review.test.py @python3 scripts/opencode-code-review.test.py
@python3 scripts/lint-harness-files.test.py @python3 scripts/lint-harness-files.test.py
@python3 scripts/lint-architecture.test.py
@bash scripts/release-desktop.test.sh @bash scripts/release-desktop.test.sh
@node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs @node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs
@node --test scripts/validate-public-docs.test.mjs @node --test scripts/validate-public-docs.test.mjs
@@ -543,7 +545,7 @@ test-e2e-ci:
# #
.PHONY: lint .PHONY: lint
lint: lint-backend lint-web lint-harness lint: lint-backend lint-web lint-harness lint-architecture
@printf "\n$(GREEN)$(BOLD)✓ Linting complete!$(RESET)\n" @printf "\n$(GREEN)$(BOLD)✓ Linting complete!$(RESET)\n"
.PHONY: lint-backend .PHONY: lint-backend
@@ -561,6 +563,11 @@ lint-harness:
@printf "$(CYAN)Linting harness files...$(RESET)\n" @printf "$(CYAN)Linting harness files...$(RESET)\n"
@python3 .github/scripts/lint-harness-files.py --all @python3 .github/scripts/lint-harness-files.py --all
.PHONY: lint-architecture
lint-architecture:
@printf "$(CYAN)Linting architecture...$(RESET)\n"
@python3 scripts/lint-architecture.py --all
.PHONY: lint-format .PHONY: lint-format
lint-format: lint-format:
@printf "$(CYAN)Checking formatting...$(RESET)\n" @printf "$(CYAN)Checking formatting...$(RESET)\n"
+9 -4
View File
@@ -68,6 +68,7 @@ apps/backend/
│ ├── integrations/ # Shared shapes for third-party integrations │ ├── integrations/ # Shared shapes for third-party integrations
│ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear) │ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear)
│ │ └── secretadapter/ # Upsert-style adapter over secrets.SecretStore │ │ └── secretadapter/ # Upsert-style adapter over secrets.SecretStore
│ ├── i18n/ # Localization for backend-rendered browser/share artifacts
│ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller) │ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller)
│ ├── linear/ # Linear integration (config, GraphQL client, poller) │ ├── linear/ # Linear integration (config, GraphQL client, poller)
│ ├── lsp/ # LSP server │ ├── lsp/ # LSP server
@@ -270,13 +271,17 @@ You may still list the column in the `CREATE TABLE` so fresh DBs get it inline,
Built-in prompt content refreshes are seed-data migrations, not schema migrations. Match only known historical content hashes after applying the same normalization as the embedded prompt loader, require `created_at == updated_at` to preserve user edits, and use a conditional update over the original row values to avoid racing concurrent edits. Keep these refreshes with prompt seeding rather than `runMigrations()`. Built-in prompt content refreshes are seed-data migrations, not schema migrations. Match only known historical content hashes after applying the same normalization as the embedded prompt loader, require `created_at == updated_at` to preserve user edits, and use a conditional update over the original row values to avoid racing concurrent edits. Keep these refreshes with prompt seeding rather than `runMigrations()`.
## Internationalization
`internal/i18n` renders only browser-facing copy: SPA-unavailable pages and shared-task artifacts. Diagnostics, logs, agent/ACP output, and CLI output remain English.
Use `i18n.T`/`i18n.Tf` with explicit locale threading (including interpolation/plurals); resolve artifact locale at creation. Catalogs are embedded in `internal/i18n/locales/`; regenerate `pseudo` with `pnpm run i18n:pseudo`.
Prefer stable error codes for new output so the frontend translates it. See `docs/i18n.md` and ADR `2026-08-01-share-artifact-locale.md`.
## Code-quality limits ## Code-quality limits
Enforced by `apps/backend/.golangci.yml` (errors on new code only): Enforced by `apps/backend/.golangci.yml` (errors on new code only):
- Functions: ≤80 lines, ≤50 statements - Functions: ≤80 lines, ≤50 statements · Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30
- Cyclomatic complexity: ≤15 · Cognitive complexity: ≤30 - Nesting depth: ≤5 · Naked returns only in functions ≤30 lines · No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences)
- Nesting depth: ≤5 · Naked returns only in functions ≤30 lines
- No duplicated blocks (≥150 tokens) · Repeated strings → constants (≥3 occurrences)
When you hit a limit, extract a helper function. Prefer composition over growing a single function. When you hit a limit, extract a helper function. Prefer composition over growing a single function.
@@ -0,0 +1,41 @@
{
"version": 1,
"entries": [
{
"id": "agent-registry-type-aliases",
"locator": {
"path": "apps/backend/internal/agent/registry/registry.go",
"marker": "// Type aliases for backward compatibility during migration."
},
"reason": "Callers still refer to agent configuration types through the registry package after ownership moved to the agents package.",
"owner": "backend agent-runtime maintainers",
"introduced_on": "2026-02-14",
"removal_condition": "All callers import the owning agents package and no registry compatibility type alias remains.",
"target_removal_date": "2027-02-01"
},
{
"id": "agent-model-fetcher-shim",
"locator": {
"path": "apps/backend/internal/agent/settings/modelfetcher/cache.go",
"marker": "// shim for legacy callers until they migrate."
},
"reason": "Legacy callers still require the old typed cache while model discovery moves to the host utility capability cache.",
"owner": "backend agent-settings maintainers",
"introduced_on": "2026-04-15",
"removal_condition": "No production package imports internal/agent/settings/modelfetcher and the shim package can be deleted.",
"target_removal_date": "2027-02-01"
},
{
"id": "passthrough-stdin-writes-legacy-view",
"locator": {
"path": "apps/backend/internal/agent/agents/passthrough_payload.go",
"marker": "// PlanPassthroughStdinWrites is the legacy string-only view of PlanPassthroughStdinChunks."
},
"reason": "Callers that do not yet consume timed passthrough chunks use a string-only compatibility view.",
"owner": "backend agent-runtime maintainers",
"introduced_on": "2026-05-25",
"removal_condition": "All callers use PlanPassthroughStdinChunks and preserve per-chunk delays where required.",
"target_removal_date": "2027-02-01"
}
]
}
@@ -0,0 +1,300 @@
{
"version": 1,
"rule": "ARCH-FRONTEND-ROOT-STATE-CAST",
"entries": [
{
"escape": "as any",
"marker": "...createKanbanSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createKanbanSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createKanbanSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createWorkspaceSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createWorkspaceSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createWorkspaceSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSettingsSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSettingsSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSettingsSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionRuntimeSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionRuntimeSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSessionRuntimeSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitHubSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitHubSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitHubSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitLabSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitLabSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createGitLabSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createAzureDevOpsSlice(set as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createJiraSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createJiraSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createJiraSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createLinearSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createLinearSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createLinearSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createOfficeSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createOfficeSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createOfficeSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createFeaturesSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createFeaturesSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createFeaturesSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createAuthSlice(set as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSystemSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSystemSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createSystemSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createUISlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createUISlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createUISlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createAutomationsSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createAutomationsSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createAutomationsSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createPluginsSlice(set as any, get as any, api as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createPluginsSlice(set as any, get as any, api as any),",
"occurrence": 2,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createPluginsSlice(set as any, get as any, api as any),",
"occurrence": 3,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "...createReviewSlice(set as any),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
},
{
"escape": "as any",
"marker": "hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)),",
"occurrence": 1,
"path": "apps/web/lib/state/store.ts"
}
]
}
@@ -0,0 +1,262 @@
{
"version": 1,
"rule": "ARCH-RUNTIME-IMPORT",
"entries": [
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/controller/controller.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/agent/handlers/git_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/git_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/passthrough_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/port_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/review_change_source.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/shell_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/vscode_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/agent/handlers/workspace_file_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/agent/handlers/workspace_file_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/agent/hostutility/manager.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/backendapp/adapters.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/adapters.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/backendapp/agentctl.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl/launcher",
"path": "apps/backend/internal/backendapp/agentctl.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/backendapp/agents.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/agents.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/boot_state.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/branch_materializer.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/gateway.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/backendapp/helpers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/helpers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/backendapp/main.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/main.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill",
"path": "apps/backend/internal/backendapp/main.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/metrics_provider.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/orchestrator.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/storage_inventory.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/storage_maintenance.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/workspace_source_materializer.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/worktree.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/lsp_handler.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/port_proxy.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/port_tunnel.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/setup.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/terminal_handler.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/terminal_sessions.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/gateway/websocket/vscode_proxy.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle/skill",
"path": "apps/backend/internal/office/skills/runtime_adapter.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_agent.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_clarification.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_git.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_prepare.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_stall.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/event_handlers_streaming.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/orchestrator/executor/executor.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor_environment_reuse.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor_execute.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor_interaction.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor_resume.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/executor/executor_state.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/orchestrator/task_operations.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/task_operations.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/orchestrator/watcher/watcher.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/ssh/handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/task/handlers/executor_profile_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/task/handlers/message_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/agentctl",
"path": "apps/backend/internal/task/handlers/process_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/task/handlers/process_handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/task/service/service_turns.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/utility/handlers/handlers.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/backendapp/diagnostic_acp_exporter.go"
},
{
"import": "github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"path": "apps/backend/internal/mcp/handlers/diagnostic_bundle.go"
}
]
}
@@ -0,0 +1,34 @@
{
"version": 1,
"rule": "ARCH-TASK-OFFICE-IMPORT",
"entries": [
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_cascade.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_cleaner.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_context.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_materialize.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_restore.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/handoff_service.go"
},
{
"import": "github.com/kandev/kandev/internal/office/models",
"path": "apps/backend/internal/task/service/service_office.go"
}
]
}
+68
View File
@@ -0,0 +1,68 @@
# 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:
1. Remove the architecture violation.
2. Run `python3 scripts/lint-architecture.py --all` to see the stale entry.
3. Delete that exact entry from the rule's `config/architecture-lint/<rule>.json` file.
4. Run `make lint-architecture` and 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:
1. One scanner module under `scripts/architecture_lint/rules/`, exporting a `Rule` with
its stable ID, slug, baseline path, path predicate, and scanner.
2. One focused test module under `scripts/architecture_lint_tests/`.
3. One exact baseline under `config/architecture-lint/`.
4. One explicit import in the small `rules/__init__.py` registry 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.path` and exact, stable `locator.marker` near the compatibility behavior;
- a non-empty `reason` and accountable `owner`;
- 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.
@@ -0,0 +1,76 @@
# ADR-2026-08-01-architecture-lint-budgets: Architecture Lint Budgets and Compatibility Expiry
**Status:** accepted (amended 2026-08-02)
**Date:** 2026-08-01
**Area:** infra
## Context
Kandev documents important package and state-ownership boundaries in `AGENTS.md` and ADRs, but
documentation alone does not prevent a fast-moving codebase from adding new violations. Existing
runtime, task, and frontend state-composition debt is too large to fail all at once. Compatibility
aliases and fallbacks also need consistent ownership and a concrete removal trigger.
This is internal CI and repository-process behavior. It changes no product behavior, so no product
spec is needed.
## Decision
Accepted architecture boundaries may be enforced with deterministic, dependency-free repository
checks backed by explicit reviewed finding allowlists under `config/architecture-lint/`.
Existing debt is grandfathered by exact path and import or source-marker identity. New findings
fail, removed findings make their exemptions stale, and CI compares each baseline with the target
branch so its reviewed allowlist may only shrink. Normal lint never rewrites baselines.
Each rule owns one scanner module under `scripts/architecture_lint/rules/`, one focused
test module under `scripts/architecture_lint_tests/`, and one baseline JSON file. A small explicit
registry is the only central rule list. Shared runner modules own repository discovery, baseline
comparison, diagnostics, and compatibility-ledger validation and contain no rule-specific scan
conditions. A rule whose baseline is absent from the target branch may bootstrap its initial
reviewed entries; after merge, that per-rule baseline becomes shrink-only.
Architecture-lint executable code is repository tooling owned under `scripts/`; reviewed
baselines and the compatibility ledger are durable repository configuration owned under `config/`.
The file under `.github/workflows/` is only a CI adapter that invokes the same codebase-owned
entrypoint used by Make and pre-commit. GitHub-specific directories do not own the linter's
implementation or policy data.
The initial checks enforce the documented agent-runtime import seam, the rule that shared task code
must not depend on Office models, and the narrow root Zustand composition boundary. Diagnostics
must identify the rule, source location, and intended replacement seam.
Intentional compatibility exceptions are registered in
`config/architecture-lint/compatibility-ledger.json`. Every entry requires a stable identifier and
source locator, reason, owner, introduction date or version, removal condition, and target removal
date or version. Date-based entries fail after expiry, and entries fail when their tracked path or
marker disappears. New compatibility behavior must register explicitly; broad keyword discovery
is not part of this foundation.
## Consequences
Pull requests cannot silently increase these known forms of architecture debt, while existing
cleanup can land incrementally. Baseline and ledger edits become visible review decisions, and
contributors receive migration guidance at the violating line. Source refactors that move an
allowed finding or compatibility marker require an intentional metadata update, which adds some
maintenance but prevents dead exemptions from accumulating.
Additional rules should be added only after their canonical contract is accepted. This decision
does not establish an aggregate architecture score or speculative checks for transport catalogs,
typed events, or generic compatibility keywords.
## Alternatives Considered
- **Documentation-only enforcement.** Rejected because it depends on every contributor and
reviewer remembering every boundary on every change.
- **Immediately fail all current violations.** Rejected because it would couple this safety net to
a large cleanup and make adoption impractical.
- **Use a raw aggregate architecture score.** Rejected because deleting one violation could hide a
different newly introduced violation.
- **Allow permanently growing exception lists.** Rejected because allowlists without shrink-only
comparison turn temporary debt into an unbounded bypass mechanism.
- **Keep all scanners, tests, and findings in central files.** Rejected because progressive
migrations would make the linter and baseline increasingly difficult to review and would create
merge-conflict hotspots between unrelated architecture rules.
- **Place linter implementation and policy under `.github/`.** Rejected because the checks are
repository tooling used locally, in pre-commit, and in CI; GitHub Actions is only one caller and
should not own the code or durable configuration.
+1
View File
@@ -101,6 +101,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 | | 2026-08-01-validate-mcp-tool-arguments | [Validate MCP Tool Arguments at the Shared Server Boundary](2026-08-01-validate-mcp-tool-arguments.md) | accepted | backend, protocol | 2026-08-01 |
| 2026-08-01-separate-task-summary-session-stream-traffic | [Separate Task Summary and Session Stream Traffic](2026-08-01-separate-task-summary-session-stream-traffic.md) | accepted | backend, frontend, protocol | 2026-08-01 | | 2026-08-01-separate-task-summary-session-stream-traffic | [Separate Task Summary and Session Stream Traffic](2026-08-01-separate-task-summary-session-stream-traffic.md) | accepted | backend, frontend, protocol | 2026-08-01 |
| 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 | | 2026-08-01-global-run-scheduler-ownership | [Separate Global Run Dispatch from Office Maintenance](2026-08-01-global-run-scheduler-ownership.md) | accepted | backend, workflow | 2026-08-01 |
| 2026-08-01-architecture-lint-budgets | [Architecture Lint Budgets and Compatibility Expiry](2026-08-01-architecture-lint-budgets.md) | accepted (amended 2026-08-02) | infra | 2026-08-01 |
| 2026-08-01-release-toggle-gating-contract | [Release Toggles Are Install-Wide Fail-Closed Gates](2026-08-01-release-toggle-gating-contract.md) | proposed | backend, frontend, workflow | 2026-08-01 | | 2026-08-01-release-toggle-gating-contract | [Release Toggles Are Install-Wide Fail-Closed Gates](2026-08-01-release-toggle-gating-contract.md) | proposed | backend, frontend, workflow | 2026-08-01 |
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 | | 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 | | 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
+1
View File
@@ -0,0 +1 @@
"""Architecture linter package."""
+128
View File
@@ -0,0 +1,128 @@
"""Per-rule baseline loading, stale-entry checks, and shrink-only comparison."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from .model import Diagnostic, Finding, Rule
from .repository import ConfigurationError, GitDiscoveryError, run_git
def canonical_identity(identity: dict[str, object]) -> str:
return json.dumps(identity, sort_keys=True, separators=(",", ":"))
def load_json_file(path: Path) -> Any:
try:
with path.open(encoding="utf-8") as handle:
return json.load(handle)
except FileNotFoundError as exc:
raise ConfigurationError(f"required configuration file does not exist: {path}") from exc
except (OSError, json.JSONDecodeError) as exc:
raise ConfigurationError(f"cannot load {path}: {exc}") from exc
def validate_rule_baseline(data: Any, rule: Rule, label: str) -> list[dict[str, object]]:
if (
not isinstance(data, dict)
or data.get("version") != 1
or data.get("rule") != rule.id
or not isinstance(data.get("entries"), list)
):
raise ConfigurationError(
f"{label} must contain version 1, rule {rule.id}, and an entries array"
)
entries = data["entries"]
if not all(isinstance(entry, dict) for entry in entries):
raise ConfigurationError(f"{label} entries must be objects")
keys = [canonical_identity(entry) for entry in entries]
if len(keys) != len(set(keys)):
raise ConfigurationError(f"{label} contains duplicate entries")
return entries
def load_baselines(root: Path, rules: tuple[Rule, ...]) -> dict[str, list[dict[str, object]]]:
return {
rule.id: validate_rule_baseline(load_json_file(root / rule.baseline_path), rule, str(rule.baseline_path))
for rule in rules
}
def baseline_diagnostics(
findings: list[Finding],
baselines: dict[str, list[dict[str, object]]],
rules: tuple[Rule, ...],
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
actual_by_rule: dict[str, dict[str, Finding]] = {rule.id: {} for rule in rules}
for finding in findings:
actual_by_rule[finding.rule][canonical_identity(finding.identity_dict())] = finding
for rule in rules:
expected = {canonical_identity(entry): entry for entry in baselines[rule.id]}
actual = actual_by_rule[rule.id]
for key in sorted(actual.keys() - expected.keys()):
finding = actual[key]
diagnostics.append(Diagnostic(finding.rule, finding.path, finding.line, finding.message))
for key in sorted(expected.keys() - actual.keys()):
diagnostics.append(
Diagnostic(
rule.id,
rule.baseline_path.as_posix(),
1,
(
"stale baseline entry no longer matches tracked source; remove it from "
f"{rule.baseline_path}: {key}"
),
)
)
return diagnostics
def load_baselines_from_ref(
root: Path,
ref: str,
rules: tuple[Rule, ...],
allow_missing: bool,
) -> dict[str, list[dict[str, object]] | None]:
run_git(root, "cat-file", "-e", f"{ref}^{{commit}}")
baselines: dict[str, list[dict[str, object]] | None] = {}
for rule in rules:
try:
raw = run_git(root, "show", f"{ref}:{rule.baseline_path.as_posix()}")
except GitDiscoveryError as exc:
if allow_missing:
baselines[rule.id] = None
continue
raise ConfigurationError(f"baseline {rule.baseline_path} does not exist at {ref}") from exc
try:
data = json.loads(raw.decode("utf-8"))
except (UnicodeError, json.JSONDecodeError) as exc:
raise ConfigurationError(f"baseline {rule.baseline_path} at {ref} is invalid JSON: {exc}") from exc
baselines[rule.id] = validate_rule_baseline(data, rule, f"{ref}:{rule.baseline_path}")
return baselines
def baseline_growth_diagnostics(
current: dict[str, list[dict[str, object]]],
base: dict[str, list[dict[str, object]] | None],
rules: tuple[Rule, ...],
) -> list[Diagnostic]:
diagnostics: list[Diagnostic] = []
for rule in rules:
if base[rule.id] is None:
continue
current_entries = {canonical_identity(entry) for entry in current[rule.id]}
base_entries = {canonical_identity(entry) for entry in base[rule.id] or []}
for added in sorted(current_entries - base_entries):
diagnostics.append(
Diagnostic(
rule.id,
rule.baseline_path.as_posix(),
1,
f"baseline may only shrink; newly allowlisted entry is not permitted: {added}",
)
)
return diagnostics
+94
View File
@@ -0,0 +1,94 @@
"""CLI orchestration for registered architecture rules."""
from __future__ import annotations
import argparse
import datetime as dt
import sys
from pathlib import Path
from .baseline import (
baseline_diagnostics,
baseline_growth_diagnostics,
load_baselines,
load_baselines_from_ref,
)
from .compatibility import validate_ledger
from .model import Finding
from .output import print_diagnostics
from .repository import (
ConfigurationError,
GitDiscoveryError,
discover_root,
read_text,
tracked_files,
)
from .rules import RULES
LEDGER_PATH = Path("config/architecture-lint/compatibility-ledger.json")
def scan_architecture(root: Path, paths: list[str]) -> list[Finding]:
findings: list[Finding] = []
for path in paths:
matching = tuple(rule for rule in RULES if rule.applies_to(path))
if not matching:
continue
source = read_text(root, path)
for rule in matching:
findings.extend(rule.scan(path, source))
return sorted(findings, key=lambda item: (item.path, item.line, item.rule, item.identity))
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--all", action="store_true", help="scan all tracked source files")
parser.add_argument("--ledger", type=Path, default=LEDGER_PATH)
parser.add_argument(
"--baseline-base-ref",
help="git commit whose per-rule baselines are the growth ceiling",
)
parser.add_argument(
"--allow-missing-base-baseline",
action="store_true",
help="allow initial rollout of a rule whose baseline is absent from the base commit",
)
args = parser.parse_args(argv)
if not args.all:
parser.error("--all is required")
return args
def resolve_config_path(root: Path, path: Path) -> Path:
return path if path.is_absolute() else root / path
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv or sys.argv[1:])
try:
root = discover_root(Path.cwd())
paths = tracked_files(root)
baselines = load_baselines(root, RULES)
findings = scan_architecture(root, paths)
diagnostics = baseline_diagnostics(findings, baselines, RULES)
ledger_path = resolve_config_path(root, args.ledger)
diagnostics.extend(validate_ledger(root, ledger_path, set(paths), dt.date.today()))
if args.baseline_base_ref:
base = load_baselines_from_ref(
root,
args.baseline_base_ref,
RULES,
args.allow_missing_base_baseline,
)
diagnostics.extend(baseline_growth_diagnostics(baselines, base, RULES))
except GitDiscoveryError as exc:
print(f"architecture-lint: git discovery failed: {exc}", file=sys.stderr)
return 3
except ConfigurationError as exc:
print(f"architecture-lint: configuration error: {exc}", file=sys.stderr)
return 2
print_diagnostics(diagnostics)
return 1 if diagnostics else 0
+146
View File
@@ -0,0 +1,146 @@
"""Compatibility-ledger schema, expiry, and source-locator validation."""
from __future__ import annotations
import datetime as dt
import re
from pathlib import Path
from .baseline import load_json_file
from .model import Diagnostic
from .repository import read_text
RULE_ID = "COMPAT-LEDGER"
_SEMVER_NUMBER = r"(?:0|[1-9][0-9]*)"
_SEMVER_PRERELEASE_IDENTIFIER = (
r"(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
)
SEMVER = re.compile(
rf"^{_SEMVER_NUMBER}\.{_SEMVER_NUMBER}\.{_SEMVER_NUMBER}"
rf"(?:-{_SEMVER_PRERELEASE_IDENTIFIER}"
rf"(?:\.{_SEMVER_PRERELEASE_IDENTIFIER})*)?"
rf"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
STABLE_ID = re.compile(r"^[a-z0-9]+(?:[a-z0-9._-]*[a-z0-9])?$")
def diagnostic(path: str, message: str) -> Diagnostic:
return Diagnostic(RULE_ID, path, 1, message)
def nonempty_string(entry: dict[str, object], field: str) -> bool:
return isinstance(entry.get(field), str) and bool(str(entry[field]).strip())
def parse_date(value: object) -> dt.date | None:
if not isinstance(value, str):
return None
try:
return dt.date.fromisoformat(value)
except ValueError:
return None
def validate_ledger(root: Path, ledger_path: Path, tracked: set[str], today: dt.date) -> list[Diagnostic]:
data = load_json_file(ledger_path)
try:
label = ledger_path.relative_to(root).as_posix()
except ValueError:
label = str(ledger_path)
if not isinstance(data, dict) or data.get("version") != 1 or not isinstance(data.get("entries"), list):
return [diagnostic(label, "ledger must contain version 1 and an entries array")]
diagnostics: list[Diagnostic] = []
seen: set[str] = set()
for index, raw_entry in enumerate(data["entries"]):
prefix = f"entry {index + 1}"
if not isinstance(raw_entry, dict):
diagnostics.append(diagnostic(label, f"{prefix} must be an object"))
continue
entry: dict[str, object] = raw_entry
entry_id = entry.get("id")
if not isinstance(entry_id, str) or not STABLE_ID.fullmatch(entry_id):
diagnostics.append(diagnostic(label, f"{prefix} has an invalid stable id"))
entry_id = prefix
elif entry_id in seen:
diagnostics.append(diagnostic(label, f"duplicate compatibility id: {entry_id}"))
else:
seen.add(entry_id)
for field in ("reason", "owner", "removal_condition"):
if not nonempty_string(entry, field):
diagnostics.append(diagnostic(label, f"{entry_id}: required field {field} is empty or missing"))
diagnostics.extend(validate_introduction(label, str(entry_id), entry, today))
diagnostics.extend(validate_removal_target(label, str(entry_id), entry, today))
diagnostics.extend(validate_locator(root, label, str(entry_id), entry, tracked))
return diagnostics
def validate_introduction(
label: str, entry_id: str, entry: dict[str, object], today: dt.date
) -> list[Diagnostic]:
date_present = "introduced_on" in entry
version_present = "introduced_version" in entry
if date_present == version_present:
return [diagnostic(label, f"{entry_id}: set exactly one of introduced_on or introduced_version")]
if date_present:
introduced = parse_date(entry.get("introduced_on"))
if introduced is None:
return [diagnostic(label, f"{entry_id}: invalid introduced_on date")]
if introduced > today:
return [diagnostic(label, f"{entry_id}: introduced_on is in the future")]
elif not isinstance(entry.get("introduced_version"), str) or not SEMVER.fullmatch(
str(entry["introduced_version"])
):
return [diagnostic(label, f"{entry_id}: invalid introduced_version")]
return []
def validate_removal_target(
label: str, entry_id: str, entry: dict[str, object], today: dt.date
) -> list[Diagnostic]:
date_present = "target_removal_date" in entry
version_present = "target_removal_version" in entry
if date_present == version_present:
return [
diagnostic(
label,
f"{entry_id}: set exactly one of target_removal_date or target_removal_version",
)
]
if date_present:
target = parse_date(entry.get("target_removal_date"))
if target is None:
return [diagnostic(label, f"{entry_id}: invalid target_removal_date")]
if target < today:
return [diagnostic(label, f"{entry_id}: compatibility exception expired on {target.isoformat()}")]
elif not isinstance(entry.get("target_removal_version"), str) or not SEMVER.fullmatch(
str(entry["target_removal_version"])
):
return [diagnostic(label, f"{entry_id}: invalid target_removal_version")]
return []
def validate_locator(
root: Path,
label: str,
entry_id: str,
entry: dict[str, object],
tracked: set[str],
) -> list[Diagnostic]:
locator = entry.get("locator")
if not isinstance(locator, dict):
return [diagnostic(label, f"{entry_id}: locator must be an object")]
locator_path = locator.get("path")
marker = locator.get("marker")
if not isinstance(locator_path, str) or not locator_path:
return [diagnostic(label, f"{entry_id}: locator.path is required")]
if not isinstance(marker, str) or not marker:
return [diagnostic(label, f"{entry_id}: locator.marker is required")]
if locator_path not in tracked:
return [diagnostic(label, f"{entry_id}: referenced path is not tracked: {locator_path}")]
if marker not in read_text(root, locator_path):
return [diagnostic(label, f"{entry_id}: locator marker no longer exists in {locator_path}")]
return []
+47
View File
@@ -0,0 +1,47 @@
"""Shared immutable types for architecture rules and diagnostics."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
@dataclass(frozen=True)
class Finding:
rule: str
path: str
line: int
identity: tuple[tuple[str, object], ...]
message: str
@classmethod
def create(
cls,
rule: str,
path: str,
line: int,
identity: dict[str, object],
message: str,
) -> "Finding":
return cls(rule, path, line, tuple(sorted(identity.items())), message)
def identity_dict(self) -> dict[str, object]:
return dict(self.identity)
@dataclass(frozen=True)
class Diagnostic:
rule: str
path: str
line: int
message: str
@dataclass(frozen=True)
class Rule:
id: str
slug: str
baseline_path: Path
applies_to: Callable[[str], bool]
scan: Callable[[str, str], list[Finding]]
+24
View File
@@ -0,0 +1,24 @@
"""Human-readable and GitHub Actions diagnostic output."""
import os
from .model import Diagnostic
def annotation_escape(value: str, *, property_value: bool = False) -> str:
escaped = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
if property_value:
escaped = escaped.replace(":", "%3A").replace(",", "%2C")
return escaped
def print_diagnostics(diagnostics: list[Diagnostic]) -> None:
for item in sorted(diagnostics, key=lambda value: (value.path, value.line, value.rule, value.message)):
message = f"[{item.rule}] {item.message}"
if os.environ.get("GITHUB_ACTIONS") == "true":
print(
f"::error file={annotation_escape(item.path, property_value=True)},"
f"line={item.line}::{annotation_escape(message)}"
)
else:
print(f"{item.path}:{item.line}: {message}")
+49
View File
@@ -0,0 +1,49 @@
"""Tracked-file and git-ref access for deterministic scans."""
from __future__ import annotations
import subprocess
from pathlib import Path
class GitDiscoveryError(RuntimeError):
"""Raised when tracked repository state cannot be discovered."""
class ConfigurationError(RuntimeError):
"""Raised when linter configuration or tracked source cannot be read."""
def run_git(root: Path, *args: str) -> bytes:
try:
result = subprocess.run(
["git", "-C", str(root), *args],
capture_output=True,
check=False,
)
except OSError as exc:
raise GitDiscoveryError(f"could not execute git: {exc}") from exc
if result.returncode != 0:
detail = result.stderr.decode("utf-8", errors="replace").strip()
raise GitDiscoveryError(detail or f"git {' '.join(args)} failed")
return result.stdout
def discover_root(cwd: Path) -> Path:
try:
output = run_git(cwd, "rev-parse", "--show-toplevel")
except GitDiscoveryError as exc:
raise GitDiscoveryError(f"unable to discover git repository: {exc}") from exc
return Path(output.decode("utf-8").strip()).resolve()
def tracked_files(root: Path) -> list[str]:
output = run_git(root, "ls-files", "-z", "--")
return sorted(path for path in output.decode("utf-8").split("\0") if path)
def read_text(root: Path, relative: str) -> str:
try:
return (root / relative).read_text(encoding="utf-8")
except (OSError, UnicodeError) as exc:
raise ConfigurationError(f"cannot read tracked file {relative}: {exc}") from exc
@@ -0,0 +1,8 @@
"""Explicit registry of architecture rules."""
from .frontend_root_state_cast import RULE as FRONTEND_ROOT_STATE_CAST
from .runtime_import import RULE as RUNTIME_IMPORT
from .task_office_import import RULE as TASK_OFFICE_IMPORT
RULES = (RUNTIME_IMPORT, TASK_OFFICE_IMPORT, FRONTEND_ROOT_STATE_CAST)
@@ -0,0 +1,150 @@
"""Prevent new unsafe casts in frontend root-state composition."""
from pathlib import Path
from architecture_lint.model import Finding, Rule
RULE_ID = "ARCH-FRONTEND-ROOT-STATE-CAST"
STORE_PATH = "apps/web/lib/state/store.ts"
def applies_to(path: str) -> bool:
return path == STORE_PATH
def normalize_marker(line: str) -> str:
return " ".join(line.strip().split())
def _line_number(source: str, index: int) -> int:
return source.count("\n", 0, index) + 1
def _skip_quoted(source: str, index: int, quote: str) -> int:
index += 1
while index < len(source):
if source[index] == "\\":
index += 2
elif source[index] == quote:
return index + 1
else:
index += 1
return index
def _skip_comment(source: str, index: int) -> int:
if source.startswith("//", index):
newline = source.find("\n", index + 2)
return len(source) if newline < 0 else newline
end = source.find("*/", index + 2)
return len(source) if end < 0 else end + 2
def _scan_template(source: str, index: int, tokens: list[tuple[str, int]]) -> int:
"""Skip template text while recursively tokenizing `${...}` expressions."""
index += 1
while index < len(source):
char = source[index]
if char == "\\":
index += 2
elif char == "`":
return index + 1
elif source.startswith("${", index):
index = _scan_code(source, index + 2, tokens, stop_at_brace=True)
else:
index += 1
return index
def _scan_code(
source: str,
index: int,
tokens: list[tuple[str, int]],
*,
stop_at_brace: bool = False,
) -> int:
brace_depth = 0
while index < len(source):
char = source[index]
if char.isspace():
index += 1
elif source.startswith("//", index) or source.startswith("/*", index):
index = _skip_comment(source, index)
elif char in {'"', "'"}:
index = _skip_quoted(source, index, char)
elif char == "`":
index = _scan_template(source, index, tokens)
elif char == "{":
brace_depth += 1
index += 1
elif char == "}":
if stop_at_brace and brace_depth == 0:
return index + 1
brace_depth = max(0, brace_depth - 1)
index += 1
elif char.isalpha() or char in "_$":
start = index
index += 1
while index < len(source) and (source[index].isalnum() or source[index] in "_$"):
index += 1
tokens.append((source[start:index], _line_number(source, start)))
else:
index += 1
return index
def unsafe_casts(source: str) -> list[tuple[int, str]]:
tokens: list[tuple[str, int]] = []
_scan_code(source, 0, tokens)
findings: list[tuple[int, str]] = []
for index, (token, line) in enumerate(tokens):
if token == "as" and index + 1 < len(tokens) and tokens[index + 1][0] == "any":
findings.append((line, "as any"))
elif (
token == "as"
and index + 2 < len(tokens)
and tokens[index + 1][0] == "unknown"
and tokens[index + 2][0] == "as"
):
findings.append((line, "as unknown as"))
return findings
def scan(path: str, source: str) -> list[Finding]:
findings: list[Finding] = []
source_lines = source.splitlines()
occurrences: dict[tuple[str, str], int] = {}
for line_number, escape in unsafe_casts(source):
marker = normalize_marker(source_lines[line_number - 1])
occurrence_key = (marker, escape)
occurrences[occurrence_key] = occurrences.get(occurrence_key, 0) + 1
findings.append(
Finding.create(
RULE_ID,
path,
line_number,
{
"path": path,
"marker": marker,
"escape": escape,
"occurrence": occurrences[occurrence_key],
},
(
f"unsafe root-state composition escape `{escape}`; derive typed domain "
"state, actions, and defaults instead"
),
)
)
return findings
RULE = Rule(
id=RULE_ID,
slug="frontend_root_state_cast",
baseline_path=Path("config/architecture-lint/frontend_root_state_cast.json"),
applies_to=applies_to,
scan=scan,
)
@@ -0,0 +1,35 @@
"""Minimal Go import parsing shared by import-direction rules."""
import re
IMPORT_LINE = re.compile(r'^\s*(?:(?:[A-Za-z_][\w]*|\.|_)\s+)?["`]([^"`]+)["`]')
SINGLE_IMPORT = re.compile(
r'^\s*import\s+(?:(?:[A-Za-z_][\w]*|\.|_)\s+)?["`]([^"`]+)["`]'
)
def import_lines(source: str) -> list[tuple[int, str]]:
imports: list[tuple[int, str]] = []
in_block = False
for line_number, line in enumerate(source.splitlines(), start=1):
stripped = line.strip()
if not in_block:
if re.match(r"^import\s*\(", stripped):
in_block = True
continue
match = SINGLE_IMPORT.match(line)
if match:
imports.append((line_number, match.group(1)))
continue
if stripped.startswith(")"):
in_block = False
continue
match = IMPORT_LINE.match(line)
if match:
imports.append((line_number, match.group(1)))
return imports
def is_import_at_or_below(import_path: str, root: str) -> bool:
return import_path == root or import_path.startswith(root + "/")
@@ -0,0 +1,49 @@
"""Prevent new callers from bypassing the public agent-runtime seam."""
from pathlib import Path
from architecture_lint.model import Finding, Rule
from .go_imports import import_lines, is_import_at_or_below
RULE_ID = "ARCH-RUNTIME-IMPORT"
IMPORT_ROOTS = (
"github.com/kandev/kandev/internal/agent/runtime/lifecycle",
"github.com/kandev/kandev/internal/agent/runtime/agentctl",
)
APPROVED_PREFIX = "apps/backend/internal/agent/runtime/"
def applies_to(path: str) -> bool:
return path.startswith("apps/backend/") and path.endswith(".go") and not path.endswith("_test.go")
def scan(path: str, source: str) -> list[Finding]:
if path.startswith(APPROVED_PREFIX):
return []
findings: list[Finding] = []
for line, import_path in import_lines(source):
if any(is_import_at_or_below(import_path, root) for root in IMPORT_ROOTS):
findings.append(
Finding.create(
RULE_ID,
path,
line,
{"path": path, "import": import_path},
(
f"direct import of {import_path}; higher-level callers must use "
"internal/agent/runtime or an approved low-level adapter"
),
)
)
return findings
RULE = Rule(
id=RULE_ID,
slug="runtime_import",
baseline_path=Path("config/architecture-lint/runtime_import.json"),
applies_to=applies_to,
scan=scan,
)
@@ -0,0 +1,47 @@
"""Prevent shared task code from depending on Office implementations."""
from pathlib import Path
from architecture_lint.model import Finding, Rule
from .go_imports import import_lines, is_import_at_or_below
RULE_ID = "ARCH-TASK-OFFICE-IMPORT"
OFFICE_IMPORT_ROOT = "github.com/kandev/kandev/internal/office"
def applies_to(path: str) -> bool:
return (
path.startswith("apps/backend/internal/task/")
and path.endswith(".go")
and not path.endswith("_test.go")
)
def scan(path: str, source: str) -> list[Finding]:
findings: list[Finding] = []
for line, import_path in import_lines(source):
if is_import_at_or_below(import_path, OFFICE_IMPORT_ROOT):
findings.append(
Finding.create(
RULE_ID,
path,
line,
{"path": path, "import": import_path},
(
f"task imports {import_path}; the shared task model owns task concepts "
"and Office consumes or adapts that model, never the reverse"
),
)
)
return findings
RULE = Rule(
id=RULE_ID,
slug="task_office_import",
baseline_path=Path("config/architecture-lint/task_office_import.json"),
applies_to=applies_to,
scan=scan,
)
+118
View File
@@ -0,0 +1,118 @@
"""Temporary-repository support for architecture-lint tests."""
import json
import subprocess
import sys
import tempfile
import textwrap
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
LINTER = REPO_ROOT / "scripts" / "lint-architecture.py"
sys.path.insert(0, str(LINTER.parent))
from architecture_lint.rules import RULES # noqa: E402
RUNTIME_RULE = "ARCH-RUNTIME-IMPORT"
TASK_OFFICE_RULE = "ARCH-TASK-OFFICE-IMPORT"
ROOT_STATE_RULE = "ARCH-FRONTEND-ROOT-STATE-CAST"
RUNTIME_IMPORT = "github.com/kandev/kandev/internal/agent/runtime/lifecycle"
OFFICE_IMPORT = "github.com/kandev/kandev/internal/office/models"
RULE_FILES = {rule.id: rule.baseline_path.name for rule in RULES}
class ArchitectureFixture(unittest.TestCase):
def setUp(self) -> None:
self.tempdir = tempfile.TemporaryDirectory()
self.repo = Path(self.tempdir.name)
self.git("init", "-q")
self.git("config", "user.email", "test@example.com")
self.git("config", "user.name", "Architecture Test")
self.write_baseline()
self.write_ledger([])
def tearDown(self) -> None:
self.tempdir.cleanup()
def git(self, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["git", *args],
cwd=self.repo,
text=True,
capture_output=True,
check=True,
)
def write(self, relative: str, content: str) -> None:
path = self.repo / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(content).lstrip(), encoding="utf-8")
def write_json(self, relative: str, content: object) -> None:
self.write(relative, json.dumps(content, indent=2) + "\n")
def write_baseline(
self,
*,
runtime: list[dict[str, object]] | None = None,
task_office: list[dict[str, object]] | None = None,
root_state: list[dict[str, object]] | None = None,
) -> None:
entries = {rule.id: [] for rule in RULES}
entries.update(
{
RUNTIME_RULE: runtime or [],
TASK_OFFICE_RULE: task_office or [],
ROOT_STATE_RULE: root_state or [],
}
)
for rule in RULES:
self.write_json(
rule.baseline_path.as_posix(),
{"version": 1, "rule": rule.id, "entries": entries[rule.id]},
)
def write_ledger(self, entries: list[dict[str, object]]) -> None:
self.write_json(
"config/architecture-lint/compatibility-ledger.json",
{"version": 1, "entries": entries},
)
def track_all(self) -> None:
self.git("add", ".")
def run_cli(self, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(LINTER), *args],
cwd=cwd or self.repo,
text=True,
capture_output=True,
check=False,
)
def assert_diagnostic_location(self, result: subprocess.CompletedProcess[str], path: str, line: int) -> None:
self.assertIn(path, result.stdout)
self.assertTrue(
f"{path}:{line}" in result.stdout or f"line={line}" in result.stdout,
result.stdout,
)
@staticmethod
def runtime_entry(path: str) -> dict[str, str]:
return {"path": path, "import": RUNTIME_IMPORT}
def valid_ledger_entry(self, *, marker: str = "// compat: old-name") -> dict[str, object]:
return {
"id": "old-name-alias",
"locator": {
"path": "apps/backend/internal/example/compat.go",
"marker": marker,
},
"reason": "Older persisted callers still use the old name.",
"owner": "backend maintainers",
"introduced_on": "2026-01-15",
"removal_condition": "Remove after all persisted callers have migrated.",
"target_removal_date": "2099-12-31",
}
@@ -0,0 +1,61 @@
"""Cross-rule baseline behavior tests."""
from support import ArchitectureFixture, ROOT_STATE_RULE, RULE_FILES, RUNTIME_IMPORT, RUNTIME_RULE
class BaselineTest(ArchitectureFixture):
def test_removed_violation_requires_its_rule_baseline_to_shrink(self) -> None:
path = "apps/backend/internal/example/removed.go"
self.write(path, "package example\n")
self.write_baseline(runtime=[self.runtime_entry(path)])
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assertIn(RUNTIME_RULE, result.stdout)
self.assertIn("stale baseline entry", result.stdout)
self.assertIn("runtime_import.json", result.stdout)
def test_comparison_rejects_growth_but_allows_shrinkage(self) -> None:
old_path = "apps/backend/internal/example/old.go"
new_path = "apps/backend/internal/example/new.go"
self.write(old_path, f'package example\nimport "{RUNTIME_IMPORT}"\n')
self.write_baseline(runtime=[self.runtime_entry(old_path)])
self.track_all()
self.git("commit", "-qm", "base")
base = self.git("rev-parse", "HEAD").stdout.strip()
self.write(new_path, f'package example\nimport "{RUNTIME_IMPORT}"\n')
self.write_baseline(runtime=[self.runtime_entry(old_path), self.runtime_entry(new_path)])
self.track_all()
growth = self.run_cli("--all", "--baseline-base-ref", base)
self.assertEqual(growth.returncode, 1)
self.assertIn("baseline may only shrink", growth.stdout)
self.assertIn(new_path, growth.stdout)
(self.repo / new_path).unlink()
(self.repo / old_path).write_text("package example\n", encoding="utf-8")
self.write_baseline(runtime=[])
self.track_all()
shrink = self.run_cli("--all", "--baseline-base-ref", base)
self.assertEqual(shrink.returncode, 0, shrink.stdout + shrink.stderr)
def test_new_rule_can_bootstrap_its_own_missing_base_baseline(self) -> None:
relative = f"config/architecture-lint/{RULE_FILES[ROOT_STATE_RULE]}"
(self.repo / relative).unlink()
self.track_all()
self.git("commit", "-qm", "base without future rule")
base = self.git("rev-parse", "HEAD").stdout.strip()
self.write_baseline()
self.track_all()
result = self.run_cli(
"--all",
"--baseline-base-ref",
base,
"--allow-missing-base-baseline",
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@@ -0,0 +1,82 @@
"""CLI, ordering, annotation, and modular-layout tests."""
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from support import ArchitectureFixture, LINTER, REPO_ROOT, RULES, RUNTIME_IMPORT
class CliTest(ArchitectureFixture):
def test_findings_are_deterministic_and_untracked_files_are_ignored(self) -> None:
z_path = "apps/backend/internal/zeta/service.go"
a_path = "apps/backend/internal/alpha/service.go"
self.write(z_path, f'package zeta\nimport "{RUNTIME_IMPORT}"\n')
self.write(a_path, f'package alpha\nimport "{RUNTIME_IMPORT}"\n')
self.track_all()
self.write(
"apps/backend/internal/aaa_untracked/service.go",
f'package untracked\nimport "{RUNTIME_IMPORT}"\n',
)
first = self.run_cli("--all")
second = self.run_cli("--all")
self.assertEqual(first.returncode, 1)
self.assertEqual(first.stdout, second.stdout)
self.assertLess(first.stdout.index(a_path), first.stdout.index(z_path))
self.assertNotIn("aaa_untracked", first.stdout)
def test_cli_misuse_and_git_discovery_have_distinct_exit_codes(self) -> None:
self.track_all()
misuse = self.run_cli("--not-an-option")
with tempfile.TemporaryDirectory() as tmp:
git_failure = self.run_cli("--all", cwd=Path(tmp))
self.assertEqual(misuse.returncode, 2)
self.assertEqual(git_failure.returncode, 3)
self.assertIn("git repository", git_failure.stderr)
def test_github_actions_output_uses_error_annotations(self) -> None:
path = "apps/backend/internal/example/new_service.go"
self.write(path, f'package example\nimport "{RUNTIME_IMPORT}"\n')
self.track_all()
result = subprocess.run(
[sys.executable, str(LINTER), "--all"],
cwd=self.repo,
env={**os.environ, "GITHUB_ACTIONS": "true"},
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 1)
self.assertIn(f"::error file={path},line=2::", result.stdout)
def test_each_rule_has_dedicated_implementation_test_and_baseline_files(self) -> None:
for rule in RULES:
with self.subTest(rule=rule.id):
self.assertTrue(
(REPO_ROOT / "scripts/architecture_lint/rules" / f"{rule.slug}.py").is_file()
)
self.assertTrue(
(REPO_ROOT / "scripts/architecture_lint_tests" / f"test_{rule.slug}.py").is_file()
)
self.assertEqual(rule.baseline_path.parent, Path("config/architecture-lint"))
self.assertTrue((REPO_ROOT / rule.baseline_path).is_file())
self.assertTrue((REPO_ROOT / "scripts/lint-architecture.py").is_file())
self.assertTrue((REPO_ROOT / ".github/workflows/architecture-lint.yml").is_file())
self.assertTrue((REPO_ROOT / "docs/architecture-lint.md").is_file())
def test_workflow_fetches_full_history_for_push_baseline_comparisons(self) -> None:
workflow = (REPO_ROOT / ".github/workflows/architecture-lint.yml").read_text(encoding="utf-8")
self.assertIn("fetch-depth: 0", workflow)
def test_push_runs_are_not_cancelled_before_baseline_comparison(self) -> None:
workflow = (REPO_ROOT / ".github/workflows/architecture-lint.yml").read_text(encoding="utf-8")
self.assertIn("github.event_name == 'pull_request'", workflow)
self.assertIn("cancel-in-progress: ${{ github.event_name == 'pull_request' }}", workflow)
self.assertIn("github.sha", workflow)
@@ -0,0 +1,109 @@
"""Compatibility-ledger validation tests."""
import json
import tempfile
from pathlib import Path
from support import ArchitectureFixture
class CompatibilityLedgerTest(ArchitectureFixture):
def test_malformed_and_duplicate_entries_fail(self) -> None:
entry = self.valid_ledger_entry()
entry.pop("owner")
self.write_ledger([entry])
self.track_all()
malformed = self.run_cli("--all")
self.assertEqual(malformed.returncode, 1)
self.assertIn("owner", malformed.stdout)
entry = self.valid_ledger_entry()
self.write_ledger([entry, entry])
duplicate = self.run_cli("--all")
self.assertEqual(duplicate.returncode, 1)
self.assertIn("duplicate compatibility id", duplicate.stdout)
def test_invalid_and_expired_dates_fail(self) -> None:
entry = self.valid_ledger_entry()
entry["introduced_on"] = "2026-99-99"
self.write_ledger([entry])
self.track_all()
invalid = self.run_cli("--all")
self.assertEqual(invalid.returncode, 1)
self.assertIn("invalid introduced_on date", invalid.stdout)
entry = self.valid_ledger_entry()
entry["target_removal_date"] = "2000-01-01"
self.write_ledger([entry])
expired = self.run_cli("--all")
self.assertEqual(expired.returncode, 1)
self.assertIn("expired on 2000-01-01", expired.stdout)
def test_missing_path_or_marker_fails(self) -> None:
self.write_ledger([self.valid_ledger_entry()])
self.track_all()
missing_path = self.run_cli("--all")
self.assertEqual(missing_path.returncode, 1)
self.assertIn("referenced path is not tracked", missing_path.stdout)
self.write("apps/backend/internal/example/compat.go", "package example\n// different\n")
self.track_all()
missing_marker = self.run_cli("--all")
self.assertEqual(missing_marker.returncode, 1)
self.assertIn("marker no longer exists", missing_marker.stdout)
def test_valid_registered_entry_passes(self) -> None:
marker = "// compat: old-name"
self.write("apps/backend/internal/example/compat.go", f"package example\n{marker}\n")
self.write_ledger([self.valid_ledger_entry(marker=marker)])
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_semver_accepts_prerelease_and_build_metadata(self) -> None:
marker = "// compat: semver"
self.write("apps/backend/internal/example/compat.go", f"package example\n{marker}\n")
entry = self.valid_ledger_entry(marker=marker)
entry.pop("introduced_on")
entry["introduced_version"] = "1.2.3-alpha+build.7"
entry.pop("target_removal_date")
entry["target_removal_version"] = "2.0.0"
self.write_ledger([entry])
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_semver_rejects_leading_zero_and_empty_identifiers(self) -> None:
marker = "// compat: semver"
self.write("apps/backend/internal/example/compat.go", f"package example\n{marker}\n")
for invalid in ("01.2.3", "1.2.3-..", "1.2.3-alpha..1"):
with self.subTest(invalid=invalid):
entry = self.valid_ledger_entry(marker=marker)
entry.pop("introduced_on")
entry["introduced_version"] = invalid
self.write_ledger([entry])
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assertIn("invalid introduced_version", result.stdout)
def test_ledger_outside_repository_uses_absolute_label(self) -> None:
marker = "// compat: external-ledger"
self.write("apps/backend/internal/example/compat.go", f"package example\n{marker}\n")
self.track_all()
with tempfile.TemporaryDirectory() as tempdir:
ledger_path = Path(tempdir) / "compatibility-ledger.json"
ledger_path.write_text(
json.dumps({"version": 1, "entries": [self.valid_ledger_entry(marker=marker)]}) + "\n",
encoding="utf-8",
)
result = self.run_cli("--all", "--ledger", str(ledger_path))
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
@@ -0,0 +1,76 @@
"""Tests for ARCH-FRONTEND-ROOT-STATE-CAST."""
from support import ArchitectureFixture, ROOT_STATE_RULE
class FrontendRootStateCastTest(ArchitectureFixture):
def test_new_unsafe_cast_reports_typed_replacement(self) -> None:
path = "apps/web/lib/state/store.ts"
self.write(path, "const unsafe = state as unknown as RootState;\n")
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 1)
self.assertIn(ROOT_STATE_RULE, result.stdout)
self.assertIn("typed domain state, actions, and defaults", result.stdout)
def test_multiline_double_cast_reports_first_cast_line(self) -> None:
path = "apps/web/lib/state/store.ts"
self.write(path, "const unsafe = (state as unknown)\n as RootState;\n")
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 1)
self.assertIn("as unknown as", result.stdout)
def test_comments_between_cast_tokens_are_detected(self) -> None:
path = "apps/web/lib/state/store.ts"
self.write(
path,
"const unsafe = state as /* explanation */ unknown /* explanation */ as RootState;\n",
)
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 1)
self.assertIn("as unknown as", result.stdout)
def test_template_interpolations_are_scanned_but_template_text_is_ignored(self) -> None:
path = "apps/web/lib/state/store.ts"
self.write(
path,
"const text = `literal state as any ${state as any} ${state as unknown as RootState}`;\n",
)
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout.count(ROOT_STATE_RULE), 2)
def test_duplicate_violation_lines_get_distinct_occurrences(self) -> None:
path = "apps/web/lib/state/store.ts"
marker = "const unsafe = state as any;"
self.write(path, f"{marker}\n{marker}\n")
self.write_baseline(
root_state=[
{
"escape": "as any",
"marker": marker,
"occurrence": 1,
"path": path,
}
]
)
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 2)
@@ -0,0 +1,38 @@
"""Tests for ARCH-RUNTIME-IMPORT."""
from support import ArchitectureFixture, RUNTIME_IMPORT, RUNTIME_RULE
class RuntimeImportTest(ArchitectureFixture):
def assert_new_import_is_reported(self, path: str, source: str, line: int) -> None:
self.write(path, source)
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, line)
self.assertIn(RUNTIME_RULE, result.stdout)
def test_current_grandfathered_violation_passes(self) -> None:
path = "apps/backend/internal/example/service.go"
self.write(path, f'package example\n\nimport "{RUNTIME_IMPORT}"\n')
self.write_baseline(runtime=[self.runtime_entry(path)])
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
def test_new_import_reports_rule_location_and_migration_guidance(self) -> None:
path = "apps/backend/internal/example/new_service.go"
self.write(path, f'package example\n\nimport "{RUNTIME_IMPORT}"\n')
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 3)
self.assertIn(RUNTIME_RULE, result.stdout)
self.assertIn("internal/agent/runtime", result.stdout)
self.assertIn("approved low-level adapter", result.stdout)
@@ -0,0 +1,18 @@
"""Tests for ARCH-TASK-OFFICE-IMPORT."""
from support import ArchitectureFixture, OFFICE_IMPORT, TASK_OFFICE_RULE
class TaskOfficeImportTest(ArchitectureFixture):
def test_new_dependency_reports_ownership_direction(self) -> None:
path = "apps/backend/internal/task/service/new_service.go"
self.write(path, f'package service\n\nimport models "{OFFICE_IMPORT}"\n')
self.track_all()
result = self.run_cli("--all")
self.assertEqual(result.returncode, 1)
self.assert_diagnostic_location(result, path, 3)
self.assertIn(TASK_OFFICE_RULE, result.stdout)
self.assertIn("shared task model owns task concepts", result.stdout)
self.assertIn("Office consumes or adapts", result.stdout)
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env python3
"""Enforce reviewed architecture budgets and compatibility expiry metadata."""
from architecture_lint.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Discover and run the modular architecture-lint regression suite."""
import sys
import unittest
from pathlib import Path
TEST_DIR = Path(__file__).resolve().parent / "architecture_lint_tests"
if __name__ == "__main__":
sys.path.insert(0, str(TEST_DIR))
suite = unittest.defaultTestLoader.discover(str(TEST_DIR), pattern="test_*.py")
result = unittest.TextTestRunner(verbosity=1).run(suite)
raise SystemExit(0 if result.wasSuccessful() else 1)