Documentation
¶
Overview ¶
Package contract drives the four *_gen.go files (mock, middleware, tracing, metrics) emitted from a single hand-written contract.go.
The behavioural surface is a single Service. Data carriers (File, InterfaceDef, MethodDef, ParamDef) remain plain types.
This package generates mocks but gets none: internal/tools/mockgen links it to refresh forge's own mocks WITHOUT building forge (forge#1261), so a mock_gen.go here would put generated code back in the very import closure that must stay free of it — a method added to Service would leave the tool that regenerates the stale mock unable to compile. Nothing mocks Service.
Package contract parses Go interface definitions from contract.go files and generates mock_gen.go (function-field mock pattern) in the same directory.
Earlier versions of this package also emitted middleware_gen.go, tracing_gen.go and metrics_gen.go — per-method wrappers around every contract.go interface. Those were removed in favour of Connect interceptors at the handler boundary (forge/pkg/observe) plus opt-in helpers (observe.LogCall, observe.TraceCall, observe.NewCallMetrics) for users who want internal-package observability. The mock stays codegen because the per-method MockX struct is a real grep target — "show me MockUserService's methods" is a tight feedback loop that generic reflection can't replace.
File: internal/generator/contract/observed.go
Generates middleware_gen.go — the forge-owned observability decorator for a component's Service-contract interface. This REPLACES the former hand-written, scaffold-once observe.go: the decorator is now derived from the interface by the SAME ParseContract path that drives mock_gen.go, so a method added to the interface regenerates the wrapper automatically (zero hand-maintenance) and the mock and the decorator can never drift — both come from one parse of contract.go.
The generated decorator is deliberately SLIM: one wrapper method per interface method, each a one-liner routing through an in-process observe.ComponentChain — observe.DefaultComponentChain, or the chain the OPTIONAL owned observe_chain.go seam (newObserveChain) assembles when the package declares one. The decorator names no middleware itself. This is the in-process twin of the Connect edge interceptor chain, configured the same way.
Retirement of contract-codegen artifacts when a package opts out.
── The hole this closes ──────────────────────────────────────────────
A package opts out of contract codegen either centrally (forge.yaml `contracts.exclude`) or locally (`//forge:exclude-contract` in its own source). Both are honoured by SKIPPING the package in the mock walk — and, until this file existed, by nothing else. Whatever forge had already emitted there stayed on disk, certified with forge's `// Code generated by forge. DO NOT EDIT.` banner and its forge:hash marker, at a path no emitter would ever target again.
That leftover is not merely untidy, it is UNREACHABLE. Two mechanisms could resolve a certified file, and an opted-out artifact escapes both:
- the Tier-1 writer (checksums.WriteGeneratedFile) re-stamps anything it rewrites — including a file whose marker went stale while its body stayed byte-identical to the render. It never runs here: the walk returned before reaching the emitter.
- the stale-artifact sweep (cli/generate_cleanup.go) retires certified files the run did not re-emit — but only PRISTINE ones. Once the leftover's marker goes stale it is reclassified as "stale AND hand-edited: the bytes are the user's now", reported and never removed.
So the moment anything perturbs the bytes of an opted-out artifact — a repo-wide text sweep over the ownership banner is the observed case — it becomes permanently un-healable: `forge lint --generated-drift` fails on it forever and no forge command moves it. (Observed in forge's own tree: internal/config/mock_gen.go, opted out two months after it was generated, then caught by a banner sweep.)
Retirement closes the hole at its source. Opting out is an ownership EXIT, not a skip: forge gives the path back, and giving it back means removing the artifact forge put there — the same discipline removeLegacyWrappers already applies to the superseded observability wrappers.
── Why this is safe ──────────────────────────────────────────────────
Retirement deletes only bytes forge can PROVE are its own, and there are exactly two proofs:
- the embedded marker verifies (checksums.ClassifyPath == Pristine, which already tolerates .go formatter churn) — forge's own render of some vintage, hand-edited by nobody;
- the marker is stale but the body is byte-identical to what forge renders RIGHT NOW (canonical-formatting-insensitive). Only the certificate went stale; there is no content difference, therefore nothing user-authored to lose. Available for mock_gen.go, the one artifact this package can re-render on demand.
Everything else is kept and reported, never deleted:
- no marker at all — forge never certified those bytes;
- a marker that fails to verify AND a body that differs from the render — a genuine hand-edit, which is the user's to resolve;
- a disowned path — ownership was already transferred by recorded intent, and `forge project disown` is one-way.
Index ¶
- Constants
- func ExcludedByDirective(dir string) bool
- func Generate(contractPath string) error
- func GenerateMock(contractPath string, opts Options) error
- func GenerateWithOptions(contractPath string, opts Options) error
- func IsRetirableArtifact(base string) bool
- func RemoveObservedDecorator(dir string) error
- func Targets(internalDir, projectDir string, excluded func(rel string) bool) []string
- func WriteObservedDecorator(cf *File, dir, ifaceName, ctorName, structName, opNamespace string, ...) (bool, error)
- type ArtifactSnapshot
- type DefaultChain
- type Deps
- type File
- type ImportDef
- type InterfaceDef
- type MethodDef
- func (m MethodDef) CallArgs() string
- func (m MethodDef) ContextParamName() string
- func (m MethodDef) ErrorResultName() string
- func (m MethodDef) FuncFieldType() string
- func (m MethodDef) HasContext() bool
- func (m MethodDef) HasResults() bool
- func (m MethodDef) LastResultIsError() bool
- func (m MethodDef) ParamSignature() string
- func (m MethodDef) RecordArgs() string
- func (m MethodDef) ResultNames() string
- func (m MethodDef) ResultSignature() string
- func (m MethodDef) ZeroResults(mockName string, interfaceNames map[string]bool, ...) string
- type Options
- type ParamDef
- type Resolver
- type Retirement
- type Service
Constants ¶
const ExcludeContractDirective = "forge:exclude-contract"
ExcludeContractDirective is the package directive that opts a package out of contract codegen — the per-package equivalent of forge.yaml `contracts.exclude`: `//forge:exclude-contract: <why>`.
const InstrumentedCtorName = "NewInstrumented"
InstrumentedCtorName is the generated decorated twin of a package's New.
Variables ¶
This section is empty.
Functions ¶
func ExcludedByDirective ¶ added in v1.0.14
ExcludedByDirective reports whether the package in dir carries `//forge:exclude-contract`, reasoned or bare. The bare form still excludes: codegen never breaks a project over the spelling of an opt-out (`forge lint` is what refuses it).
func Generate ¶
Generate parses contractPath and writes mock_gen.go next to it.
In addition to (re)writing mock_gen.go, Generate sweeps any stale observability wrappers (middleware_gen.go, tracing_gen.go, metrics_gen.go) from the same directory — these were emitted by previous forge versions and are now superseded by the forge/pkg/observe Connect interceptors. Removing them here (rather than relying on the audit "orphan" report) keeps `forge generate` idempotent and gives the user a clear signal in the build output: either the file is present and current, or it's gone.
func GenerateMock ¶ added in v1.0.14
GenerateMock parses contractPath and (re)writes mock_gen.go next to it, and nothing else: unlike GenerateWithOptions it leaves the package's other generated files alone. internal/tools/mockgen calls it to refresh forge's own mocks without building forge, where deleting a middleware_gen.go it cannot regenerate would break the build it exists to repair.
func GenerateWithOptions ¶
GenerateWithOptions is Generate with project-level extension hooks (currently: ExtraInterfaceTypes). New plumbing should go through this entry point; the bare Generate is preserved for call sites that don't need the extension surface.
func IsRetirableArtifact ¶ added in v0.1.45
IsRetirableArtifact reports whether base (a bare filename) is one of the artifacts contract codegen owns inside a package directory. Exported so the stomp guard can recognise these paths without duplicating the list.
func RemoveObservedDecorator ¶ added in v0.1.45
RemoveObservedDecorator deletes middleware_gen.go from dir if present — called when a package is NOT (or no longer) instrumented (the owned observe_chain.go seam is absent), so opting out by deleting the seam leaves no orphan decorator behind. A missing file is not an error.
func Targets ¶ added in v1.0.14
Targets lists every file the contracts step generates from under internalDir — each contract.go, and the marked deps.go of a package without one — pruning testdata/ subtrees and every directory excluded reports true for (its module-relative, slash-separated path, matched against forge.yaml `contracts.exclude`). A nil excluded excludes nothing.
Packages that opt out with `//forge:exclude-contract` are still listed: the caller decides what an opted-out package gets (forge generate retires its artifacts; the mock bootstrap skips it). Listing them costs the Resolver nothing it would not also resolve for a sibling.
`forge generate` builds ONE Resolver over this list before its contracts walk, and internal/tools/mockgen generates from it, so the two agree on which packages have mocks. A deps.go left out of the Resolver still generates, but every interface it embeds from another package resolves to nothing: its mock silently lacks those methods and fails generate's build validation. Walk errors are not fatal here: the generating walk reports them.
func WriteObservedDecorator ¶ added in v0.1.45
func WriteObservedDecorator(cf *File, dir, ifaceName, ctorName, structName, opNamespace string, def *DefaultChain, opts Options) (bool, error)
WriteObservedDecorator renders middleware_gen.go for the interface named ifaceName in cf, wrapping every method of that interface. ctorName / structName are the wrapper's exported constructor and unexported struct names, resolved by codegen.ResolveMiddlewareWrapper (keyed off the constructor's concrete return type) and threaded in by the caller so the generated declaration matches the compose-emitted call byte-for-byte. opNamespace is the prefix for the per-method operation name ("<opNamespace>.<Method>") — the package name for a single-impl package, carrying an extra concrete-type segment when the package has multiple wrapped constructors.
def is nil when the package declares the owned newObserveChain seam, which the decorator then calls; otherwise the decorator builds observe.DefaultComponentChain from it.
Returns (false, nil) when ifaceName is not declared in cf (nothing to wrap) — the caller treats that the same as "package not instrumented". When opts.ProjectRoot is set the write goes through the checksums chokepoint (Tier-1, hash-stamped, disown-aware); otherwise a raw write is used (the add-package stub path, adopted into the manifest on the next full generate).
Types ¶
type ArtifactSnapshot ¶ added in v0.1.46
ArtifactSnapshot holds the bytes of the files contract codegen owns in one package directory (a nil entry: the file was absent), taken before a run so the caller can say whether the run changed them. No single write's outcome can: GenerateWithOptions deletes middleware_gen.go and the decorator step writes it back, so a package whose output is byte-identical still sees a delete and a create every run.
func SnapshotArtifacts ¶ added in v0.1.46
func SnapshotArtifacts(dir string) ArtifactSnapshot
SnapshotArtifacts records the current bytes of dir's contract artifacts.
func (ArtifactSnapshot) ChangedIn ¶ added in v0.1.46
func (s ArtifactSnapshot) ChangedIn(dir string) bool
ChangedIn reports whether any of dir's contract artifacts now differs from the snapshot: different bytes, created, or deleted.
type DefaultChain ¶ added in v1.0.0
DefaultChain is what a decorator passes observe.DefaultComponentChain when its package declares no newObserveChain seam: the tracer/meter scope, the metric namespace, and the Go expression for the success log level (config.ObservabilityConfig.SlogLevelExpr, e.g. "slog.LevelDebug").
type File ¶ added in v0.1.45
type File struct {
Package string
// Source is the base name of the file the interfaces were read from —
// contract.go, or a package's deps.go — which mock_gen.go's header names
// as the file to edit.
Source string
Imports map[string]string // alias/name → import path (e.g. "sql" → "database/sql")
// ExplicitImports records aliases that were written explicitly in
// contract.go (for example `controlplanev1 ".../v1"`). Keeping that
// bit lets the mock renderer preserve aliases whose spelling cannot be
// recovered from the import path alone.
ExplicitImports map[string]bool
Interfaces []InterfaceDef
// InterfaceNames is the set of interface type names defined in this file.
// Used by the zero-value generator to emit "nil" for interface-typed
// returns instead of the invalid composite literal "T{}".
InterfaceNames map[string]bool
// PrimitiveAliases maps a named type (e.g. "BalanceCapReason") to its
// underlying primitive kind (e.g. "string"). Populated by scanning
// contract.go and its sibling .go files for `type X <primitive>`
// declarations. Used by the zero-value generator to emit the
// underlying primitive's zero (`""`, `0`, `false`) instead of the
// invalid composite literal `BalanceCapReason{}`.
PrimitiveAliases map[string]string
// ForeignImports are import paths required by interfaces discovered
// through the Deps struct rather than through contract.go. They are
// held separately because collectImports only sees contract.go's own
// import block, and a db.EstimateStore's signatures routinely mention
// packages contract.go never imports (orm, the db package itself).
ForeignImports map[string]ImportDef
}
File holds everything extracted from a single contract.go.
func ParseContract ¶
ParseContract parses a contract.go file and extracts all interface definitions.
Sibling .go files in the same package directory are also scanned (parse-only, no method extraction) to populate InterfaceNames so the mock generator can emit "nil" for interface-typed returns whose declaration lives outside contract.go (e.g. internal/debug defines Service in contract.go and Debugger in debugger.go).
Import names and foreign Deps interfaces are resolved through the Go toolchain by a Resolver scoped to this one contract; callers parsing many contracts share one via ParseContractWith.
func ParseContractWith ¶ added in v0.1.45
ParseContractWith is ParseContract against a shared Resolver (nil builds one for this contract alone).
It is a single-pass parser: each statement consumes one part of the contract file in order. Splitting a sequential parse into helpers trades a long function for hidden state passed between them.
type ImportDef ¶ added in v0.1.45
ImportDef is one import required by generated mock code. Name is the qualifier used in rendered signatures; Explicit says the declaration must retain that spelling instead of relying on the imported package's name.
func (ImportDef) Declaration ¶ added in v0.1.45
Declaration renders one import spec, preserving explicit or synthesized aliases. The path is quoted here so the template cannot accidentally quote the whole `alias "path"` declaration.
The name is also spelled whenever it is not the one goimports assumes from the path (assumedImportName) — `reliantv1 ".../gen/reliant/v1"` — because that is the spec goimports rewrites the import to. The pipeline used to leave that to its goimports pass, so the bytes the mock writer put on disk were not the run's final bytes: a run that failed before goimports (a generate hook, say) exposed them to everything reading the tree meanwhile, and reported them as the "broken aliases" it had written. Rendering the fixed point directly makes the write final, and makes the output the same whichever way the qualifier was resolved.
type InterfaceDef ¶
type InterfaceDef struct {
Name string
Methods []MethodDef
// Qualifier is the package name to reach this interface by when it is
// declared in ANOTHER package of the module (e.g. "db" for a
// db.EstimateStore named in a Deps field). Empty for the ordinary
// case: an interface declared in this package's own contract.go.
//
// The mock TYPE is always unqualified — MockEstimateStore, in the
// consuming package — because the mock is generated here. Only the
// compile-time check refers back to the original, as
// `var _ db.EstimateStore = (*MockEstimateStore)(nil)`.
Qualifier string
// Collides is set when this foreign interface's bare name is also
// declared locally, so MockName must disambiguate. See MockName.
Collides bool
}
InterfaceDef represents a parsed Go interface.
func (InterfaceDef) MockName ¶ added in v0.1.45
func (i InterfaceDef) MockName() string
MockName is the generated mock's type name. A foreign interface whose bare name collides with something the package already declares is prefixed with its qualifier — forge's own internal/docs is the live case: it declares Service AND depends on contract.Service, so the plain name would emit two MockService types and the package would not build. Disambiguate only on collision, so the overwhelmingly common case keeps the name the scaffolded comment promises (MockEstimateStore, not MockDbEstimateStore).
func (InterfaceDef) QualifiedName ¶ added in v0.1.45
func (i InterfaceDef) QualifiedName() string
QualifiedName is how the interface must be written inside the package being generated: bare when it is local, package-qualified when it was discovered through a Deps field pointing at another package.
type MethodDef ¶
type MethodDef struct {
Name string
Params []ParamDef
Results []ParamDef
// SkipObserve is true when the interface method's doc / inline comment
// carries the `// forge:no-observe` marker. The generated observability
// decorator still wraps the type, but THIS method delegates DIRECTLY to the
// inner implementation without routing through the observe chain. It is the
// method-scoped twin of the package-level `// forge:no-observe` opt-out.
SkipObserve bool
}
MethodDef represents a single method on an interface.
func (MethodDef) CallArgs ¶
CallArgs returns the argument list for delegating to the inner implementation, e.g. "ctx, id" or "ctx, query, args...".
func (MethodDef) ContextParamName ¶
ContextParamName returns the name of the context.Context parameter, or empty string.
func (MethodDef) ErrorResultName ¶
ErrorResultName returns the placeholder name for the error result (last result).
func (MethodDef) FuncFieldType ¶
FuncFieldType returns the func type for mock function fields, e.g. "func(context.Context, string) (string, error)".
func (MethodDef) HasContext ¶
HasContext returns true if the method has a context.Context parameter.
func (MethodDef) HasResults ¶
HasResults reports whether the method returns anything.
func (MethodDef) LastResultIsError ¶
LastResultIsError returns true if the last result type is "error".
func (MethodDef) ParamSignature ¶
ParamSignature returns the Go parameter list for a method, e.g. "ctx context.Context, id string".
func (MethodDef) RecordArgs ¶
RecordArgs returns the argument list to pass to the embedded contractkit.Recorder.Record. Variadic params are passed verbatim (no "..." suffix) so the recorder receives the slice as a single any value — this is what callers want when asserting on captured arguments. If the method has no parameters, returns the empty string and the template emits Record("Method") with no extra args.
func (MethodDef) ResultNames ¶
ResultNames returns placeholder variable names for capturing results, e.g. "r0, r1" for two return values.
func (MethodDef) ResultSignature ¶
ResultSignature returns the Go result type list, e.g. "(string, error)" or "error".
func (MethodDef) ZeroResults ¶
func (m MethodDef) ZeroResults(mockName string, interfaceNames map[string]bool, primitiveAliases map[string]string) string
ZeroResults returns the zero-value expression for the result types, for use in mock fallback returns. E.g. "nil, contractkit.MockNotSet(...)".
interfaceNames is the set of locally-defined interface names from the parsed contract; passed through to zeroValue so interface-typed returns emit "nil" rather than the invalid composite literal "T{}".
primitiveAliases maps locally-declared named primitive types (e.g. `type BalanceCapReason string`) to their underlying kind; passed through so the mock emits the primitive's zero value ("") rather than the invalid composite literal `BalanceCapReason{}`.
The trailing error result is rendered as contractkit.MockNotSet so the canonical "Mock<Iface>.<Method>Func not set" error string lives in the library; bumping the format is now a one-place change.
type Options ¶
type Options struct {
// ExtraInterfaceTypes is a project-supplied allow-list of cross-package
// interface types the mock generator should treat as mockable. The
// rendered type expression of a method's return (e.g.
// "billing.MeterClient") is matched against this set during zero-value
// emission; matches produce "nil" instead of the invalid composite
// literal "T{}".
//
// Sourced from forge.yaml's `contracts.interface_types` at the CLI
// layer; nil / empty is the no-op default.
ExtraInterfaceTypes map[string]bool
// ProjectRoot + Checksums route the mock write through the
// checksums.WriteGeneratedFile chokepoint so the emitted
// internal/<pkg>/mock_gen.go is recorded in the manifest AND in
// the per-run WrittenThisRun set. Without this, the stale-artifact
// sweep saw every manifest-tracked mock_gen.go as "tracked but not
// re-emitted this run" and flagged it for deletion on every
// `forge generate` — and the sweep would delete live mocks
// (kalshi-trader FORGE_BACKLOG #15).
//
// ProjectRoot is the project root (the directory containing .forge/);
// relative manifest paths are computed against it. Both fields nil
// /empty preserve the legacy raw-os.WriteFile behavior for callers
// without a manifest in scope (e.g. the `forge scaffold package` stub
// emit — its mock is adopted into the manifest on the next full
// generate).
ProjectRoot string
Checksums *checksums.FileChecksums
// Resolver answers what each import is called and what each foreign
// Deps interface's method set is, from the Go toolchain. A caller
// generating many contracts (the `forge generate` contracts step)
// builds ONE with NewResolver over all of them, so the whole run costs
// two go/packages loads. nil resolves this contract alone.
Resolver *Resolver
}
Options controls optional aspects of mock generation. The zero value is equivalent to plain Generate(contractPath) — every field is opt-in and backwards compatible.
type ParamDef ¶
type ParamDef struct {
Name string
TypeExpr string // rendered Go type expression, e.g. "context.Context", "*sql.Rows", "...any"
Variadic bool
}
ParamDef represents a method parameter or return value.
type Resolver ¶ added in v0.1.45
type Resolver struct {
// contains filtered or unexported fields
}
Resolver answers the two questions a mock renderer cannot answer from one file's syntax, by asking the Go toolchain:
- What is an unaliased import CALLED? `import "github.com/nats-io/nats.go"` binds the identifier `nats` — the package's declared name — and nothing in the import path says so. The generator used to guess the last path element ("nats.go"), never matched the `nats.Msg` a signature used, and dropped the import: control-plane's internal/svcdaemon/mock_gen.go stopped building on every `forge generate --steps mocks`.
- What IS a foreign interface's method set? A Deps field naming natsio.Requester is mocked from the type-checked interface, so embedded interfaces contribute their methods and every package the signatures reference arrives with its real path and name.
One Resolver serves a whole `forge generate` run: NewResolver is called once with every contract.go and resolves them all in two go/packages loads (a name-only `go list` and one type-check of the foreign packages), instead of two per contract.
Every answer is best-effort. A toolchain that cannot load a package (no `go` on PATH, a module missing from go.mod mid-edit) leaves that path unresolved, and the generator falls back to the syntax it can read — mock generation runs on every generate and must not block codegen over a dep the toolchain does not understand yet.
func NewResolver ¶ added in v0.1.45
NewResolver resolves, for every contract.go in contractPaths, the names of the packages its files import unaliased, the types of the in-module packages its Deps struct names, and the types of the packages its interfaces embed from. Contracts are grouped by module so a multi-module caller still gets one load per module.
type Retirement ¶ added in v0.1.45
type Retirement struct {
// Retired lists artifacts removed from disk — provably forge's own
// bytes, so nothing was lost.
Retired []string
// Kept lists artifacts left in place because forge could not prove
// the bytes were its own. Callers should surface these: forge no
// longer emits them, so only the user can resolve them.
Kept []string
}
Retirement is the outcome of one retirement sweep. Both slices hold project-relative slash paths (or, with no project root in scope, absolute ones) so callers can report them verbatim.
func RetireExcludedArtifacts ¶ added in v0.1.45
func RetireExcludedArtifacts(dir string, opts Options) (Retirement, error)
RetireExcludedArtifacts retires the contract-codegen artifacts forge previously emitted into dir, for a package that has since opted out of contract codegen. It never recurses — the per-package `//forge:exclude-contract` directive opts out exactly one package, and a central `contracts.exclude` entry is swept a directory at a time by the caller.
A missing artifact is not an error: the steady state for a package that opted out before forge ever generated for it is that there is nothing to retire, and the sweep is a no-op on every subsequent run.
type Service ¶
type Service interface {
Generate(contractPath string) error
ParseContract(path string) (*File, error)
}
Service drives contract.go → *_gen.go generation. Generate parses the contract, applies the four generators, and writes results next to the input. ParseContract is exposed for callers that want the AST without emitting files (e.g. docs).