v0.x: features as a value, and a root that owns its registries¶
Spec 0199, landing in phases. This note grows with them.
Phase 1: the snapshot replaces the seal¶
A new package, pkg/features, is the feature core: Registry, Snapshot,
Resolver, Set and Evaluator as interfaces with one default implementation
each, and no GTB import so it can leave for go/features later. props and
setup are re-implemented over it. What you write at init() does not change:
props.RegisterFeature, setup.Register, setup.RegisterChecks,
setup.RegisterAssets, setup.RegisterMiddleware and
setup.RegisterGlobalMiddleware keep their signatures and still target the
process-wide default registry, because a blank import can reach nothing else.
What changes is what protects a reader from a late writer. A seal used to turn a
registration after enumeration into a panic; now a reader takes an immutable
snapshot and a later registration is simply not in it. Nothing after main
begins panics.
| Gone | Instead |
|---|---|
props.SealFeatures, props.ErrRegistrySealed |
nothing: features.Default().Snapshot() is immutable |
setup.SealRegistry, setup.Seal, setup.IsSealed |
nothing |
setup.ResetRegistryForTesting |
build your own: r := features.NewRegistry(), contribute to it, read it with the *In accessors |
props.FeatureID as a distinct type |
an alias of features.ID; every constant and conversion compiles unchanged |
props.FeatureKind as a distinct type |
an alias of features.Kind |
Added, for tests and for hosts that hold their own registry:
features.NewRegistry(),features.Default(),features.MustDeclare.setup.RegisterOn(r, feature, ...)and the snapshot readerssetup.InitialisersIn,SubcommandsIn,FeatureFlagsIn,ChecksIn,AssetsIn,ChainIn. TheGet*andChainforms read the default registry's snapshot and are unchanged for callers.props.FeatureDescriptorimplementsfeatures.Descriptorand gains aDynamicfield (false for every built-in; see spec 0199 D7).
Before¶
func TestMyMiddleware(t *testing.T) {
setup.ResetRegistryForTesting()
t.Cleanup(setup.ResetRegistryForTesting)
setup.RegisterMiddleware("mine", mw)
wrapped := setup.Chain("mine", runE)
// ...
}
After¶
func TestMyMiddleware(t *testing.T) {
t.Parallel()
r := features.NewRegistry()
r.Contribute("mine", setup.SlotMiddleware, mw)
wrapped := setup.ChainIn(r.Snapshot(), "mine", runE)
// ...
}
A test that only builds a root and never registers anything needs no reset and no registry of its own: drop the two reset lines.
Interim: the root still contributes its three built-in global middlewares to the
default registry once per process (a sync.Once where the seal check was), so
every root in one process shares one chain closing over the first root's
Props. Phase 2 gives each root its own chain and removes this.
Phase 2: the root owns its set, its chain and its flags¶
The enabled set is a value on Props; the middleware chain and the init flag
targets belong to the root that built them; props.New is the construction
path the skeleton, gtb and the e2e binary use. Two roots in one process now
share nothing (#27, #37), and each root's telemetry middleware reports to its
own collector rather than the first root's.
| Gone | Instead |
|---|---|
Tool.IsEnabled(id), Tool.IsDisabled(id) |
p.GetFeatures().Enabled(id) (nil-safe; p.Features is the field New fills) |
props.FeatureDescriptors(), AllFeatures(), FeaturesOfKind(), DescriptorFor() |
features.Default().Snapshot() (Descriptors, OfKind, Lookup), or p.GetFeatures(); props.DescriptorsIn(e) narrows either to GTB's descriptors |
setup.GetInitialisers(), GetSubcommands(), GetFeatureFlags(), GetChecks(), GetAssets() |
features.ContributionsOf[T](p.GetFeatures(), id, setup.Slot*) for enabled features; the setup.*In(snapshot) readers for everything declared |
setup.Chain(feature, runE), setup.ChainIn |
the root's setup.Chainer (NewMiddlewareChain(builtin, set)), reached through Command.Register; root.WithChain replaces it |
setup.InitialiserProvider func(p *props.Props) Initialiser |
func(p *props.Props, flags *pflag.FlagSet) Initialiser: read your skip flag from flags (setup.FlagSkips) rather than a package variable |
a FeatureFlag binding cmd.Flags().BoolVar(&pkgVar, ...) |
bind a target the command owns: cmd.Flags().Bool(name, setup.CIDefault(), usage) |
GitHub's --skip-key |
the init command's own --skip-key (setup.SkipKeyFlag), honoured by every profile that offers an SSH key whichever forges are linked |
skeleton NewCmdRoot(v) (*setup.Command, *props.Props) |
(*setup.Command, *props.Props, error); main prints the error and exits 2 (errorhandling.ExitCodeUsage) |
forge.Unlinked(tool props.Tool) |
forge.Unlinked(set features.Set) |
Added:
Props.Features features.Set,Props.Flags features.Evaluator(defaults toFeatures),Props.GetFeatures(),Props.GetFlags().props.NewoptionsWithFeatures(snapshot),WithResolver,WithSet,WithFlags;props.StatesOf,props.DescriptorsIn,props.Enumerator.root.WithRegistry,root.WithResolver,root.WithChain.setup.Chainer,setup.MiddlewareChain,setup.RunE,Command.UseChain,setup.ChainedAnnotation,setup.SkipKeyFlag,setup.FlagSkips,setup.CIDefault;forge.DisplaysIn.
Resolution follows OQ2: enabling a feature nothing declared fails props.New
(and main exits 2); disabling one is ignored and listed by
p.GetFeatures().Ignored(). A literal Props that skipped New drops the
unknown enable and carries on, since the root cannot return an error.
Before¶
func NewCmdRoot(v version.Info) (*setup.Command, *props.Props) {
p := &props.Props{Tool: props.Tool{Name: "mytool", Features: props.SetFeatures(...)}, Logger: l, FS: afero.NewOsFs()}
p.ErrorHandler = errorhandling.New(logger.ToSlog(l), p.Tool.Help)
return root.NewCmdRoot(p, serve.NewCmdServe(p)), p
}
if p.Tool.IsEnabled(props.AiCmd) { /* ... */ }
After¶
func NewCmdRoot(v version.Info) (*setup.Command, *props.Props, error) {
tool := props.Tool{Name: "mytool", Features: props.SetFeatures(...)}
p, err := props.New(tool, l, afero.NewOsFs(), props.WithVersion(v))
if err != nil {
return nil, nil, err
}
return root.NewCmdRoot(p, serve.NewCmdServe(p)), p, nil
}
if p.GetFeatures().Enabled(props.AiCmd) { /* ... */ }
A downstream main regenerated by gtb regenerate project picks the new
shape up; a hand-written one adds the error branch.