--- name: arandu-doctor description: Read and act on aru doctor, the architecture checker of an Arandu (Go) application. Use when a doctor finding is reported, when a build passes but something feels unenforced, or before declaring any Arandu change finished — it is one of the gates. Also use when the request mentions "lint", "static analysis", "architecture check", "why is this failing", or a rule name such as grant-not-received, tenant-from-request, view-data-is-a-map, raw-output-is-not-a-component, service-takes-http or input-read-by-hand. Covers what each finding means, why it is never suppressed, and what the checker cannot see. license: MIT --- # Reading what the doctor says ## When to use `aru doctor` reported something, a build passes and something still feels unenforced, or a change is about to be called finished -- the doctor is the last gate. Fixing what a finding points at is the family skill of that code; this skill says what the finding means. ## Before you start `aru doctor` is this framework's architecture rules run as static analysis over the parsed tree. Without it, mandatory architecture is documentation nobody reads. Run the `aru` the Dockerfile pins -- an older one checks fewer rules, and its clean report says less than it seems to. ## Contracts and imports Every finding carries a file, a line, the rule name, what is wrong, and a **Why** that says what breaks. An error fails the run; a warning fails it only under `--strict`. There is no ignore comment and no allow-list. The one marker the doctor reads is `//arandu:system-grant `, on or above an `auth.SystemGrant` call outside a seeder, a job and a command: it excuses `system-grant-outside-scope` on that line and nothing else, and a marker with no reason excuses nothing. ## Every rule The whole set the doctor checks, with the severity it reports, in the order `aru doctor --list` prints them. An error fails the run; a warning fails it only under `--strict`. `tests/Unit/DoctorSkill_test.go` fails when a row here differs from the list it carries, and, when the `aru` on PATH is the release the Dockerfile pins, when that list differs from what `aru doctor --list` prints. | rule | severity | what it reports | | --- | --- | --- | | `file-does-not-parse` | error | a Go file the doctor cannot read, which leaves the rest of the report incomplete | | `repository-without-policy` | error | an entity with a repository and no policy | | `grant-not-received` | error | an exported repository method that reaches the database and takes no Grant | | `grant-not-checked` | error | a method that takes a Grant and never calls `Check` on it | | `grant-check-discarded` | error | a `Check` whose answer is thrown away, as in `_ = g.Check(...)` | | `policy-never-opened` | warning | a policy that denies every action | | `action-not-a-constant` | error | an action built from a value rather than named as a constant | | `enum-rule-not-derived` | error or warning | an `enum` rule that lists cases its type does not declare (error), or repeats the ones it does (warning) | | `handler-reaches-data` | error | a controller, a middleware or a route file that uses the data package beyond `data.Query` | | `handler-reaches-the-model` | error | a controller, a middleware or a route file that opens a query on a model | | `controller-reaches-repository` | error | a controller, a middleware or a route file that imports `app/Repositories` | | `tenant-from-request` | error | a tenant read from the request — a path value, a form field, a query parameter — or a Grant whose tenant came from one | | `tenant-from-header` | error | a tenant read from a header | | `system-grant-without-tenant` | error | a `SystemGrant` with an empty tenant | | `system-grant-outside-scope` | warning | a `SystemGrant` outside a seeder, a job and a command | | `sql-built-with-sprintf` | error | SQL assembled with `fmt.Sprintf` | | `sql-built-by-concatenation` | error | SQL assembled by concatenating a value | | `sensitive-field-not-redacted` | warning | a struct under `app/` with a field named for a secret -- password, secret, token, apikey, cpf and the rest, as a whole word -- whose value reaches a log or JSON sink in the project's code, and that has no `LogValue` and `MarshalJSON` | | `session-not-rotated` | error | a sign-in that authenticates and keeps the session id it arrived with | | `csrf-exempt-without-signature` | warning | a route that changes state under a path `CSRFExcept` exempts, whose controller action never calls `webhook.Verify` | | `view-data-is-a-map` | error | a view handed a map | | `view-does-not-exist` | error | a view name that names no `.kyse.go` | | `permission-not-declared` | error | code that uses a permission `arandu.mod.toml` declares false | | `permission-not-used` | warning | a permission `arandu.mod.toml` declares true and nothing uses | | `view-keeps-state-in-the-browser` | error | an `x-` or `@` attribute with a value in a view | | `sql-without-tenant-scope` | error | a statement on a table other statements scope by `tenant_id`, without that filter | | `outbox-not-registered` | error | code that stores domain events in a project that registers no outbox table | | `resource-not-reauthorized` | warning | a method that reads a row and does not authorize the row it read | | `raw-output-is-not-a-component` | warning | `{!! x !!}` given a value rather than a component call | | `retired-module` | warning | an import of a module whose repository was deleted | | `import-not-canonical` | warning | a framework symbol named through a bridge package rather than through the path `aru imports:catalog` gives it, in Go -- tests included -- and in a `.kyse.go` source | | `test-is-not-run` | warning | a file named `...Test.go`, or a test function in a file whose name does not end in `_test.go` | | `test-outside-the-tests-tree` | warning | a test outside `tests/` whose name does not end in `_internal_test.go` | | `package-clause-is-capitalised` | warning | a package clause with a capital letter | | `scaffolding-ships` | warning | a file outside the tests that imports test scaffolding | | `skills-out-of-date` | warning | a skill under `.agents/skills` copied from the skeleton or a `hyz-is` module that is behind what that origin hands out at the version the project pins | | `skills-missing` | warning | a skill the skeleton or a required `hyz-is` module hands out that this project does not have | | `generated-skill-retired` | warning | a skill under `.agents/skills` whose metadata source is `aru@`: one `make:module` wrote, which no generator writes or updates any more | | `migrations-not-linked` | warning | migrations in a package nothing imports, so `aru migrate` never sees them | | `added-column-not-nullable` | warning | a column added to an existing table without `Nullable()` or a default | | `rollback-does-nothing` | warning | a migration that declares neither a `Down` nor that it is irreversible | | `driver-not-linked` | warning | an engine `.env.example` names whose connector the project does not import | | `profile-not-declared` | warning | `--profile=performance`: a project whose `arandu.mod.toml` does not list that profile | | `join-across-aggregates` | error | `--profile=performance`: a statement that reads two tables | | `transaction-across-aggregates` | error | `--profile=performance`: a transaction that writes two aggregates | | `model-query-stale` | error | a generated query or factory missing, behind its entity, or left from one that is gone | | `model-core-outside-models` | error | the model core used outside a package that declares entities | | `input-read-by-hand` | warning | a controller that reads the form, the query or the body field by field instead of `ctx.Bind` into the request | | `validate-called-by-controller` | warning | a controller that calls `Validate()` on the request, or `validation.Validate`, which is the service's call | | `json-written-by-hand` | warning | a controller that encodes JSON onto the response writer, or writes its own status with it, instead of a resource and `ctx.JSON` | | `invalid-form-answered-by-hand` | warning | a controller that answers a rejected form with a 422 of its own instead of returning the `validation.Errors` | | `session-loaded-in-controller` | warning | a controller outside `app/Http/Controllers/Auth` that loads the session instead of reading `ctx.User()` | | `redirect-to-literal-path` | warning | a redirect in a controller to a path written as a literal rather than a named route | | `html-template-in-app` | warning | a file under `app/` that imports `html/template` | | `service-takes-http` | warning | a file under `app/Services` that names the request, the response writer, the HTTP context, the session, a cookie or `template.HTML` | | `service-subpackage` | warning | a directory under `app/Services` that holds Go | | `service-file-too-large` | warning | a file under `app/Services` longer than 600 lines | | `controller-too-many-actions` | warning | a controller type with more than 12 handler methods | | `operation-chosen-by-form-field` | warning | a handler that switches on a submitted field to call one service method or another | | `client-outside-clients` | warning | a request to another system — an `http.Client`, `http.Get`, `http.NewRequest`, the hesape HTTP client — made under `app/` outside `app/Clients` | | `model-rule-touches-io` | warning | a method in the custom block of a model that reaches the database, the network or the clock | | `fragment-without-partial` | warning | `ctx.Fragment` given a view outside `resources/views/partials` | | `helper-reimplemented` | warning | a function under `app/` that rewrites a helper the catalog already has, such as a slug, a CPF or a BRL formatter | | `raw-sql-outside-repository` | warning | a statement run outside `app/Repositories` and `database/` | | `generated-not-wired` | warning | an exported `New...` constructor under `app/Http/Controllers` or `app/Services` that nothing outside the tests names, unless it builds a test double a test constructs | | `subject-built-by-hand` | warning | a `Subject` literal outside the tests and `database/` that writes its own roles or actions | ## Procedure 1. **Read the Why, not the rule name.** The rule name says which check fired; the Why says what a user of the application would experience. Fix the second one. 2. **Fix the cause at the line it names**, by moving the code to the row of "Where each kind of code lives" in `AGENTS.md` that names its kind -- not by reshaping it until the rule stops matching. 3. **Never suppress.** A finding you cannot fix is a design question, and the answer goes in the report. 4. **Run it again until it is clean**, then the other gates. ### The findings you will meet most **`grant-not-received`** — a repository method takes no `auth.Grant`. Every caller gets the row, whoever asked. Add the Grant as the parameter before the id, start the method with `if err := g.Check(Action...); err != nil { return err }` — a Grant that is taken and never checked is `grant-not-checked` — and take it from the Policy. **`tenant-from-request`** — the tenant is read from a path segment, a form field or a query; `tenant-from-header` is the same finding for a header. A tenant that arrives with the request is a tenant the caller chose. Read it with `auth.Tenant(g)`. **`repository-without-policy`** — a repository is reachable with no Policy deciding. Write the Policy; the generator writes one that denies everything, and you open it action by action. **`resource-not-reauthorized`** — a method authorized the action and then read one row without authorizing the row. The first call answers "may this caller look at all"; the second answers "may this caller look at *this*". Skipping the second means any user of the same tenant sees the row. **`view-data-is-a-map`** — a view was handed a map. A typo in a key is then a blank space on a page that answered 200. Declare a struct that embeds `view.Page`. **`raw-output-is-not-a-component`** — `{!! x !!}` was given a value rather than a call. The raw form escapes nothing, so a value that ever comes from a person is stored cross-site scripting. Write `{{ x }}`, which escapes, or return it from a component function. **`policy-never-opened`** — a policy denies every action. On a new module this is correct and expected; it is a warning so that a fresh project is not red on day zero. **`retired-module`** — an import names a module that no longer exists. The line says what replaced it. **`import-not-canonical`** — a file names a symbol through a framework bridge package rather than through the path the symbol lives at: `security.Grant` for `auth.Grant`, `data.DB` for `database.DB`, `fhttp.Context` for `hhttp.Context`. The bridges are old import paths whose symbols are aliases of a component's, kept so code written against them goes on compiling; each is removed in v1.0.0, and until then one type is spelled two ways, file by file. It reads every Go file, tests included, and every `.kyse.go` source -- its import lines and its `local.Name` uses, as text, so a sentence in the markup that spells `security.Grant` counts. The Go `aru view:build` writes is skipped: the finding goes to the import line of the source it was compiled from. Which path is canonical is read, symbol by symbol, from the framework source at the version `go.mod` requires; a type the framework declares, such as `Router` or `SessionStore`, is canonical where it is. That catalog is read from disk, so on a machine that has not downloaded the version the rule is silent. Import the path the finding names, and keep the framework import only for what the framework declares; `aru imports:catalog --fix` shows the rewrite for every file and `--apply` writes it. It is a warning because nothing is wrong yet. **`sensitive-field-not-redacted`** — a struct under `app/` has a field whose name carries a sensitive word (password, passwd, secret, token, apikey, creditcard, document, cpf, cnpj; a whole word, singular or plural) and whose type can hold one, and a value of it reaches a log or JSON sink in this project's code -- the message names the sink. slog, fmt and `observability.Dump` print every field, `json.Marshal` writes every exported one, and only the type can promise it never leaks. A type nothing logs or encodes is not reported, nor one with `LogValue` or `MarshalJSON`, nor one only encoded to JSON whose sensitive fields are all `json:"-"`; a count such as `MaxTokens int` is not a secret. It follows a value only inside one function: typed by the signature, by a composite literal, or by a name that is the type's in lower case -- not through another type's field, a call's result or a helper that logs. Add `LogValue() slog.Value` and `MarshalJSON` to the type, or tag the field `json:"-"` when JSON is the only sink. **`generated-not-wired`** — an exported `New...` constructor under `app/Http/Controllers` or `app/Services` is named by no file outside the tests: code nothing constructs is code the router never reaches, its tests pass, and a change to it changes nothing anybody sees. A call from `bootstrap/app.go`, `routes/web.go` or another constructor wires it. A test double -- Fake, Stub, Mock, Spy or Dummy as a word of the constructor, of the type it returns or of its file -- that a test constructs is not reported; a service named like production code whose only caller is its own test still is, which is the case the rule exists for. It compares names across the project, not types, so a function of the same name elsewhere hides a finding rather than inventing one. Paste the wiring `aru make:module` printed into `bootstrap/app.go` and `routes/web.go`, or delete what nothing reaches. **`model-query-stale`** — a `Query.go`, or a factory `aru model:build` renders, is missing, behind its entity, or left over from one that is gone. A build compiles what is on disk, so the application would run against the query of an entity that is not the one in the source. Run `aru model:build`; in a pipeline that calls `go build` directly, `aru model:build --check` asks the same question. **`model-core-outside-models`** — the model core is used outside a package that declares entities, which here is `app/Models`: a `model.NewTable`, or a method called on a `*model.Table`, a `*model.Builder` or what `Base()` returns. The core hands back untyped rows, and a query written on it is a second way to reach the table, one the generated query does not describe. Call the generated constructor, `models.Notes(db)`, or write the query as a method on `*NoteQuery` in the custom block of the entity's file. Three more checks run only under `--profile=performance`: `profile-not-declared`, `join-across-aggregates` and `transaction-across-aggregates`. What they report is correct code on the conventional profile, and each says so in its own first lines. The ones above run on every profile. ### The structural warnings Nineteen rules, from `input-read-by-hand` to `subject-built-by-hand` at the end of the table above, report code that works and lives in the wrong place: a controller that reads the form field by field, validates, writes JSON or a 422 by hand, loads the session or redirects to a literal path; a service that takes an HTTP type, sits in a subpackage, or grows past 600 lines; a controller past 12 actions or one that picks the operation from a form field; markup outside a view, a client outside `app/Clients`, a model rule that reaches the database, the network or the clock, a fragment that is not a partial, a helper written again, SQL outside a repository, a constructor nothing wires, and a `Subject` that writes its own roles. All are warnings. The correction for each is the row of "Where each kind of code lives" in `AGENTS.md` that names the kind of code the finding is about: move the code there, do not reshape it until the rule stops matching. A count rule (`service-file-too-large`, `controller-too-many-actions`) says where to look, not that the file is wrong. Each rule reads one function, file or call by name, so a clean report means none of these shapes was found, not that none exists. The sign-in screens `go run github.com/arandu-io/ui@v0.22.0 auth` publishes, once wired, report nothing. A finding in `app/Http/Controllers/Auth` after an older kit is the kit's to fix, and republishing a kit release brings the fix. `session-loaded-in-controller` does not read that directory at all, because signing in is where a session is first loaded. ### The CSRF exemption `csrf-exempt-without-signature` is a warning. - **Reason.** `CSRFExcept`, passed to `CSRFProtect` in `bootstrap/app.go`, switches the CSRF check off for every write under a path. It exists for a route another system calls -- a webhook -- which proves who sent it with a signature over the body instead of a cookie and a token, and it is safe on that promise alone. An exempt route that verifies nothing takes a write from any page a signed-in person opens, and from anybody who can reach it. - **Scope.** Every `CSRFExcept` string literal under `bootstrap/`; every route under `routes/` that changes state (POST, PUT, PATCH, DELETE or any method) whose path the prefix exempts the way the framework matches it -- the path itself, or anything below a prefix that ends in `/`; and the controller action each such route reaches. - **Negative.** The action calls `webhook.Verify` from `github.com/arandu-io/hesape/webhook` (under any import name). A GET under the prefix, a path outside it (`/webhooksx` for `"/webhooks/"`), and a path below a prefix written without the slash (`/hooks/legacy` for `"/hooks"`) are not reported. Known false positive: an action that hands the raw body to a service, helper or middleware that verifies it. - **Limit.** Function-local: only the body of the action the route reaches is read, and nothing it calls is followed. A prefix held in a variable, a handler that is a function literal or a controller the route table does not resolve, and a prefix behind a path parameter are not read. A clean report means no unverified action was found, not that none exists. - **Fix.** Verify in the action before anything trusts the body: `webhook.Verify(secrets, timestamp, deliveryID, body, signature)` from `github.com/arandu-io/hesape/webhook`, answering 401 when it fails -- or take the route out of the exempt prefix. ## Commands - `aru doctor`, every finding; `aru doctor --strict`, warnings fail too - `aru doctor --list`, every rule with its severity - `aru doctor --profile=performance`, three more checks for the performance profile - `aru imports:catalog`, the import path of each framework symbol - `aru model:build`, the fix for `model-query-stale` ## Example The one marker the doctor reads, on a call that has a reason to be outside a seeder, a job and a command: ```go compile package example import ( "context" "github.com/arandu-io/hesape/auth" "github.com/arandu-io/hesape/database" models "/app/Models" policies "/app/Policies" ) // PublishedCount answers how many notes a tenant has published, for an // operator's report that runs with no subject behind it. func PublishedCount(ctx context.Context, db *database.DB, tenant string) (int, error) { //arandu:system-grant the operator's report runs with no subject; the tenant is the one the operator named g := auth.SystemGrant(policies.NoteList, tenant) published, err := models.Notes(db).WhereNotNull("published_at").Get(ctx, g) return len(published), err } ``` ## Do not - Reshape code until a rule stops matching. A finding is about where the code lives; code moved to the right place stops matching because it is right. - Treat a warning as noise because it is not an error. Structural rules start as warnings so a new project is not red on day zero, not because they are optional. - Write a marker without a reason, or put one on a line it does not excuse. - Run an older `aru` and call the report clean. ## Extending it A project adds no rules and suppresses none. A rule that is wrong about correct code is a bug of the doctor, reported with the file and the line; a rule that should exist is a proposal to the framework. ## Wiring None. The doctor reads the tree as it is; CI runs it on every push, without `--strict`. ## Acceptance test `aru doctor` prints no finding on the change. `tests/Unit/DoctorSkill_test.go` holds the table above to the rule list, and to what `aru doctor --list` prints when the `aru` on PATH is the pinned release. ## Limits It reads the parsed tree, not the running program. - SQL built from a variable, or held in a package constant, is not inspected. A clean report means no unscoped statement was **found**, not that none exists. - Partition keys are not checked, because nothing in the code declares one. - A build tag is invisible to it, so a file excluded from the compiler is still read. - Each structural rule reads one function, file or call by name: a clean report means none of those shapes was found, not that none exists. - `csrf-exempt-without-signature` reads only the action a route reaches: a signature checked in a helper the action calls is not seen. Trust it as evidence, never as proof. ## Gates Run them all, in this order, as `AGENTS.md` lists them: ```sh export GOWORK=off aru model:build --check aru view:build gofmt -l $(find . -name '*.go' -not -path '*/testdata/*' -not -name '*.kyse.go') go vet ./... bash tests/test-layout-guard.sh go test -race ./... go build ./... aru doctor ```