Documentation
¶
Overview ¶
Package fluentapi holds the contract every rule in the library ends in — Checkable — and the one options bag its terminal takes, CheckOptions.
A rule is a value, not an action. The chain a user types — `project files, in folder "internal/api/**", should not, depend on files, in folder "internal/db/**"` — builds a value and does no work; it ends in a terminal, and the terminal is a Checkable. Running it is the only thing that touches the filesystem.
Every consumer programs against Checkable and nothing else: the testing layer, a report, a user's own helper that loops over a list of rules. That is what keeps adding a rule family a change to one module — nothing downstream knows or asks whether a Checkable came from files, layers, slices or metrics.
Two things every terminal goes through live here rather than in the families, so that no family can be quietly lenient about either: the empty-test guard, which is why a rule that selected nothing is a violation, and LoggedCheck, which is why every check writes the same three records to the log a user asked for. Both hang off CheckOptions, because both are decisions the user's own bag makes.
Each domain module has its own fluentapi package, holding the builders the user types. This one holds only what all of them have in common.
Index ¶
- type CheckOptions
- func (o *CheckOptions) EmptyTestOptions(subject string, selectors ...matching.Filter) *assertion.EmptyTestOptions
- func (o *CheckOptions) ExtractGraph(locator *extraction.ProjectLocator) (extraction.Graph, error)
- func (o *CheckOptions) GatherEmptyTestViolations(populations ...EmptyTestPopulation) []assertion.Violation
- func (o *CheckOptions) IgnoresImportKind(kind extraction.ImportKind) bool
- func (o *CheckOptions) LoggedCheck(rule fmt.Stringer, ...) ([]assertion.Violation, error)
- func (o *CheckOptions) Logger() (*logging.Logger, error)
- func (o *CheckOptions) SourceOptions() *extraction.SourceOptions
- func (o *CheckOptions) WithDefaults() CheckOptions
- type Checkable
- type EmptyTestPopulation
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type CheckOptions ¶
type CheckOptions struct {
// AllowEmptyTests makes a rule that selected nothing a pass instead of an EmptyTestViolation.
// False by default, because a selector matching nothing is far more often a stale glob or a
// renamed folder than an intention, and such a rule is green forever. This is the one knob that
// changes what a rule reports, which is why it is the flag every terminal has to thread into the
// empty-test guard; EmptyTestOptions is how.
AllowEmptyTests bool
// Logging is the log this check writes while it runs — the rule, the steps it took, the numbers it
// measured, the violations it found. Nil, the default, means it logs nothing at all.
//
// A library must not own a process-global logger, so the whole of switching logging on is filling in
// this bag: the destination is injected, the level is the bag's, and a check that was handed no bag
// is silent however loudly the rule beside it was asked to log. logging.Options is where the knobs
// are, including the log file a CI job archives. A technical failure is still the error Check
// returns, never a log line.
Logging *logging.Options
// ClearCache makes the check extract the project from source again instead of reusing a graph an
// earlier check produced in the same process. Extraction is the expensive half of a check and
// every rule in a test suite asks about the same project, so caching is how a suite stays fast.
//
// The escape hatch matters when the source has changed underneath the library — a test that
// writes a fixture project, or generated code produced between two checks. ExtractGraph is where
// the flag is honored, and it clears the whole cache rather than this project's entry, for the
// reason extraction.ClearGraphCache gives: the reason to clear is that the source moved.
ClearCache bool
// IgnoreScopes are the ignore-directive scopes this check answers to. An import a file marked with a
// bare `//archunit:ignore` is left out of the graph for every rule; one marked with a scoped
// directive — `//archunit:ignore layers` — is left out only for a check naming that scope here, and
// counts as an ordinary dependency everywhere else.
//
// Empty by default, so an unscoped directive needs no configuration and a scoped one is honored only
// where it was asked for. extraction.IgnoreDirective is the whole convention, including what the
// comment looks like and where in a file it goes.
IgnoreScopes []string
// IncludeTestFiles adds the project's _test.go files, and the packages built only from them, to
// the extracted graph. It is the Tests flag on go/packages.
//
// Off by default: an architecture rule describes the shape of the production code, and a test
// reaching across a boundary to build a fixture is rarely the violation the rule meant to catch.
// Turn it on to hold tests to the same rules as the code.
IncludeTestFiles bool
// IgnoredImportKinds drops imports of these flavors before any edge is emitted, so a rule never
// has to know they existed.
//
// The usual member is extraction.ImportKindBlank: `import _ "github.com/lib/pq"` registers a
// driver and depends on no API, so counting it as a dependency makes every rule about the
// database layer fire in main.go. Empty by default, because dropping an edge is a decision that
// should be visible in the test that made it.
IgnoredImportKinds extraction.ImportKindSet
// BuildTags are the build constraints to analyze the project under, becoming build flags for
// go/packages. Empty means the toolchain's defaults for the host platform.
//
// A file excluded by a constraint is absent from the graph, so a rule that selects nothing on one
// platform and everything on another is usually a tag missing from here.
BuildTags []string
}
CheckOptions is the one parameter a terminal takes beyond its required arguments: everything about *how* a rule is run, as opposed to what the rule says.
It is a struct rather than a list of functional options, matching the sibling ports and reading better in the documentation, and a *CheckOptions is always allowed to be nil. Every knob's default is its zero value — a check is quiet, strict about empty selections, free to reuse a cached graph, and looks at the production code under the host platform's build constraints — so a nil bag, the zero bag and an explicitly empty one all describe the same check. Read a nil bag through the methods below, or through WithDefaults, rather than reaching for a field.
func (*CheckOptions) EmptyTestOptions ¶
func (o *CheckOptions) EmptyTestOptions(subject string, selectors ...matching.Filter) *assertion.EmptyTestOptions
EmptyTestOptions translates these check options into the empty-test guard's own options, adding what the rule was selecting and the selectors it was selecting with. Every terminal wires the guard in through this, so AllowEmptyTests is copied across in exactly one place:
violations := assertion.GatherEmptyTestViolations(matched, options.EmptyTestOptions("files", selectors...))
The guard takes the one flag rather than the whole bag because fluentapi depends on assertion, for the violations Check returns, and not the other way round.
The selectors are copied, for the reason assertion.NewEmptyTestViolation copies them: spreading a caller's slice into a variadic parameter shares its backing array.
func (*CheckOptions) ExtractGraph ¶
func (o *CheckOptions) ExtractGraph(locator *extraction.ProjectLocator) (extraction.Graph, error)
ExtractGraph gets the dependency graph a rule is to be checked against: the project the locator names, located, extracted under these options, and memoised so that the next rule of the suite is a map lookup. A nil locator means auto-detect, as it does at every entry point.
It is the whole SOURCE-and-EXTRACT half of a terminal, in one call, so that a terminal reads
graph, err := options.ExtractGraph(rule.locator)
and no terminal locates, clears and extracts in three steps of its own. That matters for exactly one reason: ClearCache is honored here, and a terminal that assembled the sequence by hand would be a terminal that could forget to.
The error is technical or the user's — an unreadable root, no go.mod at or above the locator — never a rule failure, which is a Violation in the list Check returns.
func (*CheckOptions) GatherEmptyTestViolations ¶
func (o *CheckOptions) GatherEmptyTestViolations(populations ...EmptyTestPopulation) []assertion.Violation
GatherEmptyTestViolations is the empty-test guard as a terminal wires it in: one assertion.EmptyTestViolation per population of the rule that matched nothing, and no violations when every population matched something — or when the user opted out with AllowEmptyTests.
It is the door every terminal in every module goes through, so that the highest-value defensive decision in the library — zero matches is a violation, not a pass — is made once and no rule family gets to be quietly lenient about it:
if empty := options.GatherEmptyTestViolations(rule.selection(len(selected))); len(empty) > 0 {
// A rule with no subject is reported instead of being judged.
return empty, nil
}
Every empty population is reported rather than only the first, because both patterns of a relational rule are then wrong and a reader fixing one would come back for the other. A population the terminal does not pass is not guarded, and a terminal that passes none is not guarded at all — the guard cannot know what a rule was about, which is why AGENTS.md makes wiring it in a step of adding a rule and why the library holds its own terminals to having done it.
The short-circuit stays with the terminal: this returns the violations of the empty populations, and it is the caller that reports them *instead of* judging anything, so that a report never says a rule both did and did not have a subject.
func (*CheckOptions) IgnoresImportKind ¶
func (o *CheckOptions) IgnoresImportKind(kind extraction.ImportKind) bool
IgnoresImportKind reports whether an import of this flavor should be left out of the graph. It is the question the extractor asks of each import declaration, and the answer is no for a nil options bag and for anything that is not a declared extraction.ImportKind.
func (*CheckOptions) LoggedCheck ¶
func (o *CheckOptions) LoggedCheck( rule fmt.Stringer, check func(log *logging.Logger) ([]assertion.Violation, error), ) ([]assertion.Violation, error)
LoggedCheck runs a rule's own Check under this bag's logging options: the log opened, `start check` written, the rule run, one `violation` record per violation it reported, `end check` written, the log closed. It is how every terminal in the library implements Check:
func (c FilesDependencyCondition) Check(options *kernel.CheckOptions) ([]assertion.Violation, error) {
return options.LoggedCheck(c, func(log *logging.Logger) ([]assertion.Violation, error) {
// the check itself, logging its own progress through log
})
}
It is a door in the kernel for the same reason GatherEmptyTestViolations is one: the three records that every rule family writes identically — the rule, its violations, its outcome — are written in one place, so no family gets to log a little differently and none can forget to log at all. What is left to the terminal is the part only it knows, which is the progress of its own pipeline and the numbers a metrics rule measured; the logger it is handed is how it says those.
The logger is never nil, so the closure has no branch to write, and it writes nothing at all when no destination was asked for — which is the default. Nothing about a check changes when logging is off beyond the writing: the violations, the error and the order of the steps are the same either way.
The rule is asked for its own sentence, and a nil one is logged as `a rule` rather than as an empty line. The error is the check's own, or — when the check itself succeeded — the log's: a record that could not be written or a log file that would not close is a TechnicalError, and it comes back with no violations beside it, because the contract Checkable states is that the two are mutually exclusive.
func (*CheckOptions) Logger ¶
func (o *CheckOptions) Logger() (*logging.Logger, error)
Logger opens the log this check writes, which is a logger that writes nothing when no destination was asked for — a nil options bag, a nil logging bag, or a bag with neither a writer nor a file in it.
The caller owns it and has to close it, because a log file is a file:
log, err := options.Logger()
if err != nil {
return nil, err
}
defer log.Close()
LoggedCheck is that call, plus the start and end records, plus the closing, and it is what every terminal in the library goes through instead. This is the door for a caller assembling something else — a report of its own, a harness that runs a suite through one log file.
The error is a TechnicalError naming the log file: a folder that cannot be created, a path that is not writable. A check that cannot open the log it was asked for fails rather than running quietly.
func (*CheckOptions) SourceOptions ¶
func (o *CheckOptions) SourceOptions() *extraction.SourceOptions
SourceOptions translates these check options into the extraction stage's own options, the way EmptyTestOptions translates them into the empty-test guard's. It is where the knobs that describe the analysis cross from the bag a user filled in to the walk and the toolchain that read it, in one place, so that a terminal never assembles a second one by hand and finds it disagreeing with the first.
Folder exclusions have no knob on the check options, so the enumeration's own defaults apply — vendored dependencies and build output, plus everything the Go toolchain itself ignores.
Both slices arrive already cloned, from WithDefaults, so the extraction bag does not share an array with the user's own options.
func (*CheckOptions) WithDefaults ¶
func (o *CheckOptions) WithDefaults() CheckOptions
WithDefaults returns the options a check should actually run with: a copy of the receiver, or the defaults when the receiver is nil. Terminals start with this, so that the "nil means defaults" contract is honored in one place instead of being re-derived as a nil check per field.
Today every default is a zero value, so the only default this has to supply is for the nil case. Going through it anyway is what lets a default that is not a zero value be added here later without touching a single terminal.
Both slices are cloned, for the reason EmptyTestOptions clones its selectors: a struct copy shares the slice's backing array, so a terminal appending a tag to its resolved bag would reach into the user's own options — which a stored half-built rule shares — and into every other copy.
The logging bag is not cloned. It is read once, when the check opens its log, and never written to, and its whole point is that the destination in it is the caller's own: a copy of a *logging.Options would still be a copy pointing at the same writer, so cloning it would buy nothing and cost the identity a caller may well be comparing.
type Checkable ¶
type Checkable interface {
// Check runs the rule and returns one Violation for every place the code disagrees with it. An
// empty result is the pass: there is no boolean to keep in step with the list.
//
// A nil *CheckOptions means the defaults, so rule.Check(nil) is the ordinary call.
//
// The error reports a technical failure — a project that will not load, a file that will not
// parse, an option used wrongly — and never a failing rule. A rule that fails returns violations
// and a nil error; when the error is non-nil the violations say nothing and the caller should
// ignore them. The error is an archerror.TechnicalError or an archerror.UserError, so that a
// caller can read off whether the library or the rule as written is at fault.
Check(options *CheckOptions) ([]assertion.Violation, error)
}
Checkable is a rule that can be run: the seam the whole library hangs from.
The interface has one method and no unexported members, so it is open — a rule family in any module, or a user with a rule of their own, can be a Checkable, and everything that consumes rules works on it unchanged.
type EmptyTestPopulation ¶
type EmptyTestPopulation struct {
// Subject is what the rule was selecting, in its entry point's own vocabulary — `files`, `slices`,
// `layers`, `files to depend on` — and it goes straight onto the violation. It is what lets a report
// of a relational rule say which half of the sentence the user has to go and fix.
Subject string
// Matched is how many nodes this population came to. Zero is what the guard reports on, which is why
// a terminal counts the selection rather than handing the guard the nodes themselves.
Matched int
// Selectors are the filters that, taken together, described this population, in the order the user
// chained them. They are the data a reader needs in order to see which pattern was wrong, and they go
// straight onto the violation.
Selectors []matching.Filter
}
EmptyTestPopulation is one of the populations a rule selected, as the empty-test guard is asked about it: what was being selected, how many nodes the selection came to, and the selectors that described them.
A rule about one vocabulary has one population — the files, the slices, the layers its scope named. A relational rule usually has two, because `should not depend on files in folder "internal/db/**"` selects a subject and an object and either pattern can be the stale one; Subject is what tells them apart in a report, in the entry point's own words.
Usually, because a relational rule whose object is not a population of the project's own nodes hands over its subject alone: the object of `should not depend on external modules matching "*.*/**"` *is* a set of dependencies, so "no module matched" and "no file depends on such a module" are one statement, and for the negated mood that statement is the pass. Which populations a rule has is the terminal's to say, and it says it by which ones it passes here.
The selectors are read and not kept: CheckOptions.GatherEmptyTestViolations clones them on the way to the violation, for the reason assertion.NewEmptyTestViolation gives.