v0-x-to-strict-contract-names

star 4

Internal-package contract.go files must declare `type Service interface`, `type Deps struct`, and `func New(Deps) (Service, error)` (or the legacy single-result `func New(Deps) Service`). The convention is now lint-enforced; non-canonical names previously produced silently-broken bootstrap codegen.

reliant-labs By reliant-labs schedule Updated 6/11/2026

name: v0.x-to-strict-contract-names description: Internal-package contract.go files must declare type Service interface, type Deps struct, and func New(Deps) (Service, error) (or the legacy single-result func New(Deps) Service). The convention is now lint-enforced; non-canonical names previously produced silently-broken bootstrap codegen. relevance: migration

Migrating to strict internal-package contract names

Canonical New signature. The current scaffold and the canonical shape is the two-result form: func New(Deps) (Service, error). The single-result form func New(Deps) Service is still accepted by the linter for backward compatibility, but the forge add package and forge generate scaffolds emit the two-result form and the contract_test.go auto-scaffold targets it. If you write the single-result form, you keep ownership of contract_test.go (forge will skip the auto-scaffold to avoid emitting a non-compiling _, err := pkg.New(...) call).

Use this skill when forge generate or forge lint reports a forgeconv-internal-package-contract-names violation, or when upgrading across the version that introduced the strict-naming check.

1. What changed

Pre-strict, forge would happily run forge generate against an internal-package contract.go that declared, say:

package email

type Sender interface { Send(to, body string) error }
type Config struct{}
func NewSender(_ Config) Sender { return &svc{} }

The bootstrap codegen template (pkg/app/bootstrap.go) hardcodes references to <pkg>.Service, <pkg>.Deps, and <pkg>.New(...) for every entry under Packages, so the rendered bootstrap would emit:

import emailpkg "<module>/internal/email"

// ...
emailpkg.New(emailpkg.Deps{...})

…which doesn't compile against the Sender/Config/NewSender contract. The error pointed at generated code (pkg/app/bootstrap.go) rather than the user's contract.go, making the failure mode confusing.

Post-strict, both forge lint --conventions and forge generate short-circuit BEFORE bootstrap codegen runs and report:

[forgeconv-internal-package-contract-names]
internal-package contracts must declare
  'type Service interface', 'type Deps struct', and 'func New(Deps) Service'.
  Found 'Sender' at internal/email/contract.go:3 — rename to 'Service'
  (or move out of contract.go) so the bootstrap template can wire it.

The deprecation cycle policy doesn't apply here because the broken state was always wrong — it just failed silently. There is no "old shape works for N versions with warnings" period; non-canonical names never produced a working project.

2. Detection

# Run from the project root.
forge lint --conventions

# Or scan for the four canonical pieces by hand.
for f in $(find internal -name contract.go -not -path '*/testdata/*'); do
  echo "=== $f ==="
  grep -E '^type (Service interface|Deps struct)|^func New\(' "$f" || echo "MISSING canonical pieces"
done

If the project sits in contracts.exclude (analyzer sub-packages, embed-only packages, the cli surface itself), the check skips it — those packages aren't bootstrap-managed and are allowed to declare alternate shapes.

3. Migration (deterministic part)

For each violating package:

  1. Rename the interface to Service.

    -type Sender interface {
    +type Service interface {
         Send(to, body string) error
     }
    
  2. Rename the dependency struct to Deps. Use struct{} if there are no dependencies yet — the bootstrap template emits <pkg>.New(<pkg>.Deps{}) either way.

    -type Config struct{}
    +type Deps struct{}
    
  3. Rename the constructor to New(Deps) (Service, error) (canonical two-result form) or New(Deps) Service (legacy single-result form, still accepted by the lint). Pointer receivers for the parameter are intentionally rejected (func New(*Deps) Service) — the bootstrap template emits a value, not a pointer.

    Canonical two-result form (matches the forge add package scaffold):

    -func NewSender(_ Config) Sender                 { return &svc{} }
    +func New(_ Deps) (Service, error)               { return &svc{}, nil }
    

    Legacy single-result form (still lint-clean, but disables the contract_test.go auto-scaffold — you own that file by hand):

    -func NewSender(_ Config) Sender { return &svc{} }
    +func New(_ Deps) Service        { return &svc{} }
    
  4. Update call-sites in user code (typically pkg/app/setup.go or tests) that referenced the old names. The bootstrap template itself is regenerated by forge generate and doesn't need hand-editing.

  5. Re-run forge generate && go build ./....

forge upgrade will run the lint pass automatically and surface the list of files that need renaming, but the renames themselves are manual — they touch the interface's method set, which forge can't safely rewrite.

4. Migration (manual part)

Things that may need attention beyond the mechanical rename:

  • Multiple interfaces in one contract.go. The strict naming rule only requires that ONE interface be named Service — it's fine to declare additional interfaces alongside (e.g. Service + Cache). The bootstrap codegen wires Service only; other interfaces are for in-package use. The contract scaffold's mock generator handles multi-interface files by emitting one mock per interface.
  • The package legitimately has multiple peers. If your package exposes two equally-important interfaces (e.g. internal/cache with ReadCache and WriteCache), pick the one bootstrap should wire as Service and either rename the second to a name unrelated to Service or split into two packages.
  • The package isn't bootstrap-managed. Packages that ship via contracts.exclude (analyzer sub-packages, embed wrappers, the cli itself) can keep alternate names — the check skips excluded paths. If you add a new analyzer-style package, add it to contracts.exclude in forge.yaml.

5. Verification

forge lint --conventions       # must report ✓
forge generate                 # must run to completion
go build ./...                 # must compile clean
go test ./...                  # no contract-related test should regress

6. Rollback

Not really applicable — the broken state (non-canonical names) was always wrong. There's no version where a Sender/Config/NewSender contract produced a working project; the lint just makes the existing failure mode loud and early.

If you must temporarily silence the lint to land an unrelated change, add the package's path to contracts.exclude in forge.yaml. That opt-out is permanent for analyzer-style packages and a temporary band-aid for everything else — the package's bootstrap entry will still be broken until the rename happens.

Install via CLI
npx skills add https://github.com/reliant-labs/forge --skill v0-x-to-strict-contract-names
Repository Details
star Stars 4
call_split Forks 1
navigation Branch main
article Path SKILL.md
More from Creator
reliant-labs
reliant-labs Explore all skills →