chore: add architecture lint guardrails (#2168)
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Architecture linter package."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
@@ -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]]
|
||||
@@ -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}")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user