forge lint --conventions and forge lint --contract, with --optional-deps-guard and --config-deps for the Deps shape. They exist because forge’s generators and mocks assume the shape, so a project that drifts from it gets a compile error in generated code, an untestable package, or a service that leaks internals to its callers.
For the flags, output and silencing, see the overview. The Severity column is the default for a project that does not set lint.rules; where a rule is a warning, --strict makes it an error. Rollout cells follow the rollout page.
Proto and service layout
forgeconv-one-service-per-file
Forge generates one handler package, one set of stubs and one mock per service, keyed by the proto file. Two services in one file confuse every one of those.
RefundService into proto/services/refund/v1/refund.proto. Messages two services share go in proto/shared/v1/types.proto.
forgeconv-service-dir-consistency
Forge finds a service’s files by directory name. A proto at proto/services/orders/v1/ that declares service InvoiceService makes generate and lint disagree about what the service is called. Rename the service or the directory so their logical names agree, ignoring separators and case.
forgeconv-list-filter-optional
A proto3 scalar without optional has no presence: unset and the zero value are the same on the wire, so the generated filter either never applies or the handler cannot tell a missing parent id from an empty one. The scaffolded list page then fails with “org_id is required”, a contract the proto never stated.
forgeconv-config-service-name
service.name is how every span, metric and error is attributed to a process, and one binary often runs as several workloads. Forge sets OTEL_SERVICE_NAME per workload. A config field bound to it projects one value into every workload, so the API, the worker and the operator all report as the same service. Older scaffolds shipped such a field (string service_name = 22, default "unknown"), and nothing read it.
OTEL_SERVICE_NAME in its own env.
forgeconv-activity-declared
Every mutating rpc declares the activity event it records, or declares that it records none, so the activity stream has no silent holes. The rpc that forgot is exactly the one missing from a user’s history. The fix is in the proto:
gen/forge_descriptor/, so run forge generate first. It uses the same functions the runtime interceptor uses to decide what counts as a mutation, so the two cannot disagree.
Handler errors
Handlers return errors throughsvcerr.Wrap(err), which maps a domain error to the right Connect code and redacts internals. Three rules defend that.
forgeconv-unwrapped-domain-error
A handler that hands a collaborator’s error back untouched sends the caller Unknown, with the driver’s text as the message.
forgeconv-no-handler-error-mapping
The opposite mistake: a per-service toConnectError or mapServiceError switch that re-implements what svcerr already does, with a slightly different mapping in each service.
svcerr import no longer silences it.
forgeconv-handler-connect-error
connect.Error.Message() is the wrapped error’s text, and svcerr’s interceptor passes a *connect.Error through untouched, so nothing redacts it. This publishes a driver’s SQL, a DSN or an internal host to the caller, and pins a guessed code over a NotFound or a deadline.
forge fix connect-errors rewrites the fixed-message sites with the same code and message, leaving the ones that wrap an err, which are the ones that leak, as your worklist. Test and generated files are not linted.
Deps shape
A component’sDeps struct is its entire view of the rest of the program. These rules keep it narrow, so a package can be unit-tested against mocks and a reader can see what it needs.
forgeconv-deps-are-interfaces
A concrete Deps field defeats the all-mock unit-test surface New(Deps) exists to give you.
forgeconv-deps-surface
An interface field that declares ten methods the package never calls is a concrete type in disguise. It is an error only when the interface comes from another package and has more than ten methods; otherwise a warning.
internal/app/compose.go.
forgeconv-no-container-deps
A component receives the capabilities it uses, never a bag to fetch them from.
forgeconv-deps-no-downcast
The field’s interface is the capability the component was granted. Asserting it back to a concrete type takes the rest.
forgeconv-deps-root-config
Taking the whole *config.Config gives a component every field of every other component. Declare a config block for the component in proto/<project>/config/v1, take Config config.<Block>, and fill it with g.Cfg.<Block> in compose.go.
forgeconv-no-getenv
A component that reads the process environment itself reads configuration the composition never handed it, which its workload’s env, and the config-reach check, cannot see.
forbidigo guardrail for os.Getenv (reported as typed-config-guardrail) covers the same ground outside component packages. If your .golangci.yml does not forbid it, that lane says it could not run and names the patterns to add.
forge-config-deps
A scalar in Deps (Timeout time.Duration, QueueURL string) is configuration. Declare a <Component>Config block and take it as one typed field. forge lint --config-deps prints the exact proto block and AppConfig line to write.
forge-optional-deps-guard
A // forge:optional-dep field may be nil, and validateDeps does not enforce it. An unguarded call is a nil-pointer panic no construction-time check will catch.
forgeconv-optional-dep-marker-position
The marker only works in the doc-comment slot directly above a Deps field. On the struct, on a method, or floating, it is silently inert and validateDeps still demands the field.
Package contracts
Aninternal/<pkg>/ component package has a contract.go with type Service interface, type Deps struct and func New(Deps) Service. The generated mock, the instrumentation decorator and the bootstrap all depend on those names.
The contract linter (
--contract) adds three findings without rule IDs of their own, because they come from a Go analysis pass: an exported method on a contract type that is not in its interface (“exported method X on type Y is not declared in the Service interface (contract.go)”), a package with exported methods and no contract.go, and an exported package-level variable that should be a method or getter. Sentinel errors, go:embed variables and constant-shaped data are exempt. These are governed by features.contracts and contracts.exclude in forge.yaml, not by lint.rules.
forgeconv-internal-package-contract-names
The bootstrap template hardcodes the three names, so a non-canonical contract produces generated code that does not compile.
forge.yaml this reports as a warning, since there is no generated bootstrap to protect.
forgeconv-internal-package-missing-contract
A package with the shape of a component (it has a Service-like type, constructors, state) and no contract.go gets no mock, no decorator and no seam. Run forge scaffold package <name>, move your code into the service.go it writes, or, if the package is deliberately not a component (a helper, a constants package), put //forge:exclude-contract: <why> at the top of one of its files.
forgeconv-outbound-io-no-rpc
A package tagged // forge:outbound-io is an adapter to a third-party system. If it also registers a Connect handler it has grown into a service. Drop the tag, or move the handler to internal/handlers/<svc>/ and have it depend on the package’s Service.
forgeconv-contract-names
This ID is in the graded set and the rollout table, but in v1.0.14 no analyzer emits it: the contract-name check reports under forgeconv-internal-package-contract-names. Grade that ID. This row exists so the reference matches the table.
The directives that opt out
Each opt-out costs a reason, because it is the cheapest way to turn lint green and agents learned to reach for it.
These IDs are not in
lint.rules; they are governed by the directive itself. Fix them by giving the reason, by writing the contract the package needs, or by deleting the marker.
Handler and test conventions
forgeconv-handler-file-size counts non-blank, non-comment lines and is advisory; set lint.handler_file_max_loc to move the line. forgeconv-handler-tests-use-tdd points at forge/pkg/tdd.RunRPCCases, the library the scaffolded per-rpc tests use. forgeconv-frontend-hook-tests is advisory, since a hooks file with no test has simply never been executed.
forgeconv-frontend-hook-test-shape
The hooks file is regenerated on every generate; the starter test is scaffold-once. When an rpc’s query-versus-mutation classification changes (forge sharpens its name heuristic, you add // forge:mutation, or the rpc becomes NO_SIDE_EFFECTS), the hook flips and the test does not. A test that awaits isSuccess on a mutation, or calls mutateAsync on a query, can never pass. It gates, and runs in --conventions so generate:check catches it before vitest does.
forge project rescaffold <path>.
forgeconv-tx-lock-under-serializable
Two correctness arguments for a transaction are exclusive. SERIALIZABLE is correct with no locks, provided the aborted transaction (error 40001) is retried. READ COMMITTED plus a lock is correct because the lock makes writers take turns. Combining them is the trap: under SERIALIZABLE the writer that waited on a lock wakes into a snapshot from before the wait and fails with 40001, so it survives only by retrying, and a contended wait costs a full rollback and re-run.
// forge:tx-opener so the rule can judge its calls.
forgeconv-bun-dollar-placeholder and forgeconv-bun-literal-question-mark
Two placeholder dialects share one data-access API and nothing at the call site says which one a string is in. The raw passthroughs (orm.Context.Exec, Query, QueryRow) take Postgres’s $1. Everything bun formats (db.Bun().NewRaw, the builders’ Where and Set, bun.SafeQuery) takes ? or ?0, and interpolates arguments client-side.
? is worse, because it can run: it consumes the next argument and shifts every later one onto the wrong placeholder.
Frontend conventions
forgeconv-frontend-process-env
Frontend config is declared in proto and projected from KCL into a typed config_gen.ts, with values arriving at runtime from a per-environment config.js. A raw process.env read bypasses the schema, the types, the defaults and the secret refusal, and, because the bundler inlines it at build time, freezes the artifact to the environment it was built against. That breaks promotion: a bundle built with staging’s issuer carries it into prod.
src/lib/config_gen.ts). It is a warning so a frontend mid-migration does not go red. When you finish, gate it with lint.rules: {forgeconv-frontend-process-env: error}. A value that is genuinely build-time-only gets a // forge:lint-disable-next-line forgeconv-frontend-process-env: <why>.
forgeconv-frontend-env-forge-owned
A committed .env.local that sets *_MOCK_API, *_API_URL, *_OTEL_ENDPOINT or *_ENVIRONMENT fights the value forge injects from KCL at dev launch and build, and a committed *_MOCK_API=true is the classic mock that ships to prod. Declare the value in the frontend’s KCL config block and delete the dotenv line. The same analyzer runs at error severity on the build and deploy path, so a forge-owned dotenv can never reach a shipped build.
forgeconv-frontend-stores-no-server-data
Server state belongs in React Query through the generated hooks. Zustand is for client-only state (modals, theme, preferences). A store that also imports a generated Connect client creates two sources of truth that drift.
Deploy-tooling conventions
forgeconv-allocate-port-spacing
allocate_port(base, key) computes base + block(key) * BlockSize. Two different bases that are congruent modulo BlockSize (default 100) collide at the block where their separation, in blocks, is reached: the first base’s port equals the second base’s literal. With bases 28090 and 29190, both ≡ 90 mod 100, the 11th worktree’s controller port equals the default stack’s gateway port. The default maximum is 8 stacks, so it stays invisible until the limit is raised. Pick bases with different remainders modulo BlockSize.
forgeconv-allocate-port-spacing is a graded and gating rule in the table, and its analyzer (LintAllocatePortSpacing) is in forge. In v1.0.14 the sweep does not call it, so it does not currently appear in a forge lint run. Treat the table entry as the contract and check the reference index after you upgrade.