diff --git a/.github/workflows/architecture-lint.yml b/.github/workflows/architecture-lint.yml new file mode 100644 index 0000000000..615aaf469d --- /dev/null +++ b/.github/workflows/architecture-lint.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1a73d3526..312cb74d51 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,6 +18,12 @@ repos: language: system 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 - repo: local hooks: diff --git a/Makefile b/Makefile index 53a56d3fd5..39c69598fd 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,7 @@ help: @echo " lint Run linters for both components" @echo " lint-backend Run Go linters" @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 " fmt Format all code" @echo " fmt-backend Format Go code" @@ -497,6 +498,7 @@ test-scripts: @bash scripts/opencode-code-review.test.sh @python3 scripts/opencode-code-review.test.py @python3 scripts/lint-harness-files.test.py + @python3 scripts/lint-architecture.test.py @bash scripts/release-desktop.test.sh @node --test apps/desktop/e2e/desktop-launch-smoke.test.mjs @node --test scripts/validate-public-docs.test.mjs @@ -543,7 +545,7 @@ test-e2e-ci: # .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" .PHONY: lint-backend @@ -561,6 +563,11 @@ lint-harness: @printf "$(CYAN)Linting harness files...$(RESET)\n" @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 lint-format: @printf "$(CYAN)Checking formatting...$(RESET)\n" diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 63b67acd55..7ad024bf82 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -68,6 +68,7 @@ apps/backend/ │ ├── integrations/ # Shared shapes for third-party integrations │ │ ├── healthpoll/ # Reusable 90s auth-health Poller (used by jira, linear) │ │ └── secretadapter/ # Upsert-style adapter over secrets.SecretStore +│ ├── i18n/ # Localization for backend-rendered browser/share artifacts │ ├── jira/ # Jira/Atlassian Cloud integration (config, REST client, poller) │ ├── linear/ # Linear integration (config, GraphQL client, poller) │ ├── 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()`. +## 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 Enforced by `apps/backend/.golangci.yml` (errors on new code only): -- Functions: ≤80 lines, ≤50 statements -- 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) +- Functions: ≤80 lines, ≤50 statements · 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) When you hit a limit, extract a helper function. Prefer composition over growing a single function. diff --git a/config/architecture-lint/compatibility-ledger.json b/config/architecture-lint/compatibility-ledger.json new file mode 100644 index 0000000000..4792d495d8 --- /dev/null +++ b/config/architecture-lint/compatibility-ledger.json @@ -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" + } + ] +} diff --git a/config/architecture-lint/frontend_root_state_cast.json b/config/architecture-lint/frontend_root_state_cast.json new file mode 100644 index 0000000000..e95374c821 --- /dev/null +++ b/config/architecture-lint/frontend_root_state_cast.json @@ -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" + } + ] +} diff --git a/config/architecture-lint/runtime_import.json b/config/architecture-lint/runtime_import.json new file mode 100644 index 0000000000..d07baa6c57 --- /dev/null +++ b/config/architecture-lint/runtime_import.json @@ -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" + } + ] +} diff --git a/config/architecture-lint/task_office_import.json b/config/architecture-lint/task_office_import.json new file mode 100644 index 0000000000..e832f5c4cd --- /dev/null +++ b/config/architecture-lint/task_office_import.json @@ -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" + } + ] +} diff --git a/docs/architecture-lint.md b/docs/architecture-lint.md new file mode 100644 index 0000000000..cee76bc48d --- /dev/null +++ b/docs/architecture-lint.md @@ -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/.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. diff --git a/docs/decisions/2026-08-01-architecture-lint-budgets.md b/docs/decisions/2026-08-01-architecture-lint-budgets.md new file mode 100644 index 0000000000..732237f4e3 --- /dev/null +++ b/docs/decisions/2026-08-01-architecture-lint-budgets.md @@ -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. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index b56de13bf6..434906c0ff 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -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-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-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-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 | diff --git a/scripts/architecture_lint/__init__.py b/scripts/architecture_lint/__init__.py new file mode 100644 index 0000000000..98bbfea19c --- /dev/null +++ b/scripts/architecture_lint/__init__.py @@ -0,0 +1 @@ +"""Architecture linter package.""" diff --git a/scripts/architecture_lint/baseline.py b/scripts/architecture_lint/baseline.py new file mode 100644 index 0000000000..9911e069e6 --- /dev/null +++ b/scripts/architecture_lint/baseline.py @@ -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 diff --git a/scripts/architecture_lint/cli.py b/scripts/architecture_lint/cli.py new file mode 100644 index 0000000000..6e991c72b8 --- /dev/null +++ b/scripts/architecture_lint/cli.py @@ -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 diff --git a/scripts/architecture_lint/compatibility.py b/scripts/architecture_lint/compatibility.py new file mode 100644 index 0000000000..47f48827cc --- /dev/null +++ b/scripts/architecture_lint/compatibility.py @@ -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 [] diff --git a/scripts/architecture_lint/model.py b/scripts/architecture_lint/model.py new file mode 100644 index 0000000000..98bb43b785 --- /dev/null +++ b/scripts/architecture_lint/model.py @@ -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]] diff --git a/scripts/architecture_lint/output.py b/scripts/architecture_lint/output.py new file mode 100644 index 0000000000..ecd502cbd4 --- /dev/null +++ b/scripts/architecture_lint/output.py @@ -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}") diff --git a/scripts/architecture_lint/repository.py b/scripts/architecture_lint/repository.py new file mode 100644 index 0000000000..96f7263d08 --- /dev/null +++ b/scripts/architecture_lint/repository.py @@ -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 diff --git a/scripts/architecture_lint/rules/__init__.py b/scripts/architecture_lint/rules/__init__.py new file mode 100644 index 0000000000..136d09aec2 --- /dev/null +++ b/scripts/architecture_lint/rules/__init__.py @@ -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) diff --git a/scripts/architecture_lint/rules/frontend_root_state_cast.py b/scripts/architecture_lint/rules/frontend_root_state_cast.py new file mode 100644 index 0000000000..d62e3913e1 --- /dev/null +++ b/scripts/architecture_lint/rules/frontend_root_state_cast.py @@ -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, +) diff --git a/scripts/architecture_lint/rules/go_imports.py b/scripts/architecture_lint/rules/go_imports.py new file mode 100644 index 0000000000..1080c2678c --- /dev/null +++ b/scripts/architecture_lint/rules/go_imports.py @@ -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 + "/") diff --git a/scripts/architecture_lint/rules/runtime_import.py b/scripts/architecture_lint/rules/runtime_import.py new file mode 100644 index 0000000000..0371d3ef2f --- /dev/null +++ b/scripts/architecture_lint/rules/runtime_import.py @@ -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, +) diff --git a/scripts/architecture_lint/rules/task_office_import.py b/scripts/architecture_lint/rules/task_office_import.py new file mode 100644 index 0000000000..8d91927c1e --- /dev/null +++ b/scripts/architecture_lint/rules/task_office_import.py @@ -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, +) diff --git a/scripts/architecture_lint_tests/support.py b/scripts/architecture_lint_tests/support.py new file mode 100644 index 0000000000..34fd2a7aa0 --- /dev/null +++ b/scripts/architecture_lint_tests/support.py @@ -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", + } diff --git a/scripts/architecture_lint_tests/test_baseline.py b/scripts/architecture_lint_tests/test_baseline.py new file mode 100644 index 0000000000..ccb8e5e567 --- /dev/null +++ b/scripts/architecture_lint_tests/test_baseline.py @@ -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) diff --git a/scripts/architecture_lint_tests/test_cli.py b/scripts/architecture_lint_tests/test_cli.py new file mode 100644 index 0000000000..e9d4042bbc --- /dev/null +++ b/scripts/architecture_lint_tests/test_cli.py @@ -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) diff --git a/scripts/architecture_lint_tests/test_compatibility.py b/scripts/architecture_lint_tests/test_compatibility.py new file mode 100644 index 0000000000..d538127650 --- /dev/null +++ b/scripts/architecture_lint_tests/test_compatibility.py @@ -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) diff --git a/scripts/architecture_lint_tests/test_frontend_root_state_cast.py b/scripts/architecture_lint_tests/test_frontend_root_state_cast.py new file mode 100644 index 0000000000..6a11393fd3 --- /dev/null +++ b/scripts/architecture_lint_tests/test_frontend_root_state_cast.py @@ -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) diff --git a/scripts/architecture_lint_tests/test_runtime_import.py b/scripts/architecture_lint_tests/test_runtime_import.py new file mode 100644 index 0000000000..c00b0117d1 --- /dev/null +++ b/scripts/architecture_lint_tests/test_runtime_import.py @@ -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) diff --git a/scripts/architecture_lint_tests/test_task_office_import.py b/scripts/architecture_lint_tests/test_task_office_import.py new file mode 100644 index 0000000000..c6ead0b935 --- /dev/null +++ b/scripts/architecture_lint_tests/test_task_office_import.py @@ -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) diff --git a/scripts/lint-architecture.py b/scripts/lint-architecture.py new file mode 100644 index 0000000000..1605ed325c --- /dev/null +++ b/scripts/lint-architecture.py @@ -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()) diff --git a/scripts/lint-architecture.test.py b/scripts/lint-architecture.test.py new file mode 100644 index 0000000000..92540affc5 --- /dev/null +++ b/scripts/lint-architecture.test.py @@ -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)