Surfex keeps a specification, its tests and its code aligned. It reads source without compiling it, versions every spec section, test and public definition, and keeps an append-only log of which versions someone confirmed belong together (§11–§15). When either side changes, the relation dangles until it is confirmed again. It also renders surface goldens: markdown documents that CI regenerates and byte-compares, so a document cannot disagree with the code without the build failing (§2, §10).
This document is the specification of Surfex itself. Surfex holds it to its own code and tests with its own relation log: every public module and function implements a section, every test hint is verified by a test, and every name cited here exists.
1. Scope and policies
1.1 What Surfex is for
Surfex is a build-time tool. A project takes it as a dev/test dependency
(only: [:dev, :test], runtime: false), so it never reaches that project's consumers.
It serves two uses:
- Keeping a spec, its tests and its code aligned (§11–§15). The spec, the tests and
the code are scanned into versioned records. A log records which versions were
confirmed to belong together, and
mix surfex.statuschecks it: nothing changed since it was confirmed, every item described or deliberately excused, and every name the spec cites exists (§6–§8). - Surface goldens (§2, §10). A project scans its own source for whatever it
catalogues and renders a golden with
Surfex.Golden. The scanner is the project's; the notation is Surfex's.
How precisely Surfex can relate a spec depends on how the spec is written.
guides/writing-specs.md (§16) says how to write one that it relates well.
1.2 Policies
- No dependencies.
deps: [], stdlib only. Anything Surfex depends on, every project that takes it inherits. - Never compile what is scanned. A gate built on Surfex runs before, and independently of, the code it inspects, so it cannot be fooled by a module that failed to build.
- No project-specific code. Surfex knows no project's vocabulary. What differs between
projects is data:
.surfex.exs, read as aSurfex.Profileand the log's own keys. - Goldens are a pure function of source. No timestamps, dates or VCS data ever appear in one. A date makes a golden drift against itself on a quiet day.
- Output never follows input order. Every report, golden and suggestion is ordered by what it reports, not by the order its inputs came in: the log's lines arrive in whatever order a union merge left them, and a file system lists files as it likes. A golden that reordered with them would churn, and churn teaches people to regenerate without reading.
- Loud over silent. Configuration that cannot be right (an unknown key, a pattern that matches nothing, a scan that found nothing) raises before anything renders. A misconfigured check must never pass.
- Published text is prose. The spec, the README and the guides never carry script text outside a code fence: a scripted edit that writes its own source into them is caught by the tests, not by a reader (#83).
- The test suite can't race itself. The working directory belongs to the whole VM, so a test module that changes it doesn't run async.
the status report, the completeness report, a history and the suggestions are the same
whether the scans and the log's lines come in one order or another2. Surface goldens
Surfex.Golden is the one renderer. Surfex.Golden.render/1 takes a spec (a map) and
returns the golden's text.
a golden spec renders to markdown: a header block naming what regenerates it, the prose,
the stats lines, and a table per group of typed cells; one spec always renders one text2.1 Shape
- Header block, three contiguous lines:
# <name>, the one-line purpose, and the attributionGenerated by `mix <task> --write` · gate `<gate>` (HARD|ADVISORY) — do not edit; a drift FAILS the gate.Free prose may follow. - Stats lines, each
**<lead>** · <label> <value> · ….Surfex.Golden.stat/2builds one as data (lead, dimensions and rendered text), so a report can read the counts back without parsing markdown.Surfex.Golden.stat_line/2renders just the text. - Tables. The first column is the catalogued item and the last is conventionally
Locus. A golden is either one flat table or## <heading>groups, each with its own table and optionally its own columns.
a stats line reads **N noun** followed by each label and count, and a header block
names the golden, its purpose, the task that regenerates it and its gate2.2 Cells are typed
A caller never writes notation. Each cell is typed, and the renderer formats it:
| Cell | Renders as |
|---|---|
{:code, text} | `text` |
{:atom, a} | `:a` |
{:locus, file, line} | `file:line`; a nil line gives `file`; a nil file gives — |
{:version, hash} | `hash`; nil gives — |
{:raw, text} | text verbatim |
:absent or nil | — |
| a string | the string verbatim |
Every backticked cell is a GFM code span that reads back as its text, whatever the text
holds. GFM splits a table row on | before it reads a code span, so each | is escaped
as \|. The delimiter is one backtick longer than the longest run of backticks in the
text. One space pads each side when the text starts or ends with a backtick, or starts
and ends with a space without being all spaces. surfex's own goldens build their
composite {:raw, text} cells by the same rule, and escape the | in their free text.
a backticked cell holding a pipe, backtick runs or only backticks, split as a GFM table
row and read back by GFM's code-span rule, gives its text2.3 Determinism
Rendering is invariant under the order of a row map's keys and of the row list. Rows are
sorted by the spec's :sort function, or by default by the first column's natural key
(Surfex.Golden.natural_key/1: alternating text and integer chunks, so Foo.9 sorts
before Foo.10). Two specs differing only in those orderings render identical bytes.
rows come out in natural-key order whatever order they went in, and a golden never
carries a date, a time or a commit3. Reading source
Surfex.SourceScan holds the primitives every scanner of Elixir source shares.
source is read as text and parsed, never compiled: code no compiler would accept is
still read3.1 Finding source
Surfex.SourceScan.project_root/2ascends from a starting directory to the first directory containing the given marker ("mix.exs"for a library), falling back to the starting directory.Surfex.SourceScan.project_root/1starts from the working directory. The marker has no default, because a default would be silently wrong for some layout. The starting directory exists so that nothing has to change the working directory, which belongs to the whole VM, to ask.Surfex.SourceScan.lib_sources/1lists the.exfiles of every first-partylib/under a root, sorted. It serves a library and a multi-project tree alike, with nothing to configure. Alib/is first-party when:- its parent has a
mix.exs, so it is a Mix project's own source, and not alib/that a fixture or scratch tree happens to contain - no directory between the root and it is
deps,_build,test,tmp, or hidden. Dependencies are Mix projects too, and so are some test fixtures, so the first rule alone is not enough.
A looser rule would let the scratch trees a test run leaves behind into a golden, which would then drift on that machine and never in CI.
- its parent has a
a project's root is found from a marker file; lib sources are the .ex files under a lib/
whose parent has a mix.exs, skipping deps, _build, test, tmp and hidden directories3.2 Modules and definitions
Surfex.SourceScan.defmodules/1returns everydefmodulenode in a quoted AST, in source order, nested ones included.Surfex.SourceScan.hidden_module?/1says whether a module declares@moduledoc false.Surfex.SourceScan.line_range/1gives the first and last source lines a node spans, from its metadata (parsed withtoken_metadata: true). Metadata never reaches a hash, so a line range is where something is, never what it is.Surfex.SourceScan.defs/1returns a module's public definitions, from its own body only (a nested module's definitions are that module's):- public means
def,defmacro,defdelegateanddefguard - excluded:
defp,defmacrop,defguardp; anything under@doc false; an@implcallback with no@docof its own; a definition whose name is computed - clauses are grouped by name and arity, and a later clause takes the first clause's visibility
- a default argument declares every arity it generates
- each function carries one hash over what it depends on (§3.3)
- public means
a module's public definitions are listed with name, arity and kind; a default argument
declares each arity; @doc false and implicit @impl callbacks are hidden3.3 Content versions
Surfex.SourceScan.definition_hash/1 is a definition's version: the first 8 hex characters
of a SHA-256 over its AST with all metadata removed. It accepts a quoted node or a
source string, and both give the same result for the same code.
Because metadata is gone, the hash is a function of structure. A blank line, a comment,
or re-indentation above a definition leaves its hash unchanged; changing its body changes
it. A line number reports movement, and this hash reports change. That is what lets a
golden locate items by path and version, not file:line, so two people editing
different items touch different rows.
A public function's version, from Surfex.SourceScan.defs/1, covers what the function
depends on, not only its own clauses. A change made through a helper is a change to the
function:
- its own clauses, with variables renamed by order of first appearance, so renaming a variable is not a change
- every private definition it calls, transitively: direct calls, piped calls
(
x |> fee()isfee/1), local captures (&fee/1), and calls that rely on default arguments. Recursion terminates. - the values assigned to every module attribute it, or one of those callees, reads
Calls to other modules, and to the module's own public functions, are not followed: those have their own rows and their own versions.
A public type's version, from Surfex.SourceScan.types/1, is the hash of its declaration
alone: changing one type changes it and nothing else's.
A version covers definitions, not their documentation. A function's or type's
version leaves out its @doc or @typedoc, so rewriting a docstring dangles nothing, and
surfex doesn't hold a published docstring to the spec: a @doc that states a contract
(its return values, its errors) can drift from the spec without any relation noticing.
Read the two together when either changes. A module's version does cover its
@moduledoc (above). Holding docstrings to the spec is planned with claim-level relations.
A module's version, from Surfex.SourceScan.module_hash/1, covers its public surface,
not its whole body:
- its
@moduledoc - its public definitions, as name, arity and kind
- its
@behaviours anduses - its
defstructordefexceptionfields - its
@type,@opaque,@callbackand@macrocallbackdeclarations
Each list is sorted, so reordering is not a change. Function bodies, private definitions and nested modules are left out: each public function has its own version, so a body edit changes that function's version and not its module's, and a relation to the module doesn't dangle for it.
a definition's version changes with its structure and not with its position or layout;
a function's version follows its private helpers and the attributes they read; a
module's follows its public surface onlyAn item's version covers everything it runs that isn't an item of its own. A module
hides some of its public definitions from its docs, @doc false ones and @impl
callbacks with no @doc (§3.2), so they aren't items and have no version of their own.
The Elixir scanner (§5.2) folds them into the versions of what runs them, so changing one
dangles the relations that depend on it:
- A module's version covers its hidden definitions, with the private definitions they
reach and the attributes they read: a GenServer's
handle_call/3is part of its module's version. A module with no hidden definition keeps exactly the versionSurfex.SourceScan.module_hash/1gives. - A public function's version covers the hidden definitions it runs: those it calls
locally, and, when it dispatches through its own behaviour, the module's hidden
@implcallbacks. A function dispatches when it, or a private definition it reaches, calls a function of a module this oneuses or declares as its@behaviour, an Elixir alias or an Erlang module (GenServer.call/3,GenServer.start_link/3,:gen_statem.call/2). So a GenServer'scommit/3, which callsGenServer.call, covers itshandle_call/3, andstart_link/1covers itsinit/1. It is read from source and errs towards covering more, never less: a call to another server through the same behaviour counts too. Callbacks are known by their@impl. - A documented callback is an item with its own version, so it isn't folded into anything; its own relations dangle when it changes.
A hidden module's code is versioned in what runs it, wherever that is. A module under
@moduledoc false has no items (§5.2), so none of its definitions, shown or hidden, has a
version of its own. Each item's version also covers the hidden modules' code it runs: a
function's, the code it calls, or that the hidden definitions it covers call; a module's,
the code its hidden definitions call. It follows that code in any module and any file,
transitively, through the private definitions it reaches and the attributes they read.
It stops at code an item versions: a public definition has its own version, and a shown
module's hidden definition is in its module's. So changing a helper module a Mix task's
run/1 hands its work to dangles the task's relations, and changing the public function
the helper calls dangles only that function's. Code nothing public runs is in no version.
An item running no hidden module's code keeps the version above.
Surfex.SourceScan.module_hash/1 and Surfex.SourceScan.defs/1 are unchanged by this:
they are library functions a project's own goldens use, and the scanner composes the item
versions from them.
changing a hidden callback, or a private helper it reaches, changes its module's version
and that of each public function that dispatches through the module's behaviour or calls
it, and no other function's; a @doc false definition is part of its module's version; a
module with no hidden definition keeps its surface versionchanging a hidden module's function, a private helper it reaches, or a hidden module it
calls changes the version of each public function and module running it, in any file,
and no other; changing a public function it calls changes that function's alone; a
hidden module has no items, and a module nested in it is scanned4. Items
Surfex.Item is one thing a scanner found: the unit a citation resolves to, and the
source of a code record (§11).
| Field | Meaning |
|---|---|
kind | the scanner's category (:module, :function, :wire_field, …) |
name | unique within its parent, or globally when it has none |
file | where it is declared, relative to the scanned root |
hash | its content version (§3.3) |
value, detail | a declared value, or scanner-specific extra, shown in the Value column |
parent | the enclosing item's key, for a member |
type | the key of the item a member is an instance of (a field holding a struct) |
aliases | other names that cite it (§6.4) |
lines | its first and last line in file, when the scanner knows them: never part of its key or its hash |
shape | true for an item with no behaviour of its own (a type): no test run exercises it, so an implements relation to it is validated by judgement (§18). The scanner says so; surfex never infers it from a kind |
calls | the keys of the code it calls, as far as its source says (§5.2). A fact about the source, never part of its key or its hash. Empty when the scanner doesn't know: the item is then reached only by a test calling it (§13.1) |
hidden | code the item covers that isn't an item of its own (a module's hidden definitions, §3.3), keyed as a call names it (Mix.Tasks.Foo.run/1), each with its calls. Its version is in the item's; a call naming it reaches the item and goes on through its calls (§13.1). Empty for most items |
runs | the hidden modules' code the item runs (§3.3), keyed as a call names it, each with its calls. It is no item's own: its version is in each item running it, and each carries the same entry. A call naming it reaches no item and goes on through its calls (§13.1). Empty for most items |
dispatch | on a module, the dispatch functions declared in it (§11.10), each with what its table lists: %{targets, unscanned}. Never part of its key or its hash. Empty for every other item |
Surfex.Item.key/1 is an item's identity: parent.name for a member, otherwise name.
The hash is never part of the key, so an edit changes a row's version and not its
identity.
an item's key is its name, or parent.name when it has a parent4.1 DSL items
In a project built with a DSL, much of the behaviour a spec describes is declared in
blocks inside a module (an action, a policy, a calculation) rather than written as defs,
so no Elixir item names it. Such a block is a DSL item: a Surfex.Item like any other,
given by a project scanner for the DSL beside the Elixir scanner's items (§5.3). Surfex
depends on no DSL: the project, or a companion package, writes that scanner with the
supported library API (§23). The contract fixes what it fills, so citations, relations,
reach, evidence and the triangle work for DSL items unchanged:
| Field | A DSL item's |
|---|---|
kind | the DSL's own (:action, :policy, :calculation, …), read only for grouping and a profile's rules |
parent | the module that declares the block |
name | the DSL's name for the block, so its key is Module.name (§4). It is unique within the module and stable across edits that rename nothing: a block the DSL leaves unnamed takes a name from its own text (what it applies to), never from its position, or inserting a block renames its siblings |
file, lines | the file declaring it, and the block's first and last line |
hash | Surfex.SourceScan.definition_hash/1 over the block's AST, so a change to the block changes its version and no sibling's, and moving it changes neither |
calls | the keys of the functions and modules the block references (the changes, validations and preparations it names), so reach goes on from the block into them (§13.1). A call that drives the block, through the DSL's entry or a function it generates, is the scanner's to resolve to the block's key (§5.4) |
shape | true for a pure declaration with no behaviour of its own, such as an attribute |
hidden, runs | empty. A DSL item carries no hidden definitions and runs no hidden code (§3.3): its block is its whole definition and its hash covers it, and the code it uses has items of its own, reached through calls |
A key written as Elixir names a function without its arity (MyApp.Shop.Order.checkout)
is read under the Elixir scanner's profile (§5.2) with no profile keys of its own, and a
citation of it resolves as any item's key does (§6.4), so a subject section and a
contract fence can name a DSL item too. A key is an identity: a block named as a function
of its module (checkout beside def checkout/1) makes a citation of the name ambiguous,
and a key another scanner also gives is an error (§5.3).
a fixture DSL's scanner gives one item per block, keyed Module.name, with its kind, parent,
file and lines and no hidden code; its hash changes with the block, not with a sibling or
the layout; an action lists what its block references; a declaration is a shape; beside the
Elixir scanner its key is a code record and a citation of it resolves5. Scanners
5.1 The contract
Surfex.Scanner is the behaviour a scanner implements: items(root, opts) returns every
item under root. A scanner reads source and never compiles or loads it. The contract
is language-agnostic: a project whose code is in C implements it for C, and the rest is
unchanged.
a project scanner implements Surfex.Scanner, and a module that doesn't is refused,
named5.2 The built-in Elixir scanner
Surfex.Scanner.Elixir implements it for Elixir.
Surfex.Scanner.Elixir.items/2 (and Surfex.Scanner.Elixir.items/1, with default
options) returns:
| Item | Kind | Key | Aliases |
|---|---|---|---|
| a module | :module | MyApp.Cart | none |
| a public function (§3.2) | :function | MyApp.Cart.add/2 | MyApp.Cart.add |
| a public macro or guard | :macro | MyApp.Cart.is_cart/1 | MyApp.Cart.is_cart |
a public type (@type, @opaque) | :type | t:MyApp.Cart.t/0 | MyApp.Cart.t |
- A module under
@moduledoc falseis no item, and neither are its functions. A module nested in it is, since Elixir documents it separately. Its code is hidden code: each item running it lists it inruns(§4),Module.fun/aritywith its calls, and has it in its version (§3.3). A test importing it is resolved against the definitions listed so, as against a scanned module's (§11). - A type is keyed as ExDoc writes it,
t:first, so a function of the same name keeps its own key; the alias it shares with that function cites both, as a family. A@typep, and a type under@typedoc false, is not public. A type is an item like any other: a project whoserequire:asks code for a relation relates its types, or excuses them by a class (classes: [{"type", reason}],rules: [%{class: "type", kinds: [:type]}]). A type is a shape (§4): a spec section citing it relates to it by judgement (§18). - A nested module is keyed by its full name, as Elixir names it.
- A function or macro lists what it calls (
calls): the functions its clauses and the private helpers they reach call, asModule.fun/arity, and every module they name, as a test's calls are read (§11). A local call to one of the module's own public definitions isModule.fun/arity, and adefdelegatecalls its target. Every arity of a function with default arguments lists the same calls. A module and a type list none. - A hidden definition is listed where it is called and kept on its module (§3.3). A
local call to one is
Module.fun/arity, like a call to a public definition, and a function dispatching through its own behaviour calls the module's hidden callbacks. The module item'shidden(§4) lists each hidden definition,Module.fun/arity, with its calls, so reach goes through it. - The
:pathsoption (globs relative to the root) defaults tolib/**/*.ex: one project's own source. A poncho lists its members.
Surfex.Scanner.Elixir.profile_defaults/1 gives the profile settings reading an Elixir
project's citations starts from, for its roots: namespace:, and every top-level
module the code scanner found (a Phoenix app's MyAppWeb beside MyApp), since the code
says what the project's names are (Surfex.Status.Config.profile!/3):
shape: the roots' module and function names, with or without an aritytoken: the same names, found inside longer text. A name ends where a name ends: a root glued to more letters (MyAppCollector), a hyphen (MyApp-Profile) or a further segment that isn't a name is another word, not a citation of the root- a
normaliserule that drops a call's arguments, soMyApp.Cart.add(cart, item)namesMyApp.Cart.add, and one that reads ExDoc'st:MyApp.Cart.t/0asMyApp.Cart.t: a type cited asMyApp.Cart.t()ort:MyApp.Cart.t/0resolves through its alias
A well-formed name under any root that resolves to nothing is an unresolved citation
(§6), so a stale name in the spec is reported rather than matched to a shorter one. A
name under no root (another library's, Ecto.Changeset) is not the project's to cite
unless known_external: says so.
a token ends at a name's end, so a root glued to letters, a hyphen or a non-name segment
is no citation of it; every top-level module the code has is a root, so a stale name
under any root is unresolved rather than suggested against the root modulethe Elixir scanner reports every public module, definition and type under the paths it
is given, with nested modules named in full, and profile defaults for a namespacean item running a hidden module's code lists it with its calls; a call naming it reaches
what it calls and no record for it, through other hidden modules, and a cycle endsa function lists the code it calls: remote, aliased, piped and captured calls, through
its private helpers, a local call to the module's public function, and a delegate's
target; every default arity lists the same calls; a module lists nonea public type is cited as `Mod.t()` or `t:Mod.t/0` and resolves when declared; one not
declared, or private, is unresolved; a function of the same name keeps its own key5.3 Several scanners
A project may have more than one kind of declaration worth an item: Elixir functions, and
the blocks a DSL declares inside its modules. scanners: in .surfex.exs lists the code
scanners, and their items are combined. Each entry is :elixir (the built-in scanner,
§5.2), a module implementing Surfex.Scanner, or either with its options,
{scanner, opts}:
scanners: [:elixir, {MyApp.Surfex.DslScanner, paths: ["lib/**/*.ex"]}]- Each item keeps what its scanner gave it: its
file,lines,hashandkind. Surfex readskindonly for grouping and a profile's rules (§4), so two scanners' kinds need not know about each other. scanner:withscanner_opts:stays valid and is the one-element list[{scanner, scanner_opts}]. With neither, the list is[:elixir]. Giving bothscanner:andscanners:is refused, as are an empty list, an entry of any other shape, a scanner named twice, andscanner_opts:besidescanners:.- Two scanners giving one key is an error naming both and the key. A key is an item's identity (§4), and two scanners cannot both own it. Within one scanner, items sharing a key are told apart by kind, as before (§11).
- A task compiles the project before reading the code when any scanner is the project's
own, and reads citations under the Elixir scanner's profile defaults (§5.2) when
:elixiris among them.
Surfex.Status.Config.scanners!/1 reads the list, and Surfex.Status.Config.items/2 runs
it. Surfex.Status.Config.project_scanner?/1 and Surfex.Status.Config.elixir_scanner?/1
say whether the project's own scanner, or the Elixir one, is among them.
the items of several scanners are combined, each keeping its scanner's file, lines, hash
and kind; scanner: is a one-element list; both keys, a malformed list or a scanner named
twice is refused; one key from two scanners raises naming both5.4 Calls a scanner resolves
A DSL item has no function name a call could carry: code reaches it through the DSL's
entry functions, naming the module and the block as arguments
(MyDsl.run(MyApp.Shop.Order, :checkout, input)), or through functions the DSL generates
for it. A call's id is Module.fun/arity (§11), so without help a test driving an action
reaches the entry and never the action. Surfex.Scanner has an optional callback for it:
@callback calls(call :: Macro.t(), aliases :: %{atom => String.t()}, opts :: keyword) ::
[item_key :: String.t()]- The test scanner (§11) and the Elixir scanner (§5.2) ask each of the project's scanners
that implements it (§5.3) about every remote call they read, and add the keys it
answers to the caller's
calls, beside the call's ownModule.fun/arity. A scanner without it is never asked, so it changes nothing. callis the call's AST as written, except that a piped call has its piped value as its first argument, as it runs. A capture (&MyDsl.run/3) calls nothing and is not asked about.aliasesmaps each alias in scope at the call to the module it names, so the scanner resolves the module argument itself;optsis its own configuration.- An answer is a list of item keys. A key no item has reaches nothing, as any call to unscanned code does. An answer of any other shape raises, naming the scanner.
So a test that drives an action reaches the action item, and through the item's own
calls the code its block references (§13.1). Reach, evidence (§17), the triangle and the
suggestions (§15) then follow for DSL items with no special case.
Surfex.Status.Config.call_scanners/1 gives the scanners asked. The test scanner takes
them as Surfex.Scan.ExUnit.records/4 and Surfex.Scan.ExUnit.tests/4 do, and
Surfex.Status.Config.items/2 hands them to Surfex.Scanner.Elixir.items/2 (its
:call_scanners option); Surfex.ExUnitFormatter asks them when it records a run.
a test's call through a DSL's entry, piped or not, names the action a scanner's calls/3
answers, and a capture names none; a function's call does too; reach goes on through the
action's calls; a run records the action's version; the triangle closes for a claim
related to it, and doesn't without calls/3; a malformed answer raises naming the scanner6. Citations
Surfex.Cite finds a specification's citations of the code and resolves them.
Surfex.Cite.citations/3 returns every citation in the profile's sources, sorted by
file, line and span.
each citation ends in exactly one status: resolved, ambiguous, unresolved, external or
a documented absence6.1 No new notation
A specification already cites code in prose: a code span naming a function, a struct, a constant or a file. Those spans are the machine-readable form. A span is a citation when it resolves. A span that does not resolve but has the profile's shape (a claim about the code) is a broken citation. Anything else in backticks is prose.
6.2 Statuses
Every citation has exactly one status:
| Status | Meaning |
|---|---|
:resolved | it names one item, or one family (§6.4), or one item per step of a member path (§6.5) |
:ambiguous | it names several unrelated items. Reported, never guessed |
:unresolved | it has the shape of a claim about the code and names nothing |
:external | it names something real outside the scanned tree (known_external) |
:documented_absence | a section names it because the code lacks it (documented_absences) |
6.3 What is read
Surfex.Cite.sources/2lists the source files: the profile's globs under a root, minus its exclusions, de-duplicated and sorted.- Code spans, line by line, credited to the nearest heading above them (a markdown
heading, or the
defmodulein an.exsource), or(preamble)before the first. A heading's anchor ({#id}, §11) is not part of its name. - Subject headings (§6.6) cite the items their section is about.
- Table cells under the profile's
table_columns, backticked or not. A cell holding a code span is already cited as one. - Fenced code blocks: shape-matching tokens are credited to
(code block). Inside a fence nothing else is read, so a# commentin a code block is not a heading.
a citation is credited to the nearest heading above it (or the defmodule in an .ex
source); inside a fence nothing but shaped tokens is read; sources are globbed, excluded
and sorted6.4 Resolution
A span is normalised (trimmed, then the profile's rewrites in order) and then resolved by the first rule that applies:
- a documented absence for this name in this file
- a known external name
- a member of a subject of an enclosing section (§6.6)
- an item key (
Surfex.Cite.index/2): one item resolves, several are ambiguous - an alias: every item sharing it is cited together as a family
- a member path (§6.5)
- the profile's shape: unresolved
- otherwise, the tokens inside the span that have the shape, or the
known_shapeand resolve, each cited in its own right
An alias differs from a shared key. Two items sharing a key are two unrelated things with
one name, so the citation is ambiguous. Items sharing an alias are a family on purpose:
citing MyApp.Cart.add means the function, whatever its arity.
A member is reached through its parent (struct.field), a subject, or a member path,
never by its bare name: a bare offset naming three structs' fields is not a claim
about any one of them.
a span resolves through absences, externals, subject members, item keys, aliases and
member paths, in that order; a bare member name never resolves on its own6.5 Member paths
field.member resolves the longest prefix that names an item, then each further step as
a member of the previous item's type, or of the item itself when it has no type. Every
step is cited. A step that does not name exactly one item makes the path fall through
to the shape rule.
6.6 Subject sections
A profile subject (a file, a heading pattern, item keys) declares that a section is about those items. Its heading cites them. A bare member name anywhere in the section, until the next heading of the same or higher level, resolves as that item's member, and inside such a section the member wins over a global of the same name. A subject key that names nothing makes the heading's citation unresolved. A contract fence (§11.1) in a subject section is about the subject's one item where nothing nearer names a module (§11.9).
7. Coverage
Surfex.Coverage.verdict/3 adjudicates an item nothing describes: is the silence
expected, or is it a gap? The relation log asks it when suggesting an excuses relation
(§15) and when judging whether an excuse is stale (§13). Given the set of item keys that
count as cited (described), the verdict is:
:citedwhen the item's key is in it- a gap for a
never_excusedkind that is not cited, whatever the rules say - otherwise
{:expected, class}for the first rule that matches (kinds, an optional name pattern, and optionally "only while its parent is cited") - otherwise a gap
Rules are by class, never by item. A new helper falls into its class and stays quiet,
while a new entry point matches no rule and is a gap. Each class carries its reason, so
silence is always attributable. Surfex.Coverage.verdict/3 reads only the rules and the
never_excused kinds: a whole Surfex.Profile, or the map Surfex.Profile.coverage!/1
gives.
A verdict belongs to an item, not to a key. Two items may share a key (a citation of it is then ambiguous), and each is judged on its own.
an item is cited, excused by the first rule that matches, or a gap; a never-excused
kind is a gap whatever the rules say; the rules can come from a profile or its coverage
map8. Profiles
Surfex.Profile is everything project-specific about reading a spec's citations and
excusing code, as data with no functions. Surfex.Profile.new!/1 builds one and validates
it, and Surfex.Profile.keys/0 lists its keys:
| Key | Meaning |
|---|---|
sources (required) | globs of the files whose prose cites the code |
exclude | path prefixes to drop from them: an excluded file is no part of the spec, contributing neither sections nor citations (Surfex.Scan.Markdown.files/3 is the one answer to which files are the spec) |
shape (required) | regex: what a claim about the code looks like |
token | regex whose first group finds names inside longer text |
known_shape | regex: tokens trusted only when they resolve |
normalise | {regex, replacement} rewrites of a span |
subjects | %{file, heading, items} subject sections (§6.6) |
table_columns | header names whose cells are citations |
file_targets | file names citable in their own right; :item_files adds every item's file |
known_external | %{name => reason} |
documented_absences | %{{name, file} => reason} |
classes | [{class, reason}] |
rules | %{class, kinds, name, parent_cited, parent} excusal rules; parent is a regex on a member's parent module (an item with no parent, such as a module, never matches it), so a module family's members are excused by family |
never_excused | kinds that are the spec's subject matter |
It raises, naming the key, on an unknown key, a missing required key, a wrong type, a rule naming an undeclared class, and a rule that would excuse a never-excused kind.
A project writes these keys in .surfex.exs, alongside the relation log's own (§13.2).
For the built-in Elixir scanner, shape, token and normalise default from the
project's namespace (§5.2).
a file under exclude: contributes neither spec sections nor citations; excluding every
file is an empty spec, which is an errora rule's parent: excuses the members of a module family and no other module's members of
the same name; it never matches an item with no parent; it composes with the rule's other
keys, must be a regex, and a rule without it keeps its class's versiona profile rejects an unknown key, a missing required key, a wrong type, and a rule that
names an unknown class or excuses a never-excused kind9. The v0.2 trace, removed
Versions 0.2 and 0.3 had a trace: SPEC_TRACE.md, rendered from the spec's citations,
giving every item a verdict (cited, excused by a class, or GAP). It was removed in 0.4.0.
It could show that the spec and the code cite each other, but not that anyone reconciled
them after either changed: regenerating the golden cleared its signal. The relation log
records that reconciliation, and each of the trace's checks lives on:
| The trace | The relation log |
|---|---|
| a citation | a suggested implements relation (§15) |
| cited | a current relation (§13) |
| excused by a class | an excuses relation (§11, §15) |
| GAP | an unmet id under require: (§13.1) |
| an unresolved or ambiguous citation | a broken citation, failing the check (§13.1) |
| drift since the golden | a dangling relation, naming the side that changed |
A .surfex.exs that still has a trace-only key (output, purpose, task, gate,
hardness, item_noun, columns, groups, locus_prefix, not_catalogued, prose,
require_citation, file_labels) raises, naming the keys and why (§13.2). So does a
:trace entry in goldens:.
10. Gating goldens
10.1 The gate
Surfex.Gate is the write-or-check step every golden shares:
Surfex.Gate.run/4writes a golden, or checks the committed file against a fresh render. It returns the failures, one message each: a missing file, or a drift naming what regenerates it. It returns none when the file is current or was just written.Surfex.Gate.drift/2compares committed bytes with a fresh render. Identical texts givenil. Otherwise it names the rows that changed, appeared and went. A table is told apart by its##heading and its header row. A row is keyed by the cells that identify it, read from the table: itsItemcolumn when that names every row once on both sides, else the fewest leading columns that do, else the whole row with its occurrence. Rows are compared whole, and a changed row names each cell that differs with its old and new text,State :current → :retired. Cells are split as GFM splits them, on each|not escaped as\|. A change outside every row is reported as a change to prose or a stats line. The committed side is parsed from its bytes, never re-derived.Surfex.Gate.config!/1evaluates a.surfex.exs-style data file, which must return a keyword list.
the gate writes a golden or checks it: a missing file and a drift fail, naming the rows
that changed, appeared or went, or that only the prose changeda row is keyed by the cells that identify it, so rows sharing a first cell stay apart; a
hand-edited cell is named under Changed with its old and new text, and an escaped `\|`
stays inside its cell10.2 Project goldens
Surfex.Surface is the behaviour for a golden only the project can scan for (the
endpoints a router declares, the actions a model defines): spec(opts) returns a
Surfex.Golden spec, which is rendered and gated like any golden.
a module implementing Surfex.Surface is rendered and gated like any golden, and a module
that doesn't is named10.3 Every golden at once
Surfex.Goldens runs every golden a project lists in .surfex.exs's goldens: key.
Each entry is one of:
:statusor{:status, output}: the relation status as a golden (§13.3),RELATIONS.mdby default. Its drift fails here; whether the relations are healthy ismix surfex.status's question.:completenessor{:completeness, output}: the completeness report as a golden (§20),COMPLETENESS.mdby default. Its drift fails here; whether the project is complete enough ismix surfex.status's question, undercompleteness: [min: N].{output, module, opts}: aSurfex.Surface
With no key, the list is [:status].
Surfex.Goldens.entries!/1validates the list. It raises naming an entry that is neither form, the removed:traceentry (§9), an empty list, and two entries writing one file.Surfex.Goldens.needs_compile?/2: only project code needs the project compiled, meaning a surface module, or a project scanner the status reads.Surfex.Goldens.run/6renders every entry, writes or checks each, and returns every drift in entry order. A module that does not implementSurfex.Surfaceraises, naming it.
goldens: defaults to [:status]; each entry is validated, :trace is refused with its
reason, and every golden's drift is reported in one run10.4 The task
Mix.Tasks.Surfex.Goldens is mix surfex.goldens:
- Without
--write, the task checks. It fails if any golden is missing or has drifted. --writeregenerates. It writes every golden, drifted or not, so a drift shows up in the diff, and succeeds, saying how many it wrote. Regenerating is how a drift is resolved, so it never fails for one; checking is what fails.--config PATHreads a definition other than.surfex.exs.- The namespace citations are read under defaults to the app's name camelized. The task
compiles the project only when
Surfex.Goldens.needs_compile?/2says so. - Every failure of every golden is reported in one run.
Surfex gates itself this way: CI runs mix surfex.goldens, which gates RELATIONS.md,
and, after its tests, mix surfex.status --verify --evidence --validated, which checks
the relation log itself (§13.3, §17): every relation holds, every confirmation by evidence
is borne out, and every relation was validated by the process (§18). Surfex's own
triangle is closed and gated (triangle: :fail): every section something implements
states its claims as test hints, each verified by a tagged test that exercises the
implementing code.
mix surfex.goldens checks every golden and fails on a drift; --write regenerates them
all, even when some have drifted, and succeeds11. Scan records
Sections 11 onwards describe the relation log: pure scanners report what the spec, the tests and the code are now, and an append-only log records which versions of them someone has confirmed belong together.
Surfex.Scan is a scan record: {kind, id, hash, location}, plus role and within
for spec records.
- kind:
:specor:code - id: the identity: a code item's key (
MyApp.Cart.add/2), or a spec unit's file and name (spec.md#Carts/Adding items,spec.md#cart-add) - hash: the content version, 8 hex characters
- location: the file and first and last lines
- role: what part of the source a record is: for spec,
:section,:blockor:test_hint(below); for code, the item's kind (:module,:function, …);nilfor tests and classes - within: what it sits in: the section or block of a block or hint, or the parent of
a code item (a function's module);
nilwhen nothing encloses it. It is a fact about the source, not a relation.
Scanners only ever produce scan records. They never read or write relations, and a record is a pure function of the source. Location is never part of a relation: moving code, or adding text above a section, changes where something is and not what it is.
Surfex.Scan.code/1 makes code records from any scanner's items (§5), with the item's
key, version (§3.3) and line range, its kind as role, its parent as within and its
calls.
The id is the item's key. When two items share a key (a C function and a constant both
called twin), each id carries its kind, twin (function) and twin (const), so each is
a relation end of its own. Surfex.Scan.for_item/2 finds an item's record whatever its
id. A citation of a shared key stays ambiguous (§6.2). Surfex.Scan.definition/1 is
what a code record is a version of: its file, lines and version. A function with default
arguments is one definition reported as several items (f/1, f/2), and they share it. Surfex.Status.derive/4 raises on
two records with one kind and id, naming both locations: no record may shadow another.
Surfex.Scan.ExUnit is the test scanner. It makes one record of kind :test per ExUnit
test, and per ExUnitProperties property, read from source without compiling it.
Surfex.Scan.ExUnit.records/2 reads every file matching a list of globs under a root,
and Surfex.Scan.ExUnit.tests/2 scans one text. A property is a test in everything
below: its id, version, tags and calls, and its runs are evidence (§17), found by its
lines as a test's are. Its check all generators are part of its body, so weakening one
changes its version.
- Its id is the module, the
describeif any, and the test's name:MyApp.CartTest: adding: rejects a closed cart. A test defined in a comprehension keeps its name as written (counts #{name}), since the names it generates are only known when compiled. Two tests with one id are told apart as~2,~3. - Its hash is a function's version (§3.3): its body, the private helpers it calls
(transitively) and the module attributes they read, with variables normalised. Every
setupthat applies to it (the module's and itsdescribe's, including named callbacks) and the generators of a comprehension that defines it are also part of it. A private helper defined inside adescribeblock counts like one at the module's top level, as ExUnit compiles it there. Weakening a test through a helper, a setup or its table of cases changes its version. - Its location is the file and the test's lines.
- What it declares: a test says what it verifies with ExUnit's own tags.
@tag verifies: "id"(or a list) applies to the next test,@describetagto adescribe, and@moduletagto a module. Each becomes{:verifies, id}in the record'sdeclares. They combine as ExUnit merges tags: the most specific level that setsverifiesreplaces the others, a test's own@tagover itsdescribe's over its module's, and a tag that doesn't setverifiesleaves it as it was. A test that also verifies its module's requirement names it again in its own list. Since a tag is ordinary ExUnit,mix test --only verifies:idruns the tests whose tag is that id, and surfex records each of them as declaring it. A list is where the two part. ExUnit compares a list tag as one string, its ids run together, so--onlyselects a test tagged["a", "b"]by neitheranorb, while surfex reads both. A test that verifies two requirements is either recorded against both (a list) or selectable by--only(one id), not both. The record'sunselectablelists the declared ids its runner can't select it by: for a list, each id that isn't the whole list run together (so a one-id list is selectable by its id); for a string, none. Status reports them (§13.1). Averifiesvalue that isn't a literal string or list of strings raises, naming the file and line.
a test's record lists the declared ids mix test --only can't select it by: every id of a
list of several, none of a string or a one-id list, whichever level's tag it took- What it deviates from: a test whose requirement the code is known not to meet yet
says so with
@tag deviation: "#119", beside the ExUnitskip:that keeps the suite green. The value is a reference to what will fix the code, free text: a project's tracker is its own business. It is read at the same levels asverifiesand merged the same way, key by key: a test's own@tagover itsdescribe's over its module's, so a tag setting onlyverifiesleaves an inheriteddeviationas it was. The record'sdeviationis the reference, ornil. It names no unit itself: the test deviates from what it declares it verifies, and status lists those units as deviating (§13.1). Adeviationvalue that isn't a literal string raises, naming the file and line.
a test's deviation: tag is read as its reference at its own, its describe's or its
module's level, the most specific level that sets it winning, independently of verifies;
a value that isn't a literal string raisesmix surfex.test UNIT… runs a requirement's tests as surfex reads them, list members
included. Each UNIT is any reference a declaration may use, resolved as a declaration is
(Surfex.Scan.resolve/2). The tests are every test whose declarations resolve to one of
them. With --within, they also include every test declaring a unit inside one: a block,
hint or row whose within chain reaches it. The task runs mix test in a child process
with each test's file and first line (test/cart_test.exs:12), which ExUnit's location
filter selects exactly, then any arguments after --. It exits with that run's status,
and the run's evidence is recorded as any run's is (§17). A test defined in a
comprehension shares its line with its siblings, so they all run. A unit that is unknown
or ambiguous, or that no test declares, is an error before anything runs: running nothing
would pass in silence. --list prints each selected test's id and location, and runs
nothing. Surfex.Select.tests/3 makes the selection and Surfex.Select.args/1 the
locations, both pure functions of the scans.
mix surfex.test --deviations runs every test that declares a deviation (below,
Surfex.Select.deviations/1, a pure function of the scans) in place of a unit's tests,
skips included. A deviation test is skipped beside its tag, and the run must include it
for its evidence to say whether it passes, which is how a stale deviation is found (§17).
A project checks its deviations on demand, or in a scheduled job. ExUnit includes a
skipped test only by a filter on its skip reason, and a location given as FILE:LINE
replaces every include, so the run names each test's files, --only location:FILE:LINE
for each test and --include skip:REASON for each reason among them
(Surfex.Select.deviation_args/1). The test record's skip is that reason, read like
verifies: a literal string, "true" for @tag :skip, or unreadable when computed. A
reason filter would also run any other test skipped for the same reason, so another test
in their files skipped for a deviation test's reason is an error naming both, as is a
deviation test whose reason isn't a literal: either way the run wouldn't be exactly the
deviation tests. It takes no unit (naming one is an error), and no test declaring a
deviation is an error before anything runs. --list and -- work as for a unit.
mix surfex.test --deviations runs exactly the tests declaring a deviation, with their skips
included, so a skipped one runs and a test skipped for another reason doesn't; --list runs
nothing; a unit with it, no deviation test, another test skipped for a deviation test's
reason, or a reason that isn't a literal, is an error before anything runsmix surfex.test selects the tests declaring a unit, a list's members included, and with
--within the tests of the units inside it; it runs them by location, refuses an unknown,
ambiguous or undeclared unit, and --list runs nothing- What it calls: the record's
callslists the functions the test's body and the private helpers it reaches call, asModule.fun/arity, and every module it calls or names as a value (a module handed to a helper is exercised through it). Aliases are resolved where they're declared, as the compiler sees them: a module's apply throughout it, adescribe's to its tests and the helpers defined in it (not to another describe), and one in a test or helper body to that body, from its line on. A piped call counts the piped value, and a capture (&Mod.fun/2) is a call. A local call the module doesn't define comes from one of its imports,import Aorimport Base.{A, B}(each member), whoseonly:orexcept:admits it. Given the project's code records (Surfex.Scan.ExUnit.records/3), a call through an import of a scanned module is a call to the function that module defines, and names the module too, as a qualified call does; one it doesn't define (ExUnit'sassert) comes from elsewhere and isn't listed under it. Through a module that isn't scanned, it is listed as that module'sModule.fun/arity, which reaches nothing. Setups are left out: they prepare a test, and what a test tests is what it calls itself. These are facts about the source; which of them are code the project has is forSurfex.Suggestto find (§15).
a local call resolves through an import in either form, A or Base.{A, B}, as its only:
or except: admits it; through a scanned module only to a function it defines, naming the
module too, and through one not scanned to the candidate; a function captured in a list
is a calla property is scanned as a test: its id, its declarations, what it calls, and a version
that changes with its generatorsSurfex.Scan.Classes is the class scanner. Surfex.Scan.Classes.records/1 makes one
record of kind :class per class in .surfex.exs. A class names a kind of code the spec
deliberately doesn't describe, and says why (classes: [{name, reason}]). Its rules say
which items fall into it (rules: [%{class:, kinds:, name:, parent_cited:}]). These are
the profile's keys (§8), validated the same way by Surfex.Profile.coverage!/1.
- Its id is the class's name.
- Its hash covers its reason and every rule naming it (kinds, name pattern,
parent_cited), in any order. Rewording the reason or changing a rule changes it. - Its location is
.surfex.exs, with no lines, since the config is evaluated data.
It is pure: a function of the config alone. Surfex.Status.Config.scans/2 includes class
records when classes: is present.
A declaration is a claim in the source, not a relation. Surfex.Scan.resolve/2 finds
the spec unit it names: a full id (spec.md#cart-add, spec.md#Carts/Adding items), or
a bare anchor, block or hint id (cart-add) looked up across every spec file. A bare
id never names a heading path. A §-id may be cited with its sign: §Api.3.2 finds
Api.3.2. It gives {:error, :unknown} when nothing has the id,
and {:error, {:ambiguous, ids}} when more than one file does.
Where many references resolve against one scan (every test's declarations, in a status),
Surfex.Scan.resolver/1 indexes the spec units once, by full id and by bare id (a
Surfex.Scan.Resolver, built by Surfex.Scan.Resolver.new/1), and
Surfex.Scan.resolve/2 takes that resolver in place of the scans
(Surfex.Scan.Resolver.resolve/2): each reference is then a lookup, not a pass over every
record, and resolves exactly as it would against the scans.
a resolver built once from the scans resolves every reference as the scans do: full and
bare ids, a §-id with its sign, unknown and ambiguous references, and never a heading path
by its bare nameSurfex.Scan.Markdown is the spec scanner for markdown. Surfex.Scan.Markdown.records/2
reads every file matching a list of globs under a root (Surfex.Scan.Markdown.records/4
leaves out those under an exclude: prefix, §8, and reads headings by heading_ids:);
Surfex.Scan.Markdown.sections/2 scans one text. Both give every unit (sections, marked blocks and test hints) in the
order they start.
A section is a heading and the lines under it, up to the next heading of any level. Subsections are sections of their own, so editing one changes only its own version. Text before the first heading is the section
(preamble), when it has any.Its id is the one its author wrote, when there is one, and never comes from where the section stands. The first of these that the heading has:
- An id comment,
<!-- surfex: cart-add -->, on the line after the heading or at the end of it (## Adding items <!-- surfex: cart-add -->): the id isspec.md#cart-add. On the line after, the comment is also a block's opening marker, and it opens a block, as it always has, when a closing<!-- /surfex -->comes before the next marker, heading or the end of the file. Otherwise it is the heading's id. A heading with an id on both lines is an error. - An anchor,
{#cart-add}at the end of the heading, does the same. - The project's heading grammar,
heading_ids:in.surfex.exs, for ids the authors already write into their headings::section_signreads a leading§ID(## §Api.3.2 — Limitsisspec.md#Api.3.2),:section_numbera leading1.,1.2or1.2.3, and a regex with a named captureidanything else. A section number's last part may carry a lowercase letter when its dot follows:## 2a. Model checkingisspec.md#2a, while## 3d renderingis no number. A spec that writes ids in more than one form lists its grammars,heading_ids: [~r/^\d+\. (?<id>D-\d+)/, :section_number], and the first that matches a heading reads it. A heading none matches falls through. - The path of headings down to it,
spec.md#Carts/Adding items. Two sections with the same path in one file are told apart as~2,~3, in order. Only a path is ever numbered: a written id that repeats is an error.
An id that was written survives renaming the heading or any heading above it. The comment and the anchor are not part of the heading's text, so subsections' paths don't include them; a grammar's id is, since it is the text the author wrote.
- An id comment,
Its hash covers its own body, not its heading, with runs of whitespace collapsed. Rewording the body changes it. Renaming the heading, reflowing a paragraph or adding blank lines doesn't, so a renamed section keeps its version under a new id, which is what lets a tool recognise the move.
Its location runs from the heading's line to its last non-blank line.
A line inside a fenced code block is never a heading; the block is body, and counts in the section's version. Fences follow CommonMark. A fence opens on three or more backticks or tildes, indented at most three spaces (a backtick fence whose info string holds a backtick doesn't open). It closes only on a line of the same character, at least as long, with nothing after it but whitespace, so a longer fence can quote a shorter one. A fence that never closes runs to the end of the file.
Finer units sit inside a section. Each is a record of its own, with its own version, and is left out of the version of what it sits in, so editing one changes only its own:
A marked block is one requirement: the lines between an opening marker, the HTML comment
<!-- surfex: ID -->, and a closing one,<!-- /surfex -->, each on a line of its own. Its id isfile#ID, its role:block, and it sits in its section. The comments don't render, so the spec reads as prose. A block can't nest in another block or span a heading.A test hint says how to test something: a fenced code block whose info string is exactly
test:IDortest ID. Its id isfile#ID, its role:test_hint, and it sits in the block or section around it. Its version covers the fence's content. It renders, since readers should see test guidance. Atestfence without an id is an ordinary code block.A marked table holds one claim per row. The line
<!-- surfex: table ID -->, on its own line before a table (blank lines between are fine), makes the table a unit, with idfile#IDand role:table. Its version covers the header and the separator row only. Each row is a unit too, with role:row, sitting in the table:- its id is
file#ID/KEY, where KEY is the row's key cell: the first column, or the columnkey: Headernames in the marker (<!-- surfex: table modes key: Mode -->); - KEY is the cell as written, without its code and emphasis marks and with whitespace
collapsed, so
`:word`is:word, and it may hold spaces; - its version covers the row's cells, so editing one row changes only that row.
The table ends at the first line that isn't a table line. A table without the marker is body, as before.
The marker may name its columns further, with options in either order after the id, each a header or a bracketed list of them:
key: [Function, Key]keys a row on several columns, for a table whose row is identified by a pair: KEY is their cells, each as above, joined by/(file#options/render/:width). No part may be empty, and two rows with one KEY are the duplicate-id error.ignore: [Measured]leaves columns out of each row's version, for cells that record a measurement rather than state a claim, so refreshing them dangles nothing. Use it for nothing else: an edit to an ignored cell is invisible to every relation. A key column can't be ignored. The table's version, its header, still names every column.
A marker's text after its id must be these options, so a misspelt one is an error, not silently body.
- its id is
Where the spec renders. A forge's CommonMark renders every form above as intended, but
for the anchor, which shows as text. ex_doc's markdown (EarmarkParser) renders as intended
only the id comment on the line after the heading, test:ID hints, marked blocks and
marked tables. It shows an id comment at the end of a heading, and an anchor, as heading
text, and renders a test ID hint (an info string holding a space) as inline text that
swallows the heading after it. A spec published with ex_doc uses the forms it renders.
Written ids, block, hint and table ids, and row ids share one namespace per file with the
section ids. All but a row's KEY are letters, digits and . _ - :, starting with a
letter or digit. Markers and hint fences inside a fenced code block are text. A spec that
uses none of these scans exactly as it did before they existed. Every problem raises,
naming the file and line:
- an unclosed block or hint;
- a table marker with no table after it or with text that isn't an option, a table with no
separator row, a
key:orignore:naming no column, an ignored key column, or a row with an empty key; - a block that nests or spans a heading;
- a close marker with no block;
- a heading with an id comment on the line after it and a written id of its own;
- a malformed id;
- a duplicate id, naming both lines.
a marked table is a unit, and each row one keyed file#TABLE/KEY by its key cell (the first
column, or key: Header), without code or emphasis marks; editing a row changes only that
row's version; a marker with no table, no separator row, an unknown key column, an empty
or repeated key is an error naming the linekey: [A, B] keys each row file#TABLE/a/b on several columns, and an empty part or a repeated
pair is an error; ignore: [C] leaves C's cells out of each row's version, so editing one
changes nothing, while editing any other cell still does; an ignored key column, an unknown
column or marker text that isn't an option is an error naming the linea heading's id is its id comment, else its anchor, else what the project's heading grammar
reads from it (§ids, section numbers, a regex, or the first of a list that matches), else
its path; a section number may end in a letter before its dot (2a.); a written id survives
a renamed heading, is never numbered ~2, and a repeat is an error naming both lines, even
when two grammars read itan id comment on the line after a heading is its id, with the version and subsection paths
an id at the end of the heading gives; a <!-- /surfex --> before the next marker or heading
makes it a block's opening marker instead, as before; an id on both lines is an error naming
the line aftera fence whose info string is test:ID is the hint ID, with the version the same fence as
test ID has; test: with nothing after it, or test:ID followed by more words, is an ordinary
code blockthrough ex_doc's markdown (ExDoc.Markdown.Earmark), the forms it is said to render (the id
comment after a heading, test:ID hints, marked blocks and tables, an id comment of its own
line before a paragraph or list item, a marked list of written ids, a marked contract
fence) show no marker text, and every heading in the text is a heading; the id comment at
the end of a heading, the anchor, test ID hints, an id comment inside a list item, one
starting a paragraph and an elixir contract fence are shown to break as saidgiven a section
when its body is reworded, its version changes
when its heading is renamed, a paragraph reflowed or blank lines added, it doesn't
when a subsection is edited, only the subsection's version changesa # line inside a fence (backticks or tildes, closed only by the same character at
least as many times) stays in its section's body and counts in its version;
outside a fence it is a headingediting a marked block, a test hint, or a section's own prose changes that unit's
version and no other'sa test's version changes when its body, a private helper it calls, an attribute it
reads, a setup that applies to it, or its comprehension's cases change;
not when a variable is renamed, or a setup it doesn't get changesscanners produce scan records only, each a pure function of the source: spec units,
code items, tests and classesa test's calls resolve each alias where it's declared: a module's throughout, a describe's
for its tests and helpers but not a sibling describe, a body's from its line on; every
alias form11.1 Claim forms
A claim is often smaller than a section: one rule in a list, one paragraph. Its author
names it, and it is a unit of its own with role :rule, sitting in its section or block.
A hint inside a rule sits in the rule. No id ever comes from where a claim stands: an
ordinal would dangle every claim after an insertion.
- A named paragraph or list item. An id comment on a line of its own, directly before
a paragraph or a list item, names it:
<!-- surfex: no-override -->and then the item or paragraph on the next line isfile#no-override. The comment is a block's opening marker too. It opens a block, as it always has, when a<!-- /surfex -->closes it before the next marker, heading or the end of the file; on the line after a heading it is the heading's id (§11); otherwise it names what follows it. An id comment that starts an item or paragraph names it the same way:- <!-- surfex: no-override --> Anonymous requests never …. - A marked list.
<!-- surfex: rules NAME -->on its own line before a list (blank lines between are fine) makes each of the list's top-level items a rule. An item's id is its id comment, else the id the project'srule_ids:grammar reads from the start of its text, in the formsheading_ids:takes (§11)::section_signreads1. §Tenancy.1.4 …asTenancy.1.4, and~r/^(?<id>P\d+)/readsP3. NAME names the list in errors. The list ends at the first line that is no part of its items, or an item of another list (another bullet, or another ordered delimiter). - Where a claim ends. A paragraph runs to a blank line, a heading, a marker, a fence or a list item. An item takes its nested lines (every line indented to its content) and a lazy continuation of its text. It ends at a less indented line after a blank one, another item at its level or above, a marker, a fence or a heading. A rule can't hold another.
- Its version covers its text and nested lines, without its list marker or id comment, with whitespace collapsed, so renumbering a list changes none. Like every finer unit, a rule is left out of the version of what it sits in, so rewording one changes that rule's version and no other.
- Bold is never a claim. A bold-lead sentence with no id comment is its section's text, whatever it says. Bold is emphasis far more often than it is a claim.
A contract fence holds the one place where naming code in the spec is the contract: a
module's signatures. A fenced block after the line <!-- surfex: contract --> (blank lines
between are fine), or opened as ```elixir contract, yields one unit of role
:contract per function its @spec, @callback or @macrocallback lines name. Where
a signature ends is §11.8's.
- Its id is
file#Module.name/arity, read by parsing the signature as Elixir, with the module §11.9 resolves:spec/17-public-api.md#MyApp.Feed.feed/2. Several signatures of one function (a multi-clause spec) are one unit, at the first one's place. The record'scontractis the function it is the contract of,MyApp.Feed.feed/2. - Its version is its signatures' text, so changing a signature dangles exactly that unit's relations. The fence's other lines (comments, code) and its fence lines are the text of what it sits in.
The bare marker is a block's opening marker too, and stays a block where a
<!-- /surfex --> closes it (§11). A signature with no module, a marker with no fence after
it, a fence holding no signature, a signature that doesn't parse, and one id in two fences
of one file are errors naming the line. Whether a contract unit's citation of its own
function yields implements is plan item D.2's to decide.
Folding path headings. With fold_paths: true in .surfex.exs, a heading with no id
(no id comment, anchor or heading_ids: id) inside a section that has one is part of
that section's unit, through any such headings between them:
- it isn't a unit of its own;
- its heading line and body count in the id'd section's version, which runs to its last line, so editing or renaming it changes that section's version;
- the units inside it (hints, blocks, rules, rows, contracts) sit in the id'd section.
A heading with no id'd heading above it stays a path unit, as before: an index page, or a
project that writes no ids. A sub-heading meant as a unit of its own gets an id. Folding
is the default wherever the spec writes ids (heading_ids:): a path heading under an
id'd heading is part of that unit's own text (§13.4). fold_paths: false keeps path sub-headings as units, and without heading_ids:
nothing folds. A value other than true or false is an error.
with heading_ids: and no fold_paths:, a path heading under an id'd heading folds;
fold_paths: false keeps it a unit; without heading_ids: nothing foldswith fold_paths: true a heading with no id inside an id'd section is no unit: its text and
body count in that section's version and what sits in it sits in that section; one with no
id'd heading above stays a path unit; without it every heading is a unit as before; a
value that isn't a boolean is an errorA project's citation forms. Ids are file-scoped, and a project cites them across files
in its own forms: §03.4 (chapter 3, section 4), spec/05 §4.3. unit_refs: in
.surfex.exs lists those forms, each a regex with named captures: id, the unit's id in
its file, and file, the spec file's path with or without .md, or chapter, the number
a spec file's name starts with (spec/03-wire-format.md is chapter 3, 03 and 3 alike).
~r/^§(?<chapter>\d\d)\.(?<id>[\w.]+)$/ makes §03.4 resolve to
spec/03-wire-format.md#4.
Surfex.Scan.Resolverreads them:Surfex.Scan.resolver/2takes the scans and the forms, andSurfex.Scan.resolve/2tries the forms before its exact and bare-id lookups. The first form that matches decides: one unit is found; none falls through to the ids; several (two files of one chapter) is ambiguous.Surfex.Scan.Resolver.unit_refs!/1validates them, and a form with noid, or neitherfilenorchapter, is an error.- So a test's
verifies:may use the project's form.Surfex.Status.Config.scans/2gives such a declaration as the full id it names, so everything reading declarations resolves it as the id; one that names nothing stays as written, and is reported as any unknown declaration is. mix surfex.testand the recording tasks that take a unit (relate,validate,confirm,annotate,retire,move,resolve,mark, their--filebatches included) take the form too.
Prose citations of spec units aren't checked as citations: §6 checks citations of the code, and checking the spec's citations of itself is plan item D's.
unit_refs: forms resolve a project's citations by chapter or file to file-scoped units, an
unknown chapter or id is unknown and two files of one chapter ambiguous; a form without id
and file or chapter is an error; a test's declaration in the form names its unit, and
surfex.test and the recording tasks take the formRule ids share the file's namespace (§11), so a repeated one is the duplicate-id error.
Surfex.Status.units/1 counts rules and contracts with the other roles, and the reports
show them once a spec has one. A spec that names no claim scans exactly as before. An id
comment of its own line that was once an unclosed block is now a claim's id. When no
paragraph or item follows it, it is still the error it was. Also an error, naming the line:
- an item of a marked list with no id, or a marker with no list after it;
- a rule inside a rule;
- a list item with two ids (a comment of its own line, and one starting it);
- a malformed id.
Where these render. ex_doc's markdown renders an id comment of its own line before a
paragraph or list item, and the rules marker, as comments. Before an item, the comment
needs a blank line above it, which splits the list in two where it isn't the first item.
It shows an id comment anywhere inside a list item as text, and an id comment that starts
a paragraph swallows the paragraph and the heading after it. A forge's CommonMark reads
a line that starts with a comment as raw HTML, so the rest of that line loses its
markdown. A spec names its claims with the comment on its own line, or with a marked list
of written ids. The contract marker renders as a comment; an elixir contract info string
holds a space, and ex_doc renders it as a test ID hint: as text swallowing the heading
after it.
a contract fence (after the contract marker, or opened elixir contract) is one unit per
function its @spec, @callback and @macrocallback lines name, keyed file#Module.name/arity;
changing one signature changes only its unit, the fence's other lines are its section's
text; a bare marker a close marker follows is a block; a signature with no module, a fence
with no signature, a marker with no fence, a signature that doesn't parse and one id in two
fences are errors naming the line; status counts contractsa marked list's top-level items are rules, each by its id comment or the id rule_ids: reads
(a § id, a regex, the first of a list), sitting in their section; an item starting with an
id comment, or after one of its own line, is a rule in any list; a hint inside a rule sits
in it; a rule's version is its text and nested lines without its list marker or id comment;
an item with no id, a marker with no list, a repeated id, a rule inside a rule and an item
with two ids are errors naming the line; status counts rulesan id comment of its own line before a paragraph, or starting it, makes the paragraph a
rule whose version is its own text, left out of its section's; bold with no id comment is
the section's text; a comment a close marker follows is a block, and one naming nothing is
the block error it wasinserting a claim at the top of every claim form (a named paragraph, a marked list, a named
item) changes no other unit's id or version11.2 List-shaped table keys
A marked table's row key (§11) that reads as a list, such as 1001, 1005 legacy hilite, is
almost always two things in one row, so their relations can't be told apart.
Surfex.Status.list_keys/1 lists every row whose key holds ,, / or and, with
its table and the separator it holds, in file and line order. The key parts of a
composite key (key: [A, B]) are joined by a bare /, which is no separator. The status
reports them in a list of their own, and the JSON as list_keys. It is a warning, never
a failure, since a key may hold a comma on purpose. The scan is unchanged: the rows are
units as before.
a row key holding ", ", " / " or " and " is listed with its table and separator, in the text
report and the JSON, and the status doesn't fail; a composite key or a plain one isn't
listed, and the rows are units as before11.3 Calls a test states through a framework
Some tests reach their code through a framework rather than by naming it: a request to a route the router maps, an event sent to the view a request mounted, a message sent to a named process. Each resolves deterministically from source, so it is a call in the test's record, beside the calls §11 lists, and reach, the triangle, evidence (§13.1, §17) and the suggestions (§15) follow it. These are a test's calls only: a definition's calls (§5.2), and so every code version, stay as they were.
Routes. The project's router is read from source, never compiled
(Surfex.Status.Config.routes/2): the router modules router: in .surfex.exs names
(a module or a list), or, without it, each module an endpoint (use Phoenix.Endpoint)
plugs that is a router (use Phoenix.Router, or use App, :router), from the Elixir
scanner's :paths. router: false turns this off; a named router the source doesn't
define, and a router: of any other shape, is refused. Its routes are read in order:
scope joins paths and aliases as Phoenix does (scope "/carts", MyAppWeb then
live "/:id", CartLive.Show is MyAppWeb.CartLive.Show at /carts/:id), and
live_session and pipe_through change neither. A live route is a GET; get,
post, put, patch, delete, options and head routes name a controller and an
action. Other forms (resources, forward) aren't read.
live(conn, PATH)and each verb's request (get(conn, PATH), …), local or qualified (Phoenix.LiveViewTest.live/2) and piped or not, resolve to the first route, in the router's order, of that verb whose path matches. A live route calls the LiveView module, itsmount/3and itshandle_params/3; a controller route calls the controller module and its action (Controller.action/2).PATHis a literal string, or a string or~pwith interpolations. A literal segment matches the same segment or a param (:id); a segment holding an interpolation matches only a param, since what it holds is known only when the test runs; a glob (*path) takes the rest. The query string is no part of the match. A path in a variable, or one no route matches, is no call.live_isolated(conn, Module, …)callsModuleand itsmount/3.
LiveView events. In one body (the test's, or a helper's), a variable bound by
{:ok, view, _html} = REQUEST, where the request resolves to a LiveView, is that view.
render_click, render_submit, render_change, render_keydown, render_keyup,
render_blur, render_focus and render_hook given the view, or an element/2,3 or
form/2,3 of it, call the view's handle_event/3. A component the element targets
(phx-target) isn't known from the test, so the call names the view's.
Messages. GenServer.call(Module, msg) and GenServer.cast(Module, msg), with a
module as the server, call that module's handle_call/3 and handle_cast/2, and name
Module. A server given any other way (a pid, a {:global, …} name) is no call.
Captures through a helper. A capture handed to a helper (growth(&MyApp.f/1, …))
is already the test's own call (§11), so reach follows it whether or not the helper is
scanned.
Surfex.Scan.ExUnit.records/5 and Surfex.Scan.ExUnit.tests/5 take the routes;
Surfex.Status.Config.scans/2 and Surfex.ExUnitFormatter pass the project's.
the router is found from the endpoint or named by router:, its scopes joined in order;
a request through a live route calls the view's mount and handle_params, through a
controller route the action; a literal segment matches a param, an interpolated one only
a param, a glob the rest; an unknown or variable path is no call; reach followsan event given a view bound from a resolved live request, or an element or form of it,
calls the view's handle_event/3; one to any other view is no call11.4 Checks
Some claims are verified by a gate, not a test: a lint rule that no function rescues, a
security scanner, a project's own task comparing a layout table with a compiled header.
checks: in .surfex.exs declares them, each a command that can fail and the claims it
verifies:
checks: [
[id: "no-rescue", run: "mix credo --strict --only Credo.Check.Warning.Rescue",
verifies: ["plat-3"], inputs: [".credo.exs", "lib/**/*.ex"]],
[id: "layout", run: "mix my_app.layout", verifies: ["spec/03-wire.md#layout/*"],
inputs: ["lib/my_app/wire.ex", "c_src/wire.h"]]
]Surfex.Scan.Checks makes one scan record of kind :check per check
(Surfex.Scan.Checks.records/2), and Surfex.Status.Config.scans/2 includes them:
- Its id is the check's
id: letters, digits and._-, starting with a letter or digit, so never a test's. - Its version covers its declaration (
run,verifiesandinputs) and the content of every file itsinputsglobs match. Changing the rule, what it verifies or what it inspects is a new version, and its relations dangle; no other file changes it. The record'scheckholds the command, the declaration's own version and each input file's version, from whichSurfex.Scan.Checks.version/2makes its version. - What it declares is its
verifies, each{:verifies, ref}, resolved as a test's declaration is (§11, andunit_refs:, §11.1).TABLE/*declares every row of a marked table at once. A reference that resolves to nothing is broken (§13.1), as a test's is. - Its location is
.surfex.exs, with no lines.
Surfex.Scan.Checks.declared!/1 validates the key: each check is a keyword list of id
(unique), run (a command), verifies (a reference or a list of them) and, optionally,
inputs (a list of globs), and nothing else. Anything else raises, naming it, as does an
input glob that matches no file: a misspelt input would leave the version blind to what
the check inspects.
a check is a record of kind check, its version changing with its command, its verifies,
its input globs and its inputs' content, and not with other files; TABLE/* declares each
row; a malformed check, a repeated id or an input matching no file raisesA check verifies, as a test does, and does nothing else. A verifies relation runs from
a test or a check to a spec unit (§12.1), and status treats a check's as a test's: it is
suggested from the declaration (§15), unrecorded while the log lacks it and undeclared
once the check stops declaring it (§13.1), the check named check ID in those lists. A
check has no calls, so it takes no tests relation, and it carries no implements: the
code a claim is about is related to the claim by hand. The triangle (§13) counts a check
verifying a unit as a verifying test that exercises the code in the files it inspects,
and completeness (§20) counts a unit a check verifies by a validated relation as
verified. Status lists the checks, with what they verify, in its text, its JSON
(checks) and RELATIONS.md, where a project has any.
a check's declared verifies is suggested, unrecorded until recorded and undeclared once
dropped; a unit only a check verifies has no triangle gap for the code in its inputs and
counts as verified in completeness; status, its JSON and the golden list the checksmix surfex.check [ID…] runs checks, every one or those named, with the system's shell
in the project's root (Surfex.Check.run/3), and records each result as evidence
(§17.1) in the file the test formatter writes, or in --evidence PATH. Exit status 0
passed and anything else failed; the task then fails, so it is the gate as well. An
unknown id, or a project with no checks, is an error before anything runs
(Surfex.Check.select/2). --list names them and runs nothing.
mix surfex.test UNIT (§11) also names the checks verifying a unit: --list lists them
after the tests, and a run says which there are. --checks runs them after the tests,
recording their evidence as mix surfex.check does, and fails if a test or a check
fails. With it, a unit that only checks verify is no error.
mix surfex.check records each check's result, passed or failed by its exit status, at its
declaration's version with its inputs' versions, and fails when one failed; an unknown id
is an error; surfex.test --checks runs the checks verifying a unit, and --list names them11.5 Where a declaration counts
A verifies declaration counts only from the test or property block it applies to (§11),
or from a declared check (§11.4). Nothing else asserts anything: a tag on a module with no
test, or in a helper under test/support/, would credit a requirement for where a file
sits. The test scanner gives a declaration only to the test records it applies to, so such
a tag verifies nothing. It is still reported, so a misplaced tag is visible rather than
silently ignored. Surfex.Scan.ExUnit.inert/2 finds them:
- in the files
tests:names, a@tagsettingverifiesthat no test follows in its block (the module's, a describe's or a comprehension's), and a@describetagor@moduletagsetting it over a describe or module that defines no test; - every
verifiestag, at any level, in a file undertest/support/, which is compiled as support code and never run as a test, unlesstests:scans that file.
Each is its file and line, the ids it names, and why. Surfex.Status.Config.load/3 passes
them to Surfex.Status.derive/4 as inert:, and status lists them as inert in its
text, its JSON (inert) and RELATIONS.md (by file, where there are any). It is
information, never a failure: a tag that verifies nothing fails nothing, and the
requirement it names is judged by the tests that do verify it.
a verifies tag no test follows, a describetag or moduletag over no test, and any verifies
tag under test/support is inert, listed with its file, line, ids and reason; a tag a test
takes, a comprehension's included, is not; status lists them and doesn't fail on them11.6 A project's own declaration forms
A project may already record what its tests verify in a form of its own, an ExUnit tag of
another name or a module attribute, often with something appended to each id, such as a
hash of the section's text. Rewriting every tag as verifies: would be a one-shot import,
redone whenever the form changes. Instead declares: in .surfex.exs names the forms the
test scanner reads as verifies declarations:
declares: [verifies: [{:tag, :validates, strip: ~r/@[0-9a-f]+$/}]]{:tag, KEY}is an ExUnit tag key, read at@tag,@describetagand@moduletagand merged level by level asverifies:is (§11).{:attribute, NAME}is a module attribute before a test,@validates ["…"], applying to the next test as a@tagdoes.strip: REGEX, optional, is removed from each id, so@tag validates: ["Engine.15.2@abc123"]declaresEngine.15.2. Each id is then a declaration like any other: in the project's citation form it resolves throughunit_refs:(§11.1), it is suggested, recorded and confirmed as one (§15, §17), and a form outside a test is inert (§11.5).- Such ids can't be selected by
mix test --only verifies:id, which reads theverifiestag alone, so each is listed in the test'sunselectable(§13.1).mix surfex.testselects by declaration, so it runs them. Surfex.Status.Config.declares!/1reads and validates the key: anything but[verifies: [form, …]], a form of another shape, a name ExUnit or surfex reads itself (verifies,tag,moduletag, …), or an option other than a regexstrip:is refused, naming it.
The form stays the project's: nothing is rewritten, and a change to it is a change to the
config. A bare § mention in a test's prose or a string is not read: a mention is not
a claim.
a declared tag form at every level and a declared attribute form before a test are read as
verifies declarations with their strip pattern removed, unselectable by --only; without
the form they declare nothing; a bare § mention is no declaration; a form no test takes is
inert; a value that can't be read raises naming its keydeclares: is validated, refusing a malformed form; a declared id in the project's citation
form resolves through unit_refs:, is counted unselectable, and is selected by surfex.test11.7 Spec-unit classes and deferrals
Code that is described elsewhere has an excusal: a class with a reason (§8). Spec text has its own: a unit that deliberately asserts nothing a test could fail on (a decision record, prior art, an architecture map, how to read the spec) is classed, and a unit that states what isn't built yet is deferred by the issue that will build it. Both are read from the source, never recorded in the log.
Classes, declared once. spec_classes: in .surfex.exs names the project's classes,
each with a reason, in the shape of code's classes::
spec_classes: [
{"informative", "explains or points; asserts nothing a test or check could fail on"},
{"decision", "a decision record: why, not what", files: ["spec/13-decisions.md"]},
{"process", "how the project is developed", headings: ~r/^Process\b/},
{"informative", "explains or points; asserts nothing a test or check could fail on",
marker: ~r/^spec-coverage: informative$/}
]- A class is a name (letters, digits,
-,_) and a reason, both required. One name may be declared several times, with one reason. files:(globs, relative to the root) andheadings:(a regex over a heading's text as written, without its id comment or anchor) make it a pattern class: every section that matches is classed, and with both, a section must match both.marker:(a regex over an own-line HTML comment's inner text) reads a project's existing comment as this class's marker, an alias. The comment then belongs to no unit's text, like any marker.- A class with none of these is selected only by surfex's own marker.
Surfex.Status.Config.spec_classes!/2reads and validates the key. A malformed class, an empty reason, a bad name, an option other than these three, one name with two reasons, and afiles:glob that matches no spec file are refused, naming them: a misspelt pattern would otherwise class nothing in silence.Surfex.Status.Config.scans/2passes the classes toSurfex.Scan.Markdown.sections/3asspec_classes:.
The markers, each on a line of its own, in a unit's own text:
## 2. Architecture
<!-- surfex: arch -->
<!-- surfex: class informative: the module map is orientation; the rules after it are claims -->
## 6. View abstraction
<!-- surfex: deferred #412: past v1 --><!-- surfex: class NAME -->classes the section it sits in, with the class's reason.<!-- surfex: class NAME: REASON -->adds the author's own reason for this unit.<!-- surfex: deferred REF -->or<!-- surfex: deferred REF: REASON -->defers the section or marked block it sits in. REF is an issue,#123orgroup/project#123, or the spec unit that records the decision (§13.13): a deferral with neither would never end, and that is what a class is for. There is no pattern form; each deferral names its own.- A marker may follow a heading's id comment on the next line. Like every marker it is left out of the unit's version, so classing or deferring a unit changes no version.
an own-line class marker classes its section with the class's reason and its own; it may
follow the heading's id comment; the section's version is the one it has without the
marker; the preamble can be classeda deferral marker defers its section, or the marked block it sits in, with its reference
and reason; a cross-project reference is read whole; the unit's version is unchangedWhich class a section has. A section's own marker wins, a deferral marker included:
no pattern classes a deferred section. Otherwise the first declared pattern class that
matches it, as code's rules: do (§7). An alias is a marker. Surfex.Scan holds the
result on the record: class (%{name, reason, note, by}, by being :marker,
:pattern or :alias, and note the marker's own reason) and deferred
(%{ref, reason}).
files: classes every section of the files it matches, headings: only the sections whose
heading matches, and both only where both match; a marker beats a pattern; the first
pattern declared that matches winswith marker: ~r/^spec-coverage: informative$/ that comment classes its section by alias and
leaves the version as it was; without the alias it is section text, as beforeClasses are for sections. A rule, a row, a hint, a contract and a marked block are claims: the way to excuse one is not to name it. Every problem raises, naming the file and line:
- a class marker naming a class
spec_classes:doesn't declare; - a class marker in a marked block or in a rule, or a deferral marker in a rule;
- two class or deferral markers in one unit, a class and a deferral among them;
- a deferral whose reference is neither an issue nor a spec unit (§13.13);
- a marker under a heading
fold_paths:folds into its ancestor (§11.1), which would class the ancestor its author didn't mark.
an undeclared class; a class marker in a block or a rule; two markers in one section; a
class and a deferral in one unit; a deferral without a well-formed reference; a marker
under a folded heading: each raises naming the file and line; spec_classes: refuses a
malformed class, an unknown option, two reasons for one name and a glob matching no spec
fileWhere they render. Both markers are own-line HTML comments, like the id comment after a heading, so ex_doc's markdown renders them as nothing, alone or after an id comment, and keeps every heading.
through ex_doc's markdown (ExDoc.Markdown.Earmark), a class marker and a deferral marker,
alone after a heading or after its id comment, show no marker text and every heading
stays a headingWhat a class or a deferral changes in the status is §13.6's.
11.8 Where a contract signature ends
A contract signature (§11.1) runs from its @spec, @callback or @macrocallback line
for as long as its text doesn't parse as Elixir yet: while a {, (, [ or << is open,
or a :: waits for its type, it takes the next line whatever its indentation, so a map
closed by a } back at the head's column is one signature. Once it parses, it takes the
lines indented deeper than its head, such as a when or a | nil continuation, and no
others. It never runs into the next @spec, @callback, @macrocallback, @type,
@typep or @opaque line, and it ends at the fence's end. A signature still unfinished
there, or one that never parses, such as a sketch %{id:, …}, is the error naming its
first line that §11.1 lists.
a contract signature whose closing }, ), ] or >> sits at its head's column is one unit
through that line; once a signature parses only deeper-indented lines join it; a sketch
that never parses raises naming its first line, and an unfinished one stops at the next
@spec, @callback, @macrocallback, @type, @typep or @opaque line; every fence of
typespecs that parses whole reads without a parse error11.9 Which module a contract signature is about
A contract signature (§11.1) is keyed by the function it states, so two functions of one name in two modules are two units, and a callback is a unit apart from a function of the same name. A signature's module is, nearest first:
- the module its head names,
@spec MyApp.open(port, opts) :: …, as written; - else the
defmodule M doaround it inside the fence, whoseendat its column closes it; adefmoduleinside another joins its name,A.B; - else the marker's
module:(<!-- surfex: contract module: MyApp.Feed -->); - else the nearest enclosing heading that names a module, climbing past headings that
name none: the items a profile subject gives it (§6.6), else the module alias it writes
alone in backticks, as
## 1. `MyApp.Feed`: the feeddoes.
A @spec is keyed file#Module.name/arity, a @callback file#Module.callback:name/arity
and a @macrocallback file#Module.macrocallback:name/arity. The kind holds no space, and
no module or function name holds a :, so a callback's id never collides with a module's.
A behaviour that also defines the function it declares as a callback has both units. The
record's contract is Module.name/arity for every kind.
These stay errors naming the line: one id in two fences of one file, which names the line it first had, since the same function stated twice in one file is a restatement that drifts; a signature none of the four places, and one whose nearest naming heading names several modules. A heading's modules are asked only of a signature that needs them, so a qualified signature under such a heading reads.
an unqualified signature takes the module of the defmodule around it in the fence (nested
ones join), else the marker's module:, else the nearest heading naming one, by a subject
or a backticked alias, climbing past headings that name none; a callback and a spec of one
name and arity are two units; one id twice in a file raises naming both lines; a signature
no module places, or whose heading names several, raises naming its line11.10 Calls through a declared dispatch function
Some code runs its handlers through a table: a function looks the handler up in a map of
operations and calls it with apply(module, fun, args). No call in the source names a
handler, so without help a test driving the dispatcher reaches none of them, and each
handler earns (§13.5) only through a review naming the path (§18.2). The project author
knows the dispatcher exists, so the project declares it once, and surfex reads the table
from source, never compiled and never traced at run time:
dispatch: [{Module, :fun, arity}]in.surfex.exs(Surfex.Status.Config.dispatch!/1) names the dispatch functions. Anything but a list of such triples is refused, as is a dispatcher the scanned source doesn't define withdef, one in a@moduledoc falsemodule, and the key without the Elixir scanner among the scanners (§5.3), which is what reads it.- A dispatcher's targets are the literal entries its module writes inside a module
attribute's value (not a typespec's or the docs'), a map literal or a keyword literal
(not a
do:block):{Module, :fun}names every arity ofModule.funthe scanned source defines withdefordefdelegate,{Module, :fun, arity}and&Module.fun/aritythat arity. Aliases resolve as the module declares them, and__MODULE__is the module. A module nested in it is its own. An entry naming no function the scanned source defines is listed as unscanned and reaches nothing. - A call that names a dispatcher, remote, local inside its module or a capture, also calls each target and names its module, in a definition's calls (§5.2) and a test's (§11) alike. So reach, the triangle, evidence (§13.1, §17), the suggestions (§15) and earning (§13.5) follow it with no special case.
- A scanner's
calls/3(§5.4) still takes precedence: where it answers for a call to a dispatcher, its answer is what that call reaches, and the table adds nothing. - The reading travels on the dispatcher's module item and code record (
dispatch, §4).Surfex.Status.derive/4givesdispatch, one entry per dispatcher,%{dispatcher, targets, unscanned, unresolved}. A dispatcher whose module lists no literal entry is unresolved: a call to it reaches nothing beyond it. It is reported, never failing.Surfex.Status.Report.text/1lists each dispatcher with what it reaches, the JSON givesdispatch, and the status golden holds adispatchtable and counts the dispatchers in its header, each only wheredispatch:declares one.
a test driving an endpoint whose code calls a declared dispatcher reaches each handler its
module's map lists, and the handler's module; a {Module, :fun} entry names every defined
arity and an &Module.fun/arity capture its arity; without dispatch: it reaches nonea dispatcher whose table isn't literal reaches nothing beyond it and is listed unresolved,
an entry naming unscanned code is listed unscanned, and the status doesn't fail; text,
JSON and golden list dispatchers only where dispatch: declares one; a malformed dispatch:
or an undefined dispatcher is refusedwhere a scanner's calls/3 answers for a call to a dispatcher, the call reaches its answer
and not the table; a call it doesn't answer still reaches the table12. The relation log
The relation log records judgements: that two things relate, at two versions. It is the only state surfex keeps. Scanners never read or write it (§11).
12.1 Entries
Surfex.Log.Entry is one entry:
| Field | Meaning |
|---|---|
id | SHA-256 of the entry's canonical line without id: stable, and changed by any edit |
at | when it was recorded, UTC ISO 8601: orders the history |
commit | HEAD when it was recorded: context only, since squash merges can make it unreachable |
parents | the entry ids it follows for the same relation; two means it resolves a fork |
op | relate, retire, mark or observe (Surfex.Log.Entry.ops/0) |
type | a relation's: implements, refines, depends_on, tests, verifies (a test or a check verifies a spec unit, §11.4) or excuses (Surfex.Log.Entry.types/0); or a mark's: needs_update (Surfex.Log.Entry.mark_types/0); or an observation's: red_green or baseline (Surfex.Log.Entry.observation_types/0) |
ends | a relation's two {kind, id, hash}, of the kinds Surfex.Log.Entry.kinds/0 lists (spec, code, test, class, check) and the grammar below allows. A null hash is a planned end: an id that didn't exist when the relation was recorded (§14). At most one end is planned. A mark's one end: the spec unit, at its hash. An observation's one end: the test, at its version, or for a red_green, a check at its declaration's version (§17.1) |
by | who recorded it |
note | why, optionally |
basis | how the entry validates the relation (§18): evidence, review, judgement, baseline (§18.1) or proposed (Surfex.Log.Entry.bases/0); absent when it validates nothing, which keeps every older entry's canonical line and id |
through | how a reviewed test exercises code no call reaches (§18.2), free text; only on a relate of an implements with basis review, and absent otherwise, which keeps every other entry's line and id |
migrates | the section (or table) a claim's implements moves from, recorded by a migration (§14.1); only on a relate of an implements with basis proposed or baseline, and absent otherwise, which keeps every other entry's line and id |
An observation is a fact the log keeps about one test version, not a relation:
op: observe, typered_green, onetestend at its version (or a check's, §17.1), basisevidence. It records that the version discriminated (§17): it failed against one version of its code and later passed against another, with the two runs in its note. It is never withdrawn: a changed test is a new version, which the observation doesn't speak for.Surfex.Log.Entry.relation?/1tells a relation's entries from a mark's or an observation's.A mark is a statement about one spec unit, not a relation:
op: mark, typeneeds_update, onespecend at the unit's version, and a note (required) saying what is wrong in the result. Aretireof the same type and end, naming the mark as its parent, withdraws it.Surfex.Log.Entry.mark?/1tells a mark's entries from a relation's.A relation is its type and its two ends' kinds and ids (
Surfex.Log.Entry.relation/1). For the undirected types (implements,excuses), ends are sorted, so A↔B and B↔A are one relation. The directed types (Surfex.Log.Entry.directed/0:depends_on,refines,tests,verifies) keep their ends in the order given, from → to, because "A depends on B" is not "B depends on A".Surfex.Log.Entry.relation/3gives the relation of a type between two ends without an entry, oriented the same way.The grammar. What a well-formed entry is, the log enforces itself, rather than trusting its writers. A malformed entry from a bad merge, a hand edit or a writer's bug is refused where it is read, not left to surface as a confusing state far away:
Type Op Ends Basis implementsrelate, retire code ↔ spec proposed,evidence,review,judgement,baseline; or none (legacy)verifiesrelate, retire test or check → spec proposed,evidence,review,judgement,baseline; or none (legacy)testsrelate, retire test → code none, evidence,judgement,baselinerefinesrelate, retire spec → spec none, judgementdepends_onrelate, retire code → code none, judgementexcusesrelate, retire class ↔ code proposed,judgement; or none (legacy)needs_updatemark, retire one spec none red_greenobserve one test or check evidencebaselineobserve one test baselineA
retirehas the ends of what it retires and never a basis. None (legacy) admits entries written before bases existed (0.4 and earlier): they decode, and status reports animplementsorverifiesamong them unvalidated (§13.1).Surfex.Recordnever writes one: everyimplementsandverifiesit records carries a basis. Animplementsonjudgementis grammatical, but validates only a shape's relation (§18): the grammar sees one entry, and whether its code has behaviour is the scan's to say. A violation names the rule ("implements ends must be code ↔ spec, got test, test"), and at decode the entry too.mix surfex.log --verifylists every violation with its file and line.Surfex.Log.Entry.build/1makes an entry from its fields and computes its id, or says which field or rule is broken.Surfex.Log.Entry.new!/1raises instead.The canonical line is JSON, keys in the order above (
Surfex.Log.Entry.encode/1). It is readable by anything, and parsing it never evaluates code.Surfex.Log.Entry.decode/1reads a line back, or says why it isn't an entry: a missing or unknown field, or an id that no longer matches the content, meaning the line was edited.Surfex.Log.Entry.decode!/1raises instead. Text that is not JSON at all always raises, since the file is then corrupt.Surfex.Log.Entry.json/1reads one JSON value withnullasnil.
a mark is one spec end with op mark and type needs_update, refused with two ends or a
non-spec end; entries recorded before marks existed keep their lines and idsan entry whose line is edited no longer decodes under its id,
and verifying the log reports itan entry's canonical line is JSON with fixed keys, its id a hash of that line; undirected
ends are sorted; the vocabulary of ops, types, kinds and bases is fixed; a basis is
written only when set, so an entry without one keeps its line and ideach type joins only its kinds of end, and a directed type only from → to; a verifies runs
from a test or a check; a mark is one spec end and an observation one test end, or a check
for red_green; there is no config kindeach type carries only its bases, with none admitted as legacy for implements, verifies
and excuses; a tests relation is never reviewed; a retire and a mark carry no basis; an
observation carries exactly its ownthrough is written after basis only when set, so an entry without it keeps its line and
id; an entry other than a reviewed implements carrying it, or a blank one, is refusedmigrates is written last only when set, so an entry without it keeps its line and id; an
entry other than a proposed or baselined implements relate carrying it, or a blank one, is
refuseda line breaking the grammar is refused at decode, naming the entry and the rule, and
verifying the log lists it with its file and line; every entry in surfex's own log fits,
with its id unchanged12.2 The log
Surfex.Log keeps the entries in .surfex/ under the project root
(Surfex.Log.dir/1). Nothing is ever edited or removed. A newer entry for a relation
supersedes an older one and names it as a parent, and any question about history is
answered from the log alone.
- Segments: entries are appended to
surfex.log.Surfex.Log.break/1closes it as the nextsurfex_N.logand starts a new one. - Headers: every file starts with
{"segment": N, "previous": HASH}, whereHASHis the SHA-256 of the previous segment's sorted entry ids (nullfor the first). Headers are not judgements, soSurfex.Log.rechain/1may rewrite them. That's needed only when two branches each started a segment and a merge combined them. Surfex.Log.init/1creates the log and adds.surfex/*.log merge=unionto.gitattributes, so git merges concurrent appends by keeping both sides' lines.Surfex.Log.append/2adds entries to the open segment.Surfex.Log.load/1reads every segment, drops duplicate ids, and orders entries byatand thenid. Every checkout derives the same order, whatever order the lines arrive in, which is what makes the union merge correct.Surfex.Log.verify/1reports every edited line, every parent no entry has (a removed line), and every segment header that doesn't match the segments before it (truncation).
Mix.Tasks.Surfex.Log is mix surfex.log, with exactly one of --init, --break,
--verify (fails on any problem) or --rechain. It never edits or removes an entry.
two branches that each append to the log merge without a conflict,
and the merged log loads to one statethe log only appends: segments load together with duplicates dropped, a break starts a
new segment, and verifying finds an edited line, a removed parent or a truncation13. Relation status
Surfex.Status derives the state of every relation from the scans (§11) and the log
(§12). Surfex.Status.derive/2, Surfex.Status.derive/3 (with a require: policy) and
Surfex.Status.derive/4 (with options: planned: :allow | :fail, triangle: :report | :fail, validated: (§18), evidence: (§17), citations: (§6), coverage: (§13.1),
deviations: :allow | :fail (§13.1)) are
pure: the same scans, log and options always give the same status.
the status is derived from the scans and the log alone: the same inputs always give the
same statusDeriving grows with the project in proportion. Every status, report and check derives
the whole status, so its work over a project twice as large (twice the spec units, code,
tests and relations) is about twice, not four times. What derive looks up per item is
indexed once per derive: each declaration resolves against one resolver (§11), and each
spec unit finds the tests verifying it or a unit inside it from an index built by walking
each verified unit's within chain once, for the triangle and for the baseline (§18.1)
alike. It doesn't scan every record per declaration, nor every verifies relation per spec
unit.
plain derive's work over a project twice as large, in spec units, code, tests and
relations, is about twice the work, counted in reductions13.1 States
The judgement in force for a relation is its tip: an entry of the relation that no
other entry of it names as a parent (Surfex.Status.tips/2).
| State | When |
|---|---|
| conflicted | tips that disagree: entries recorded without seeing each other (sharing a parent, or both with none) that record different judgements. A tip's judgement is its operation, type, ends at their versions, and basis; who recorded it, when, on which commit and with which note are context. Tips that agree are one judgement, represented by the one with the smallest id (Surfex.Status.representative/1): two branches confirming the same change identically make no conflict. A different basis at the same versions still disagrees: CI checks an evidence claim and not a review, so a person picks which record stands |
| retired | the tip retires the relation |
| orphaned | an end recorded at a hash is no longer scanned (removed or renamed) |
| planned | an end was recorded without a hash, before it existed, and still isn't scanned. Once it is scanned, a planned implements relation is proposed until evidence or a review validates it (§18); a planned relation recorded without a basis is dangling on that end until it is confirmed. |
| dangling | both ids are scanned, but at least one is at a different hash than the tip recorded; the report names which ends changed |
| current | both ends are at the hashes the tip recorded |
tips that record the same judgement (operation, ends at their versions, basis) are one
judgement: judged, validated, checked and reported as one tip, and re-recorded with every
one as a parent; a different operation, version or basis is a conflict, and resolve refuses
tips that agree- Impacted is a flag, not a state. A relation is impacted when one of its ends has a
depends_onrelation to something that is not current. It doesn't fail the check: re-confirming everything downstream of every change would train people to confirm without reading. - New: a scanned id in no relation at all.
- Unimplemented: a spec id whose every non-retired
implementsrelation is planned: meant to be built, and not built yet. - Unmet: a scanned id that the
require:policy says must take part in a non-retired relation of one of those types, and doesn't. A planned relation meets it: the intent is on record. The policy is[key: [type, …]], where a key is a kind (every scan of that kind) or a spec role (section,block,test_hint: every spec unit in that role). For example,require: [code: [:implements], test_hint: [:verifies]]means every code item implements something and every test hint is verified. An id that fails several rules is unmet once per rule. Code is one item per definition to the policy, as it is to the triangle (§11): a function's default-argument arities (f/1,f/2from onedef f(x, y \\ 1)) are met by a relation to either. - Broken: a test's declaration (§11) that names no spec unit, or a bare id more than one file has.
- Broken citation: a name the spec cites (§6) that resolves to nothing the code has, or to more than one item. The spec can't name what doesn't exist. Names the profile declares external, and documented absences, are not broken.
- Failed: a current
verifiesortestsrelation whose test, at the version the relation holds, failed in the evidence given asevidence:(§17), whatever validated it: evidence, a review, a judgement or a baseline. A review judged that the test covers the claim, and relied on the run to say whether it passes. The relation isn't invalidated: once its test passes again it holds, with no new review. - Unproven: a current relation confirmed by evidence (§17) that the evidence given to
Surfex.Status.derive/4asevidence:doesn't bear out: its test, at the version the relation holds, didn't run or ran against another version of the code (one that failed is failed, above, and not also unproven). For animplementsrelation, some test with a currentverifiesrelation to the unit (or inside it) and a currenttestsrelation to the code must pass. Withoutevidence:nothing is checked, and a relation confirmed by hand is never required to have run. - Undeclared: a live
verifiesrelation whose test no longer declares its spec unit (§11): the tag was removed or changed. A tag isn't part of a test's version, so without this the relation would stay current, asserting a claim the source no longer makes. Averifiesrelation recorded by hand, with no declaration, is undeclared too: a test must say what it verifies. - Unrecorded: a test's declaration (§11) that resolves to a spec unit, where the log
holds no
verifiesrelation between the test and that unit, in any state: the tag was written and the relation never recorded. It is undeclared's other direction, a claim in the source that the log doesn't hold, and it fails the same way; arequire:policy can't say it, since the policy names what every id of a kind must relate to, not what a tag asks for.mix surfex.suggestoffers the relation (§15), to record and validate. A declaration whose relation was retired is listed under the same heading as its own line, with the retire's note: "declared, but the relation was retired (note): remove the tag, or reinstate it". It fails too: the source asserts what the log decided against, and the fix is one of those two explicit acts. Suggest never proposes it, so a retirement is never undone without someone choosing to.
a test's declaration resolving to a spec unit with no verifies relation in the log is
unrecorded and fails; one whose relation was retired is listed with the retire's note and
fails too; a relation in any other state, proposed or dangling included, clears it- Moved: a live
refinesrelation, both ends scanned, whose unit no longer sits in (within, §11) the unit it refines. Arefinesrelation records a fact read from source, not a judgement: the block or hint sits in that section or block. Moving a marked block unchanged into another section changes neither end's version (a section's version leaves its blocks out), so the relation would stay current while stating what the source no longer says. Status re-readswithinfor every liverefinesrelation and lists one that no longer holds as moved, naming where the unit sits now. It fails: the log states a structure the spec no longer has.mix surfex.suggestproposes its retirement beside the newrefinesrelation (§15), somix surfex.suggest --acceptsettles both. Only a unit that sits in another (a block, hint, table or row) is checked: a section sits in nothing, so arefinesrelation from one, related by hand, states no fact the source could contradict.
a refines relation whose block now sits in another section is moved, naming the section
it sits in now, and fails, though neither end's version changed; one whose block still
sits in its unit, a retired one, and one from a section, which sits in nothing, is not
moved- Unselectable: a test that
mix test --only verifies:idcan't select by some of its declared ids (§11), with those ids. A list of several ids is legitimate, the only honest tag for a test that verifies several requirements, so this is information for choosing between a list and one id per test, never a failure.
status reports each test some declared id can't select, with its ids, without failing:
the text counts them in one line and the JSON lists each test- Deviating: a spec unit a test declares it verifies, where the test also declares a
deviation (
@tag deviation: "<ref>", §11). The spec is right and the code doesn't meet it yet; the test, written from the spec, fails, and is skipped with the reference to what will fix the code. That is notneeds_update(§14), where the spec is wrong and the code agrees with it. Status lists each deviating unit with its test and reference, and, given evidence (§17), the test's last result at its version. A proposed relation that deviates doesn't fail: one whose spec end is a deviating unit or a unit one sits in (the requirement whose code deviates), such as the unit'simplements, the deviation test's ownverifies, or another test'sverifiesthat a review would overstate. It stays proposed, markeddeviateswith the references, and is reported as a planned relation is. The check is then a gate a project can join while it fixes its deviations, failing on anything else. A status derived withdeviations: :fail(mix surfex.status --no-deviations) fails on any deviation, for a release. A deviation tag on a test that declares no spec unit deviates from nothing it names, and is broken. - Stale deviation: a deviating unit whose deviation test passed, at its version,
in the evidence given (§17): the code meets the unit now, so the tag comes off and the
relations are validated by the process. It fails, or tags would outlive their fixes. It
is judged only from a run where the test ran: a skipped test records
skipped, neverpassed, so without such a run a deviation is listed, not judged stale.
a unit a deviation-tagged test declares is listed as deviating with its reference and
test; its proposed relations and those of a unit it sits in don't fail and are marked
deviates, while other states still fail; deviations: :fail fails on any; a deviation tag
on a test declaring no unit is broken- Proposed: the tip is recorded with basis
proposed: a claim (a citation, a tag, a pair named by hand) that nothing has validated yet (§18). It fails the check, like a dangling relation, until evidence or a review validates it. - Unvalidated: a current
implementsorverifiesrelation whose tip has no validating basis (evidence,revieworjudgement, §18), such as one recorded before validation existed. It is reported, and fails the check only when the status is derived withvalidated: true(mix surfex.status --validated). - Marks (§14): a
needs_updatemark says the spec unit itself needs to change: the tests reflect it and the code passes them, but the result is wrong or clearly sub-optimal. Each mark is open while the unit is at the version it recorded, resolved once the unit's version moves (the spec changed, and the process starts over from it), withdrawn when a retire names it, and orphaned when the unit is no longer scanned. Open and orphaned marks are reported (Surfex.Status.derive/4'smarks); resolved and withdrawn ones live on in the history. - Stale: a live
excusesrelation whose code item its class no longer covers, judged bySurfex.Coverage.verdict/3as the suggestion was (§15). The item may now be implemented, no rule of any class may match it, or another class's rule may match it first. An excuse must stay true to its class's rules, not only to the versions it was confirmed at. Judging it needs the rules, whichSurfex.Status.derive/4takes ascoverage:.
Surfex.Status.failing?/1 is true when any relation is dangling, orphaned, conflicted or
proposed (unless it deviates), any id is unmet, any verifies relation is undeclared, any declaration is
unrecorded, any refines relation is moved, any declaration or citation is broken, any excuse or deviation is stale, any relation's
test failed, or any confirmation by evidence is unproven. New,
retired, impacted, planned, unimplemented, unselectable, deviating and unvalidated don't fail on
their own. A status derived with deviations: :fail fails on any deviating unit. A
status derived with planned: :fail (Surfex.Status.derive/4) fails on any planned
relation too: the check that everything planned has been built, such as a release's. One
derived with validated: true fails on any unvalidated relation: the check that every
relation was validated by the process (§18). Marks don't fail on their own either: a known
spec problem is backlog, not a broken build. One derived with marks: :fail fails on any
open or orphaned mark: the check that nothing ships with a known spec problem.
The triangle. A spec unit, its tests and its code relate three ways: the code
implements the unit, a test verifies the unit, and the test tests the code. When
tests are scanned, every spec unit that something implements is checked for each side
missing:
:no_test: no test verifies it, or any block or hint inside it;:test_misses_code: a test verifies it but exercises none of the code that implements it, naming the test;:code_untested: code implements it that no verifying test exercises, naming the code.
A test exercises code when a live tests relation says so, or when the test reaches
it. A call is a fact the source states, so the triangle doesn't wait for it to be
recorded, and a project that doesn't record tests relations sees no false gaps. Only
code that is scanned counts: a test calling a function not written yet still shows the
code missing.
What a test reaches (Surfex.Scan.reach/1) is the scanned code its calls (§11)
name, and the scanned code that code calls (§4 calls), transitively: a test calling a
public entry point reaches the functions it hands the work to. A call naming code a
record covers but that isn't a record (a module's hidden definition, §4 hidden), such
as a Mix task's undocumented @impl run/1, reaches that record and goes on through the
hidden code's calls; the hidden code itself is never a record. It is a pure function of
the scans. A spec unit can then cite the function that does the work while its test
drives the entry point that users call. Reach credits only relations someone recorded.
It never makes one, and a tests relation still names what the test calls itself (§15).
Code is compared by its definition (§11), so a test calling f/1 exercises the f/2 a
unit names when both are one function with a default argument. The same holds when
evidence confirms implements (§17) and when CI checks the claim. The gaps are the
status's triangle. They are reported, and fail the check only when
derived with triangle: :fail. Without scanned tests there is no triangle, since
every unit would otherwise report a missing test for a project that doesn't scan them.
Surfex.Status.summary/1 counts relations per type and state. Surfex.Status.units/1
counts the spec units scanned, by role: sections, blocks, test hints, tables and rows. The
reports show tables and rows only when a spec marks a table.
a relation whose end changed since it was confirmed is dangling,
and names which end changeda relation to an id that doesn't exist yet is planned and passes the check; when the id
appears a planned implements is proposed, and one recorded without a basis dangles; once
validated or confirmed it is currentgiven a spec unit something implements:
no verifying test, a verifying test that reaches none of its code, and implementing
code no verifying test reaches are each reported, naming the test or the codea test reaches the scanned code it calls and, transitively, the scanned code that code
calls;
a cycle ends, unscanned code is passed over, and code reached only through an entry point
closes the triangle without suggesting a tests relation to ita call naming a module's hidden definition reaches the module and what that definition
calls, never the definition as a record; a public function dispatching through its own
behaviour reaches what the hidden callbacks calla mark is open while its unit is at the marked version, resolved when the unit changes,
withdrawn by a retire naming it, and orphaned when the unit is gone; open and orphaned
marks are reported and fail only under marks: :failthe check fails on a dangling, orphaned, conflicted or proposed relation (one that
deviates excepted), an unmet id, an undeclared verifies relation, an unrecorded
declaration, a moved refines relation, a broken declaration or citation, a stale excuse or
deviation, a failed test's relation, or an unproven confirmation, and on nothing else by
default; planned: :fail adds planned relations, validated: true unvalidated ones, and
deviations: :fail deviating unitsthe spec units scanned are counted by role: sections, blocks, test hints, tables and rows,
and the reports show tables and rows once a spec marks a tablea verifies relation whose test no longer declares its spec unit is undeclared and fails;
suggest --accept retires it13.2 Configuration
Surfex.Status.Config reads .surfex.exs. It holds the profile keys (§8) and the
log's own: heading_ids (§11), rule_ids, fold_paths and unit_refs (§11.1),
scanners (§5.3), scanner, scanner_opts, namespace, goldens (§10.3), require,
tests, declares (§11.6), triangle,
require_red (§17), router (§11.3), claim_implements (§15.1),
spec_classes (§11.7), issues (§13.7), adoption (§18.1),
process (§19) and
completeness (§20).
Surfex.Status.Config.read!/1reads and validates it. It raises on an unknown key, and on a key of the removed trace, saying so (§9). Every task reads its config through it, so a misspelt key is never silently ignored.- Claim grain is how surfex reads a spec (§13.4, §13.5): there is no switch for it.
claim_grain:, the transitional key it shipped behind, is refused,trueorfalse: the message names the key, says claim grain is now the only reading, and points to the upgrade step andmix surfex.migrate --report(§14.1). A config that only drops the key reads as before. Surfex.Status.Config.items/2runs the code scanners and combines their items (§5.3): the built-in Elixir scanner, the project's own (Surfex.Scanner), or several.Surfex.Status.Config.scans/2scans each markdown unit ofsources, reading headings byheading_ids:(§11), folded byfold_paths:, and marked lists byrule_ids:(§11.1), each item the code scanners (§5.3) find, and, whentests:names globs of test files, each test in them (Surfex.Scan.ExUnit), its declarations in aunit_refs:form given as the full ids they name. A missingsources, or one that matches no section, raises. So does atests:that matches no file.Surfex.Status.Config.options!/1readstriangle: :report | :fail(:reportby default) forSurfex.Status.derive/4, and raises on any other value. Whenclasses:is present it also gives the class rules ascoverage:, for judging stale excuses. It is the project maintainer's intent, and a wrong value is loud: the gaps are reported either way.Surfex.Status.Config.load/3gives everythingSurfex.Status.derive/4needs from the config, scanning the code once: the scans, and the options, including the broken citations (Surfex.Status.Config.broken_citations/4). Those are read withSurfex.CiteunderSurfex.Status.Config.profile!/2: the config's profile keys (Surfex.Profile.keys/0) over the Elixir scanner's defaults fornamespace:, which defaults to the project's app name.Surfex.Status.Config.status/4derives the whole status asmix surfex.statusdoes: the scans, the log, the policy and the options (load/3), plus the caller's own options. Every task that needs the status derives it this way.Surfex.Status.Config.process!/1validatesprocess:(§19)::print, the default, or{:command, argv}with a non-empty list of strings, and raises on anything else.Surfex.Status.Config.require!/1validates therequire:policy against the known kinds, spec roles and types, and raises naming what's wrong.
A spec rule (a spec role's key such as section:, or the kind key spec:) judges claims
only (§13.4). A container isn't judged: it has no text of its own, and its claims answer
under their own roles. A claim is judged by its own relations, as any id is, so a
:contained claim that a verifies rule fails is unmet and says why: contained
(why: :contained, the JSON's why, and the report's contained: a unit inside it is verified, its own text isn't). require: [spec: [:verifies]] is then the one-line
policy that every claim is verified.
under require: [section: [:verifies]] a container isn't unmet, a contained section is
unmet with why contained, and a section with its own tag is met; spec: [:verifies] judges
rules and rows and skips containers.surfex.exs is read and validated: unknown keys and keys of the removed trace are refused,
the code is scanned once, and citations are read under the config's profilea .surfex.exs setting claim_grain: true or claim_grain: false is refused, naming the key,
saying claim grain is now the only reading and pointing to mix surfex.migrate --report;
the same config without the key reads13.3 Reports and the check
Surfex.Status.Report presents a status:
Surfex.Status.Report.text/1: the verdict, the counts per type and the spec units by role. Then each dangling, orphaned, conflicted, proposed (one that deviates with its references), impacted and planned relation, the deviating units (each with its location, reference, test, and the test's result in the run) and, apart, the stale deviations with what settles them, the unimplemented, new and unmet ids, the broken citations, the failed relations (each with its test), the unproven confirmations, the undeclared relations, the unrecorded declarations (each with its test's location, and what settles it), the movedrefinesrelations (each with its unit's location, where it sits now, and what settles it), the stale excuses, the broken declarations, the triangle's gaps and the open and orphaned marks (with their notes, who and when), with locations. A block or hint in a list says what it sits in. Unvalidated relations (§18) are counted; they are listed only when the status is derived withvalidated: true, where they fail, since a project moving over has many. Unselectable tests are counted in one line,unselectable by --only: N tests, and listed only in the JSON (unselectable, each test with its ids): a project that tags with lists has many, and the count is the decision's input.Surfex.Status.Report.golden/2(Surfex.Status.Report.golden/1writesRELATIONS.md): the status as a golden to commit. It has a table per relation type (end, other end, state, the ends that changed or are planned), then the unimplemented, new and unmet ids, the broken citations (by section, not line), the undeclared relations, the unrecorded declarations, the deviating units (unit, reference, test: the ledger of known deviations), the movedrefinesrelations, the stale excuses, the broken declarations, the triangle's gaps and the open and orphaned marks (unit, state, note), with the spec units by role among its stats. It holds no hashes and no times, so it changes when a relation's state changes, not on every confirmation. It's a pure function of the scans and the log, so a merge conflict in it is resolved by regenerating. A proposed relation that deviates reads:proposed· deviates. It leaves failed relations, stale deviations and unproven confirmations out, since they depend on the last test run, not on the source or the log, and validation too: it follows the relations' states, and a proposed relation shows as one.Surfex.Status.Report.json/1: the work list for tools and agents. For each relation, its type, state, impact and the references it deviates by (deviates), and per end the recorded hash, the current hash, whether it changed, whether it is planned, and its location. Plus the spec units by role, the unimplemented, new and unmet ids (each with its role, what it sits in and what it declares), the broken citations, the failed relations (failed, each with its type, ends and test), the unproven confirmations, the undeclared relations, the unrecorded declarations (unrecorded, each with its test, its spec unit, whether its relation was retired, and the retire's note), the movedrefinesrelations (moved, each withfrom,to, andnow, where the unit sits now), the deviating units (deviating, each with itsspec,test,ref, the test'srunresult or null, and whether it isstale), the stale excuses, the broken declarations, the triangle's gaps, the unvalidated relations, the unselectable tests (each with its ids) and the open and orphaned marks (unit, state, note, who, when, entry id, location). A value that doesn't exist (a planned end's recorded hash, a gone end's hash and location, or a conflicted relation's recorded hashes, since it has no one tip) is JSONnull.
Mix.Tasks.Surfex.Status is mix surfex.status:
--format text|json(text by default)--verifyverifies the log (§12.2) before loading it, so a tampered log gets its report, not the first decode error--evidencechecks every confirmation by evidence against the evidence the lastmix testrecorded (evidence:), and fails on any that is unproven, and on any relation whose test failed. It only reads.--no-plannedfails on any planned relation as well (planned: :fail). Which pipeline allows plans is the caller's decision, so it is a flag, not a setting--validatedfails on any unvalidated relation as well (validated: true, §18), and lists them--no-marksfails on any open or orphaned mark as well (marks: :fail), for a release that must not ship with a known spec problem--no-deviationsfails on any deviating unit as well (deviations: :fail), for a release that must not ship with a known deviation. Which pipeline allows them is the caller's decision, as with--no-planned--no-baselinefails on any relation resting on the baseline (baseline: :fail, §18.1), and the report counts them with the adoption mode--merge PATH, once per file, checks the confirmations by evidence against several CI jobs' evidence files together, in place of--evidence's one file (§17)- with
completeness: [min: N]in.surfex.exs, it fails when the overall completeness score (§20) is belowN, printing the score - it fails when the status is failing, or the log doesn't verify
- it compiles the project first only when
.surfex.exsnames a project scanner
the status reads as text for people, JSON for tools and a golden for review, the golden
with no hashes or times13.4 What verified means
Whether a unit's text is verified is a question about the spec, and only evidence aimed
at that text answers it. A claim inside a section says nothing about the section's other
text, and a section's own verifies says nothing about the claims inside it.
Own text, claims and containers. A unit's own text is what its version covers
(§11): its body without the finer units inside it (blocks, hints, rules, rows, contracts)
and without markers, so empty exactly when Surfex.Scan.Markdown.empty?/1 says so.
- A claim is a unit with own text: a section or marked block whose own text isn't empty, and every test hint, rule, row and contract.
- A container is a unit with none: a heading over subsections, a section whose whole body is named claims (a marked list, a marked table, hints), or a marked table, whose rows are its claims (its version is its header, which is structure). A container states nothing itself.
A unit is inside what it sits in (within, §11) and, for a section, the section
whose heading it sits under: the nearest heading above it at a higher level, a fact of
the scan (under, Surfex.Scan, recorded by Surfex.Scan.Markdown.sections/3), at any
depth. Fully decomposing a section's prose into named claims makes it a container: the
claims then answer for the section's text, each for its own.
naming a section's last prose paragraph as a rule turns the section from a claim into a
container: the section's version and the rule's change, and no other unit'sVerdicts. Surfex.Status.derive/4 gives every spec unit one verdict
(verdicts, read one at a time with Surfex.Status.verdict/2), from the current
verifies relations of tests and checks (§11.4):
| Verdict | When | What it needs |
|---|---|---|
:verified | a claim with a current verifies of its own | nothing |
:contained | a claim with no verifies of its own, where a claim inside it is :verified | its own evidence: its text is not verified |
:unverified | a claim with no verifies, own or inside | its own evidence |
:through_claims | a container, reported with how many of the claims inside it are verified | nothing of its own: its claims answer for themselves |
Two more verdicts, :excused and :deferred, are reserved for classes of spec text, a
section whose own text asserts nothing. A classed section with claims inside reads as a
container.
- No credit moves upward. A unit's own
verifiessays nothing about the claims inside it, so each claim answers for itself. - No credit moves into own text. A claim inside a unit says nothing about the unit's
other text.
:containednames that state, so a unit green only through containment is a fact the status states, not something a reader infers.
The verdicts come from one index of the units' within chains, built once per derive
(§13), not a walk per unit.
a section verified by its own tag is verified; one with prose and a verified hint inside
but no tag of its own is contained, naming the hint; one with prose and nothing is
unverified; a heading over two subsections, one verified, is a container with 1 of 2
claims verified; a section whose body is only a marked rules list is a container; a
marked table whose rows a check verifies is a container, through its rowsOn a container. A verifies whose spec end is a container counts for nothing: the
container has no text to verify, and its claims aren't verified by it. Each is listed
on a container (on_container), with the claims inside it, so the tag can move to
what it checks. The relation stays valid: moving the tag is the project's call, as with
an inert tag (§11.5).
a test tagging a heading-only section is listed on a container with the claims inside
it; it doesn't fail the status, and it verifies none of those claimsReports. None of this fails a status on its own. Surfex.Status.Report.text/1
counts the claims by verdict and the containers in a header line (claims: N · verified · contained · unverified · containers) and lists each contained claim (with its location
and the verified claims inside it) and each verifies on a container.
Surfex.Status.Report.json/1 gives verdicts (each unit's), contained, on_container
and containers (each container's claims and how many are verified). The status golden
(Surfex.Status.Report.golden/2) counts claims by verdict among its stats and holds a
contained table and an on container table.
13.5 Earning an implements
Whether code earns its implements is a question about the code, and it is answered by
the verified claim whose tests exercise it (§13.4). Surfex.Status.earning/2 gives the
answer for one implements relation, and Surfex.Status.earned?/2 says only whether it
earns. It is the one definition of "earned": a policy that counts only earned relations
reads it, as do the migration's candidates (§14.1).
An implements from code C to a spec unit U earns when:
- U is
:verified; or - U is a container, or
:contained, and a:verifiedclaim inside U has a verifying test that exercises C, or a verifying check whose inputs hold C (§11.4). A test exercises C as the triangle reads it (§13.1): it reaches C, or holds a livetestsrelation to it. A current review of the relation through a named path (--through, §18.2) counts as a verified claim's test exercising C.
Otherwise it is unearned, and the answer says why:
unverified: no evidence at all, neither on U nor on a claim inside it;not_exercised: a claim inside U is verified, but none of its tests or checks exercise C;nothing_inside: U is a container with no claims inside it.
Code compares by definition (§11), so a function's default-argument arities earn together.
Rule 2 is containment for code, bounded by evidence. A claim with no verifying test earns nothing, however many relations hang off it, and a verified claim earns only for the code its tests exercise: no credit moves upward from a verified section to the claims inside it, and none is given wholesale to whatever a verified claim's section names. An earned contained relation is exactly one the migration can re-home to its claim (§14.1). The answers come from indexes derive builds once (what each test and check exercises, the claims inside each unit), so asking about every relation stays linear.
code implementing a contained section earns when a verified hint inside it has a test
reaching the code, directly, through a function that calls it, or through another arity
of the same definition, or a verifying check whose inputs hold it; it is not_exercised
when the hint's test reaches only other code, and nothing_inside when the section is a
container with no claimsa rule inside a section the section's own tag verifies is unverified, and code related
only to that rule is unearned as unverifiedon one fixture, an earned contained relation is one the migration finds a candidate claim
for among the verified claims inside, and a not_exercised one has none13.6 Excused and deferred units
A class (§11.7) says a section's own text asserts nothing a test or check could fail on,
and a deferral says a unit's text isn't built yet. Surfex.Status.derive/4 lists both
whatever the configuration: excused (each classed unit with its class, the class's
reason, the marker's own reason, how it was selected, and the claims inside it) and
deferred (each deferred unit with its reference and reason). The status also reads
them, which is what can change what fails.
A class excuses a section's own text, and nothing else. Every named claim inside a classed section (a rule, a row, a hint, a contract, a marked block, a subsection) stays a claim and is judged as any claim is. A pattern can't hide what an author named.
- A classed section with no claim inside is
:excused, and a deferred unit with none is:deferred(C.1's verdicts, §13.4). With claims inside, either reads as a container: its claims answer for themselves, and code relating to it earns through them (§13.5). require:spec rules don't judge an excused or deferred unit, as they don't judge a container.- The triangle skips excused and deferred units.
- An
implementsto an excused or deferred unit earns nothing (Surfex.Status.earning/2saysunverified): there is no evidence to earn through.
a section classed by pattern holding a named rule, a marked table, a hint, a contract, a
marked block and a subsection: each stays a claim, unverified until a test verifies it,
and require: [spec: [:verifies]] fails on the claims, not the section; a classed section
with no claims inside is excused and not judgedContradicted. A class says the text asserts nothing, so a relation saying something
checks or realises it means one of the two is wrong. Each is listed as contradicted, and
the status fails:
- a live
verifieswhose spec end is a classed section, with claims inside or not (it should name the claims); - a live
implementswhose spec end is an excused section (one to a classed section with claims inside is a container's); - a test's or check's declaration naming a classed section, when no live
verifiesrecords it yet.
The fix is to retire the relation (it was narration) or to remove the class.
an implements and a verifies to an excused section, and a test declaration naming it,
are contradicted and fail; retiring the relation or removing the class clears it; an
implements to a classed section with claims inside isn't contradictedDeferred. A deferred unit isn't judged by require: spec rules, is out of the
completeness score (§20.2) and is listed with its reference and reason. A deferral
excuses the spec unit, never code: the MR that builds it adds the test and the
relations, and removes the marker.
a deferred section and a deferred block are deferred, listed with reference and reason,
not judged by require: spec rules; an implements to one is unearned as unverifiedSurfex.Status.Report.text/1 counts the excused units (and how many hold claims) and the
deferred units in one header line, and lists each contradicted relation.
Surfex.Status.Report.json/1 gives excused, deferred and contradicted.
13.7 Deferrals go stale
A deferral (§11.7) holds while its issue is open and the unit isn't built. One that no
longer holds is a stale deferral, and the status fails, as it does on a stale
deviation (§13.1). stale_deferrals lists each with
its reference and why:
built: the unit has a currentverifiesof its own (§13.4). It has its evidence now, so the marker comes off. This is judged with or without issue states.closed: its issue is closed. Remove the marker, or re-defer with the issue that now holds the work.unresolved: the caller asked the tracker and it couldn't say. A reference the tracker can't answer could never go stale.
with issues: %{"#12" => :closed} a deferral by #12 is stale and fails; :open isn't; an
unanswered reference is unresolved; without issues: the deferral is listed with no issue
state and not judged closed; a deferred unit with a verifies of its own is stale as built,
with or without issue statesHow surfex learns an issue's state. Surfex knows no tracker (§19), and
Surfex.Status.derive/4 is pure (§13), so an issue's state is an input, like evidence
(§17): derive takes issues: %{ref => :open | :closed | :unresolved}, and lists each
deferral's state beside it (issue, nil when none was given).
issues:in.surfex.exsdeclares how to ask, in the shape ofprocess:'s command:issues: [command: ["glab", "issue", "view", "{n}", "--output", "json"], state: "state", project: "group/project"].Surfex.Status.Config.issues!/1validates it.Surfex.Issues.states/3runs the command once per distinct reference, with no shell, substituting{ref}(as written),{n}(its number) and{project}(the reference's project, elseproject:). Withstate:the output is JSON and that key holds the state; without it the trimmed output is.openoropened, in any case, is open;closedis closed; anything else is unresolved. A command that exits non-zero stops the asking with its output: a tooling failure is loud, never a state.mix surfex.status --issuesasks once per run, throughSurfex.Status.Config.status/4, for the references the scanned spec's deferrals name, and passes the answer to derive. Without--issuesnothing runs, and onlybuiltis judged.- Nothing is stored. An issue's state is for the run that asked. A committed snapshot would be stale the day after.
Staleness is judged only where the command can run: a CI job whose token can't read the tracker leaves the judgement to a developer's check or a scheduled job that can.
a declared command printing opened, JSON {"state":"closed"} read with state:, and garbage
give open, closed and unresolved; {n}, {ref} and {project} are substituted with no shell;
a non-zero exit stops mix surfex.status --issues with its output; an unresolved reference
fails only under --issues; issues: is validated13.8 Excusals in the record
A class or a deferral is reviewed where it is written, in the spec and in .surfex.exs,
and recorded nowhere else: no log entry is written for one. So that a new excusal is
still seen, the status golden (Surfex.Status.Report.golden/2, RELATIONS.md) holds an
excused table (each classed unit, its class and how it was selected: marker,
pattern or alias) and a deferred table (each deferred unit and its reference), keyed
by unit id and ordered by it, plus contradicted and stale deferrals tables when there
are any. Its header counts excused, deferred, contradicted and stale deferrals
where a spec has any class or deferral, so a pattern that suddenly classes twenty units
shows as twenty rows and a moved count. The completeness golden (COMPLETENESS.md)
counts the units excused by class and deferred in its header (§20.2). Whether a growing
excusal is right is the project's call: a decision log should grow.
the status golden's excused and deferred tables are keyed by unit, in id order whatever
the scan order, counted in the header, and change only when a class or deferral does;
a spec with neither renders as before; the completeness golden's header counts both13.9 Unearned implements
An implements counts for a require: code: rule only when it earns (§13.5). A claim written only to own code, with nothing that could fail
behind it, satisfies nothing: the code it names is judged by the relations that do earn.
Surfex.Status.derive/4lists everyimplementsthat doesn't earn asunearned: its code, its spec unit and why (unverified,not_exercisedornothing_inside). A retired, orphaned or planned relation is judged by its state instead.- An unearned
implementsmeets norequire:rule. Code whose onlyimplementsare unearned failsrequire: [code: [:implements]]as unmet, and its entry sayswhy: :unearned. - Where the policy asks code for an
implements(require: [code: [:implements]]), every unearned relation fails the status, including one from code another relation already meets: the spare claim is what the rule guards against. Without that rule the list is reported and doesn't fail. - Credit never flows up from an enclosing unit: a claim inside a verified section earns nothing through the section's tag, and code earns through a verified unit, or through a verified claim inside the unit whose test exercises it (§13.5).
The fix for each depends on its why. unverified needs a test or check that verifies the
unit, or the relation retired; not_exercised usually means the code belongs to another
claim, and mix surfex.migrate --report shows which (§14.1); nothing_inside names a
container with no claims to earn through.
with require: [code: [:implements]], code related only to an unverified hint is unearned and unmet with why unearned, and the status fails; verifying
the hint clears both; code related to a contained section earns through a verified hint
whose test reaches it, and is unearned as not_exercised when the test reaches other code;
a spare unearned relation fails even when another relation meets the code; without the
rule the list is reported and passesSurfex.Status.Report.text/1 counts the unearned relations in a header line (marking it
failing under require: code:) and lists each with its why; Surfex.Status.Report.json/1 gives unearned (code, unit, why); and the status
golden (Surfex.Status.Report.golden/2, RELATIONS.md) holds an unearned table keyed by code and counts it in its
header.
the text counts unearned relations in its header and lists each with its why, the JSON
gives code, unit and why, and the golden has an unearned table and header count13.10 A contract stated differently in several files
A spec often states one function's contract in more than one file: in the chapter that defines it and again in a reference chapter. Within a file that is an error (§11.9); across files it is legitimate, but the statements drift apart unseen. A contract stated in several spec files whose statements differ is restated, listed and never failing.
- Two contract units state the same contract when their ids after the file are equal:
the same
Module.name/arityand the same kind, so a@callbacknever pairs with a@specof the same name (§11.9). - Their statements differ when their versions do (§11.1): a signature's text with its whitespace collapsed, so reflowing one changes nothing.
Surfex.Status.derive/4givesrestated, one entry per such contract,%{contract, units}with the units' ids sorted, whatever its other units' versions; the same statement in several files is not listed. It never makes the status fail.Surfex.Status.Report.text/1lists each with the units stating it, the JSON givesrestated(contract,units), and the status golden holds arestatedtable and counts it in its header, each only where there is one.
one contract stated in two files with different signatures is listed with both units, and
the status doesn't fail; the same statement in two files is not listed, nor are two
functions of one name in two modules, nor a callback and a spec of one name; a statement
differing only in whitespace is the same; text, JSON and golden show the list only where
it has an entry13.11 A deviation holds back what it sits in
A deviation (§13.1) says the code doesn't meet a unit yet. Code related to a section that
holds that unit doesn't meet the section either, however green its other tests are. The
deviation holds back the units it says that of, and nothing validates or earns an
implements to one of them while the tag is on.
Surfex.Status.derive/4givesheld: each spec unit a deviation holds back, with the references of its deviations, sorted. That is every deviating unit and each unit it sits in (within), at any depth, and each section it sits under (under). A proposed relation is markeddeviateswhen its spec end is held, so a section above the deviating one is marked too.mix surfex.confirm --evidence(Surfex.Record.confirm_by_evidence/4) doesn't record animplements, dangling or proposed, whose spec unit is held, whatever the evidence says. It lists each one the evidence would have validated asheld back by a deviation, with the references (Surfex.Record.evidence_outcome/4). Other relations are confirmed as before: a deviation test's failing run is still itsverifiesrelation's evidence.- Once the tag goes and the test passes, nothing is held, and the next evidence run validates them as it would have.
with a deviation tag on a hint, confirm by evidence records neither the hint's nor its
section's implements and lists both as held back with the reference, while it still
records the verifies; a section the deviating one sits under is held too; with the tag
off and the test passing, the next run validates themAn implements whose spec unit is held is unearned (§13.9),
asked before anything else: Surfex.Status.earning/2 answers {:unearned, :deviates}.
So unearned lists it with why deviates and it meets no require: code: rule; code
with no other implements that earns misses unearned in completeness (§20.3). Status
and confirm agree: what confirm holds back doesn't earn.
an implements to a deviating hint, to its section, or to a section above it is unearned
as deviates, even when a verified claim's test exercises the code; the text names the why13.12 Lines that are not claims
Everything in a section that isn't a finer unit is its own text (§13.4). A spec that names
every behavioural statement as a claim can still keep lines that state nothing a test could
fail on: a provenance line (Legacy reference: …), or a sentence naming the code a section
is realised by. Such a line keeps the section a claim with no evidence of its own, contained
for good. Classing the section (§11.7) would excuse it, and with it any behavioural sentence
added later, which no one would then judge. The spec's author knows which lines these are,
so the project declares them, where config review sees it:
not_claims: [~r/^Legacy reference: \S+$/]in.surfex.exs(Surfex.Status.Config.not_claims!/1) is a list of regexes. Anything else is refused.Surfex.Status.Config.scans/2passes it toSurfex.Scan.Markdown.sections/3asnot_claims:.- Each non-blank line of a section's or marked block's own text is matched as written, one line at a time, against the patterns in order; the first that matches takes it. A statement wrapped over several lines needs each line matched, and a pattern on a prefix alone takes whatever the line says after it, so anchor it to the whole line. Hints, rules, rows and contracts are always claims and are never matched.
- A matched line is left out of the unit's own text for verification (§13.4) and
completeness (§20) only, read with
Surfex.Scan.Markdown.claimless?/1: a unit is claimless when its own text is empty or every line of it was matched. A section left with no own text is a container and answers through its claims. A sentence added later that no pattern matches is own text again, so the section is a claim, contained when a claim inside it is verified. - Versions are unchanged. A unit's version still covers every line, matched or not, so editing a provenance line still dangles the unit's relations.
- A paragraph that only names code is not left out by rule. A project that wants that writes a pattern for it, so the choice is visible in config, and a behavioural sentence that happens to name code stays a claim.
- The scan records what was matched on the unit (
not_claims,Surfex.Scan):%{lines, own_text}, the lines each pattern took (by the pattern'sinspect/1form) and whether any own text is left.Surfex.Status.derive/4givesnot_claims, one entry per pattern,%{pattern, lines, units}, the configured ones in order and a pattern matching nothing with0, so a pattern that starts swallowing prose, or matches nothing any more, shows.Surfex.Status.Report.text/1lists each pattern with its lines and units, the JSON givesnot_claims, and the status golden holds anot claimstable, each only wherenot_claims:is set. Information, never a failure.
a section whose own text is only a provenance line, with a verified hint inside, is
contained without not_claims: and a container with a pattern matching that line; its
version and the hint's are the same either wayadding a sentence no pattern matches beside the matched line makes the section a claim
again, contained by its verified hint, and completeness counts itderive lists each pattern with the lines it matched and the units holding them, and a
pattern matching nothing with 0; text, JSON and golden list them only where not_claims: is
set; a not_claims: that isn't a list of regexes is refused13.13 A deferral may cite a spec unit
Some deferrals are spec decisions, not tracked work: a feature "deferred past v1", with the reason recorded in a decision table. No issue exists for it, and none should, so a deferral (§11.7) may cite the spec unit that records the decision instead of an issue:
## 6. View abstraction
<!-- surfex: deferred spec/06-architecture.md#deferred@1a2b3c4d: past v1 --><!-- surfex: deferred UNIT[@VERSION][: REASON] -->: a reference that isn't an issue (#123,group/project#123) cites a spec unit, in any form a test's declaration may name one (§11, the project'sunit_refs:forms included). A reference starting with#is always an issue. The reference holds no:or@.- It must resolve.
Surfex.Scan.Markdown.records/4resolves each citation against the whole spec once every file is scanned (Surfex.Status.Config.scans/2passes it the project'sunit_refs:), and one that names no unit, or several, raises naming the file and line, as a malformed marker does. The cited unit must be insources:. @VERSIONpins the cited unit's version (8 lowercase hex characters, §11) as the author last judged it. Anything else after@raises.- The scan records the citation on the deferred unit as
%{ref, reason, cites, pin, line}(Surfex.Scan):citesthe cited unit's full id. An issue deferral's record is unchanged,--issuesasks only issue references, and a citation'sissueisnil.
a deferral citing a unit by full id, by bare anchor or by a unit_refs: form records the
cited unit's id, its pin and its reason; one citing no unit or several raises naming the
file and line; a malformed pin raises; an issue deferral's record is as beforeIt goes stale when the cited unit changes. A citation is a stale deferral (§13.7) when:
changed: the cited unit's version isn't the pinned one. The decision text was edited, so the deferral is judged again: write the new version in the marker, or remove it.unpinned: the marker pins no version. The status names the version to write.gone: no unit has the cited id any more (a scan withrecords/4raises first).built, as for every deferral.
The text report says what to write and on which line; the JSON's stale_deferrals carry
pin, version and line, and its deferred carry cites and pin, for citations only.
a citation pinning the cited unit's version holds; editing the
cited unit makes it stale as changed, naming the new version; without a pin it is
unpinned; with the unit gone it is gone; a built unit is built; issue deferrals are judged
as before and only issue references are asked of the trackerWhy a pin, and not only disappearance. Nothing is stored in the log for a deferral (§13.8), so what the author judged has to live in the marker. Going stale only when the cited unit disappears would need no pin, but a deferral would never be judged again when its decision text is edited, which is the point of citing it. The pin costs one hash in the marker, rewritten by hand from the status report each time the deferral is judged again. Nothing needs a tracker, so it is judged offline and in every CI job.
14. Recording
Surfex.Record turns a decision into the entries to append. It's pure: the tasks read
the scans and the log, and append what it returns. Every function returns the entries
or the reason there are none. Nothing edits or removes an entry: a new entry
supersedes the relation's tips by naming them as parents.
Ids are scan ids (MyApp.Cart.add/2, spec.md#Carts/Adding items). A spec:,
code:, test: or class: prefix picks between kinds when an id is scanned as more than
one. An id that isn't scanned is an error, since the relation would be orphaned from the
moment it was recorded. Every entry carries who recorded it (git's user), HEAD at the
time (context only), and when.
| Function | Records |
|---|---|
Surfex.Record.relate/6, Surfex.Record.relate/7 | a relation of a type between two ids at their current hashes, from → to for a directed type, superseding the relation's tips. implements is recorded as proposed; verifies too, unless the test's current version has failed in the evidence given (evidence:), when it is recorded on that evidence (§18) |
Surfex.Record.plan/7 | a planned relation: one end is scanned and recorded at its hash, the other doesn't exist yet and is recorded without one. Its kind is its spec:/code: prefix, else spec when it has a #, else code. A plausibility check refuses an id the project couldn't have, so a typo doesn't become a permanent plan. mix surfex.relate --planned asks whether a spec id is in a file the scanner reads, and whether a code id has the scanner's shape (for Elixir, a name under one of the project's namespaces). Both ends scanned, or neither, is an error. |
Surfex.Record.confirm/6, Surfex.Record.confirm/7 | one dangling or proposed relation, named by its ends and type, again at the current hashes, with a note (required) saying what was judged: basis judgement. implements is refused, since code is validated by evidence or a review (§18), never asserted, unless its code end is a shape, which no run exercises. Orphaned and conflicted relations aren't confirmed: they need re-pointing or resolving. Under require_red: (opts, §17) a tests relation isn't confirmed until its test's current version has discriminated |
Surfex.Record.validate/6 | a review (§18): the verifies relation from a test to a spec unit, and each implements relation of that unit whose code the test calls (§11), or reaches (§13.1) and the review names (meta[:code]), and whose version the test's green run recorded, by definition, so one arity covers a function's others (§11), each only if it isn't validated already, validated at the current versions, basis review; with nothing left to record it is refused. The unit may be a section whose block or hint the test verifies: that relation must already be current, and the review records the section's implements relations alone |
Surfex.Record.confirm_by_evidence/4 | every confirmation the test evidence justifies (§17, §18), basis evidence, with the evidence in the note: each dangling or proposed verifies relation whose test's current version has failed (unless only the spec changed, which is a judgement), then each dangling tests relation and each dangling or proposed implements relation whose test went red and then green. Nothing justified is {:ok, []}, not an error |
Surfex.Record.annotate/6 | a new note on one current relation: a re-recording at the tip's own versions, with its basis, parented on every tip, so a re-review that changes nothing still lands in the log rather than a commit message. A dangling or proposed relation is refused (confirm settles it), and the note is required |
Surfex.Record.retire/6 | that a relation no longer applies, naming every tip as a parent, with the ends as the tip recorded them. An end need not still be scanned: this is how an orphaned relation is put to rest. A pair never related is declined: a retire at both ends' current versions with no parent, recording the decision not to relate them, so mix surfex.suggest never proposes it. Both must be scanned, and the note is required, as the only record of why. A later relate revives it. |
Surfex.Record.resolve/7 | the chosen tip of a conflicted relation, recorded again with its basis and every tip as a parent: picking a side judges nothing new. Tips that agree are no conflict, and it refuses them. If the scans have moved since, the relation is then dangling, and confirming it is next. |
Surfex.Record.move/5 moves every live relation of an id onto a new one: a renamed
heading, an anchor added, a section moved. For each relation whose tip names the old id
it appends a retire of that relation, and a relate of the same type with the new id
in the old one's place, both noting the move. The moved end keeps the hash recorded
for the old id, and the other end its recorded hash, so a move carries a judgement
across and never makes one. If the text changed as it moved, or the other end changed,
the moved relation dangles. The new id must be scanned. A conflicted relation must be
resolved first. An old id with nothing to move is an error.
A retirement moves too. A retired relation is a decision with a reason, and a rename
is no reason to forget it: for each retired relation of the old id, the move records a
retire of the same relation on the new id, at the recorded versions, its note naming
the original retire and its reason. It is skipped where the new id already has that
relation, so it never retires a decision taken since. mix surfex.move reports how many
live and retired relations came across, and mix surfex.suggest finds a move whose old
id only retired relations name (§15), so a declined pair stays declined.
Moving a test carries its records. A test's version is its content (§11): renaming
its module, its describe or its file leaves the version as it was, under a new id. Each
red_green and baseline observation (§12.1) of the old id at the version the new id is
scanned at is recorded again on the new id, with its type and basis and a note naming the
original entry, so the test stays discriminated and, under trust, baselined. A test with
such records and no live relation still moves. An observation at another version stays
behind (Surfex.Record.left_behind/4, which mix surfex.move lists): the test changed, so
it earns it again. Renaming the test itself changes its version, since its name is part of
the test node that is hashed, so its records stay behind too.
Surfex.Record.mark/5 records a needs_update mark on one spec unit, at its current
version, with a note (required) saying what is wrong in the result. The id must be a
scanned spec unit. Surfex.Record.withdraw/5 withdraws its open mark, with a note saying
why it proved unfounded: a retire naming the mark as its parent. With no open mark it is
an error; with several, the one to withdraw is named by an id prefix (pick:).
Surfex.Record.history/2 lists every entry touching an id, oldest first, from the log
alone.
The tasks:
Mix.Tasks.Surfex.Relate:mix surfex.relate FROM TO --type T [--planned] [--note N]Mix.Tasks.Surfex.Confirm:mix surfex.confirm FROM TO --type T --note N, ormix surfex.confirm --evidenceMix.Tasks.Surfex.Validate:mix surfex.validate TEST SPEC_UNIT --note NMix.Tasks.Surfex.Annotate:mix surfex.annotate FROM TO --type T --note NMix.Tasks.Surfex.Retire:mix surfex.retire FROM TO --type T [--note N]Mix.Tasks.Surfex.Resolve:mix surfex.resolve FROM TO --type T --pick ID_PREFIXMix.Tasks.Surfex.Move:mix surfex.move OLD NEW [--note N]Mix.Tasks.Surfex.History:mix surfex.history IDMix.Tasks.Surfex.Mark:mix surfex.mark SPEC_UNIT --needs-update --note N, ormix surfex.mark SPEC_UNIT --withdraw --note N [--pick ID_PREFIX]
Each reads .surfex.exs as mix surfex.status does. Each needs the log to exist
(mix surfex.log --init), and prints every entry it recorded.
confirming a relation with nothing to confirm is an error, never a silent no-opa move records the recorded versions under the new id: if the text changed as it
moved, or the other end changed, the moved relation danglesmoving a test whose version is unchanged carries its red_green and baseline records to
the new id, which is then discriminated and baselined; a carried baseline is no new one;
a changed version carries nothing and says what stayed behinda move carries a retired relation onto the new id as retired, with its reason, and never
over a relation the new id already has; suggest still finds the move and goes on
skipping the declined pairretiring a pair never related records the decision not to relate it, at both ends'
current versions, and needs a note; suggest never proposes it again, and a later relate
revives it with the retire as its parentmark records one needs_update mark at the unit's current version with a required note;
withdraw retires the open mark, naming it when there are several; both refuse what
isn't a scanned spec unit, and history shows themrelate, confirm, validate, retire, resolve, move and history each record or read entries by
name, and never edit or remove oneannotate gives a current relation a new note at its own versions and basis, keeping it
current; a relation that isn't current, or a missing note, is refused14.1 Migrating to claim grain
A project whose implements relations are on sections moves them to the claims inside
(§11.1) section by section, as work touches a section, never wholesale. A section's
existing relations are the input of its migration and of nothing else (§15.1).
Surfex.Migrate is the aid, and Mix.Tasks.Surfex.Migrate is mix surfex.migrate.
mix surfex.migrate SECTION lists, for each live (current, dangling or proposed)
implements between code C and the section, or a table in it, the claims inside that
unit that could own C (Surfex.Migrate.proposals/3). --within PREFIX takes every section
whose id starts with PREFIX, such as one spec file (Surfex.Migrate.sections/3). A claim is
a candidate when:
- it names C: a row whose key is C's id, or C's name with no arity, which names every
arity (
Mod.funisMod.fun/*, since one row may honestly state one claim over both arities); a contract unit whose function is C; - one of its verifying tests calls C directly: a test with a live
verifiesto the claim or a unit inside it, or a declaration resolving there, whosecalls(§11) name C, or name a hidden definition of C's module; - failing both, for any claim, one of its verifying tests reaches C (§13.1) through an item it calls, named in the listing. A test reaching a helper through a public entry point is a weaker sign than a call, so reach counts only where nothing names or calls C.
A check (§11.4) verifies a claim but calls nothing, so it proposes nothing.
Arities of one definition are one proposal. A function's default-argument arities share a definition (§11), and one review credits both (§18.4), so they are proposed, and judged, together, said to be one definition.
Each proposal is one of:
- single: one candidate claim;
- several: the candidates are listed, never decided: which claim code realises is a judgement;
- none: no claim inside the section owns C. It is realised by a claim not written yet, or it doesn't belong to the section. These are the spec work the migration surfaces, so they are listed first.
The listing writes nothing.
a section's section-grain implements are each proposed to the claims inside it that name
the code (an arity-less row key every arity), whose tests call it, or failing those reach
it, naming through what; arities of one definition are one proposal; none is listed first,
then several, then single; --within takes every section under a prefixRecording a migration. mix surfex.migrate SECTION --accept [--note N]
(Surfex.Migrate.accept/4) records each single proposal: an implements between the code
and its claim, proposed like any pair named by hand (§18), with the entry's migrates
naming the section, or table, it moves from (§12.1). --code ID --to CLAIM (--to once
per claim, Surfex.Migrate.choose/6) records a reviewer's choice the same way: one or more
of several candidates, or the claim written for code that had none. It takes every arity
of ID's definition that the section relates, and refuses a claim that isn't inside the
section. Where a claim already holds a validated relation to the code, nothing is left to
wait for, and the section's relation is retired at once.
- Migrating. A proposed relation recorded with
migratesdoesn't fail the status (an exception to proposed, §13.1) while the relation between the same code and the section it names is live. So a section can move across several commits with the check green, rather than in one commit that relates, validates and retires everything at once. The status lists each code item moving (migrating: code, section, claims, and whether it is settled) in its text, its JSON (migrating, and each relation'smigrates) andRELATIONS.md(amigratinggroup, and the flag on the relation), where there are any. - Settled. The claim relation is validated as any
implementsis: by evidence, a review, or a review naming a path (§18). Once every claim relation of a code item from a section is validated, the pair is settled:mix surfex.suggestproposes retiring the section's relation and--acceptretires it, asmigrate SECTION --acceptdoes (Surfex.Migrate.settle/4), with a note naming the claims it moved to (Surfex.Migrate.settled_note/2). A section never loses a relation before its claim holds one. Retired another way first, the section no longer holds it, and the claim's proposed relation fails as any proposed relation does. - A pair once declined, a retired relation between the code and a claim, is no candidate:
undoing a retirement takes someone choosing to, with
--code --to. - A baseline is carried. A section relation resting on the baseline (§18.1, basis
baseline, while it holds) moves to a claim whose verifying test is the same baselined test, at the version the baseline trusted, exercising the same code: a liveverifiesfrom it to the claim or a unit inside it, and a currenttestsrelation to the code. The claim relation is then recorded keeping basisbaseline: the trust already given covers the same test and the same code. It holds at once, so the section relation is retired in the same--accept(or--code --to). Any other claim relation is proposed, and needs evidence or a review. A project that took its baseline at section grain starts its migration from it rather than redoing it. The task readsadoption:for this, as status does.
a baselined section relation migrates keeping basis baseline, and the section's retires at
once, only when the same baselined test at its trusted version verifies the claim and
exercises the code; a changed test version, or another test, makes the claim relation
proposed--accept records each single proposal proposed with migrates, which doesn't fail while the
section holds its relation and is listed as migrating in the reports; once the claim's
relation is validated the pair is settled, and accept or suggest retires the section's,
noting the claim; retired early, the claim's relation fails; --code --to records a chosen
claim and refuses one outside the section; a claim already holding the code retires the
section's at once; a declined claim is no candidateWhat migration buys. mix surfex.migrate --report [SECTION…] is read-only
(Surfex.Migrate.report/3). For each section:
- its relations, of every type, now and after migration: what one edit of its text
dangles. After is now less each section-grain
implementswith a candidate or a claim it is migrating to; - its section-grain
implementsby outcome (single, several, none, or migrating already); - the marking: a prose section, one with no claim inside it that is a unit yet, needs its claims named before anything can move (§11.1), and the first marker or id comment re-versions the section, dangling all its relations at once. It is counted, since it is paid even for relations that don't move. A section whose claims are already units needs none;
- the dangle width of each proposal: the relations a change to the text stating its claim dangles, now (the section's, or the table's) and after (the claim's own and the moved ones; for several, the widest candidate's; for none, the section's after).
Each section's totals come first, then its proposals, widest after first. With no section
it reports every section holding a section-grain implements, worst first (most
relations), after the population: how many of those sections, and their implements,
have claims that are already units (tables, hints, blocks, rules or contracts), which
migrate today, and how many are prose, which need their claims named first.
the report gives a section's relations now and after, its implements by outcome, the
marking a prose section needs and none for one with units, and each code item's dangle
width now and after, widest first; with no section, every section holding section-grain
implements, worst first, and the population split into units and prose14.2 The migration work list
The report (§14.1) is the migration's one work list: it lists, too, the sections whose
own text has no evidence while a claim inside them is verified, the :contained sections
of §13.4, read as the status reads them (the project's classes, §13.6, decide what is
contained). Surfex.Migrate.report/4 takes Surfex.Status.derive/4's
options, and the task passes the project's.
- Each contained section's report names the verified claims inside it, from the
status'
containedlist, sorted: acontained:line in the text,containedin each section of the JSON. A section that isn't contained names none. - With no section named, the report covers every contained section as well as every section
holding a section-grain
implements, including contained sections that hold noimplements: they are settled by a test of their own, by naming their claims, or by a class (§20.1), never by moving code. The population counts the contained sections. --format json(Surfex.Migrate.json/1) gives the same report, the population and each section with its counts,containedand dangle widths, for tools. It is the report's form: without--reportit is refused, as is any format but text or json.
the report names the verified claims inside each contained section, none for one that isn't
contained; with no section named it includes a contained section holding no implements and
counts the contained sections in the population; --format json gives the same report and is
refused without --report15. Suggesting relations
Surfex.Suggest proposes relations from what the spec already says. Its candidate
implements relations come from citations. Surfex.Suggest.candidates/5 reads the spec's
citations (§6) under the project's profile (Surfex.Status.Config.profile!/2),
and for each resolved citation proposes the pair (the innermost spec unit
containing the citation's line, each code item it names). A citation in a marked block
relates the block, not its section. A family names every member. Left out:
- a pair already related, in any state, including retired: suggesting it again would overrule a decision already on record
- a citation inside a fenced code block, which has no line to place it in a section
- a named item that is not a code scan
Each candidate records where it was first cited. Surfex.Suggest.accept/4 records one
relate per candidate, at the current hashes. It only ever creates relations that don't
exist. It never confirms a dangling judgement: a dangling implements, verifies or
excuses relation is settled by evidence, a review or a named confirm (§14, §18).
Surfex.Suggest.all/5 gives every suggestion at once, computed together so that none
repeats another:
- moves: a spec or test id the log knows that is no longer scanned, and an id of the
same kind with no records of its own at the same version: a renamed heading or an
added anchor; a test whose module,
describeor file was renamed, as when a test file is split into sub-modules. A test is known by its relations, or by its records alone (§14). Accepting carries the relations and a test's records (Surfex.Record.move/5). Only one-to-one matches are suggested. A version found under several gone or new ids is ambiguous: reported, never suggested, since which is which is a judgement (mix surfex.movedoes it by hand). A section renamed and reworded at once is not recognised, and neither is a renamed test, whose name is part of its version: their old relations stay orphaned for review. - code moves: a code id with relations that is no longer scanned, and a new code item
of the same module and name with another arity, with no records of its own: a
function whose arity changed (
render/2becamerender/3). Its version changed with it, so the version rule above can't see it. One to one only; several candidates are ambiguous. A code move carries the old end's recorded version like any move, so the moved relations aren't current until judged again (mix surfex.moveworks for any id, code included). - refines: each marked block and test hint
refinesthe unit it sits in (within, §11), unless that relation exists in any state. - implements: the candidates above.
- verifies: each test's declaration (§11) that resolves to a spec unit.
- tests: each test paired with each scanned code item in its
calls(§11). - refresh: each dangling structural relation whose fact the source still states: a
testsrelation whose test still has the code in itscalls, arefinesrelation whose block or hint is stillwithinthe unit. These relations record what the source says, not a judgement, so reading the source again re-establishes them. Judgement relations are never refreshed. - undeclared: each undeclared
verifiesrelation (§13.1), which accepting retires: the test's own source says the claim is gone. - moved: each moved
refinesrelation (§13.1), which accepting retires: the unit no longer sits in what it refines. The relation to where it sits now is suggested under refines, so one--acceptrecords the new relation and retires the old. A danglingrefinesrelation whose unit moved is never refreshed (its fact is gone) and is retired here too. - excuses: each code item that nothing implements, and that no
implementscandidate in the same run will, paired with the class of the first rule it matches. Matching isSurfex.Coverage's (§7), where a parent counts as cited when something implements it. Anever_excusedkind is never proposed. Withrequire: [code: [:implements, :excuses]], every public item is then either described or deliberately excused, and both are on record. An excuse dangles when its item or its class changes.
A judgement suggestion shows how to decline it. Under each implements and excuses
candidate, mix surfex.suggest prints the exact command that declines it
(Surfex.Suggest.decline_command/3): a retire of the never-related pair, ids
kind-prefixed and quoted, with a note to replace. That records the decision, so the pair
is never proposed again (§14). Declining in a commit message or an MR leaves the log
without it, and the suggestion comes back. Structural suggestions (tests, refines, a
refresh) state what the source says, so they have no decline; a wrong verifies is fixed
in the test's tag.
Suggesting grows with the log in proportion. An agent runs it on every work item, so its work over a log twice as large is about twice, not four times: each relation's judgement in force is read from that relation's own entries.
suggest's work over twice the relations is about twice the work, counted in reductionsa judgement suggestion's decline command retires the pair with ids kind-prefixed and
quoted, and a quote in an id escaped; a structural suggestion has noneThe others are computed as if the moves were already recorded, so a moved
section's relations are not suggested again under its new id.
Surfex.Suggest.accept_all/5 records them (its options carry the test evidence): each
move (Surfex.Record.move/5), then each other suggestion as a relate at the current
hashes, except an undeclared or moved relation, which is retired. Accepting validates nothing:
an implements, verifies or excuses relation is recorded as proposed, since a
citation, a tag or a matching rule is a claim, not a judgement (§18). A verifies
relation whose test has already failed at its current version is recorded on that
evidence.
Mix.Tasks.Surfex.Suggest is mix surfex.suggest [--accept] [--note N]: without
--accept it lists every suggestion and writes nothing. Adopting the relation log is
mix surfex.log --init followed by mix surfex.suggest --accept.
a pair already related, in any state, is never suggested again,
so accepting suggestions never confirms a dangling relationa function whose arity changed is suggested as a move, and the moved relation isn't
current until judged again; two candidates of one name are ambiguousa test id gone from the scan whose version one new test id has, with no records of its
own, is suggested as a move, and accepting carries its relations and records; a version
under several new ids is reported as ambiguous and not suggestedan anchor added to a related section is suggested as a move, and its relations
aren't suggested again; two new ids at one version are not suggestedafter a marked block moves unchanged into another section, suggest proposes the refines
to the new section and the old one's retirement, and accepting both leaves the status
with nothing moveda dangling tests relation whose test still calls the code is refreshed on --accept; one
whose test no longer calls it is not, and a dangling implements relation never issuggest proposes relations the spec and tests already imply and records them only on
--accept15.1 Claim relations from a claim's tests
A citation is how a spec names code, not why code belongs to a claim. Code realises a
claim (§11.1) when the claim's tests exercise it, so suggest also proposes implements
from what a claim's tests call. It is opt-in until the release that completes claim
grain: claim_implements: true in .surfex.exs (Surfex.Status.Config.claim_implements?/1,
false by default; anything but a boolean is refused), passed as claims: true to
Surfex.Suggest.all/6. Off, nothing below is proposed and reached is empty.
- A claim is a spec unit of role block, test hint, row, rule or contract. A section is not one.
- Its verifying tests are the tests with a live
verifiesrelation to it or to a unit inside it, and the tests whose declaration (§11) resolves there. - Proposed: each scanned code item a verifying test calls directly, its own
calls(§11): its body's and its private helpers' calls, and the requests, events and messages §11.3 resolves. A call naming a hidden definition (a LiveView'smount/3, a server'shandle_call/3) proposes its module, the item that versions it (§3.3). A module the test names by itself is not proposed: a test names every module it calls into. Each candidate lists its tests (tests), and the task prints how many and its decline command (§15). - Reached code isn't proposed item by item. For each code a claim's tests call that
reaches further code (§13.1) the test doesn't call itself,
reachedlists one line,%{spec, through, count}:reached spec.md#x through MyApp.Protocol.handle/2: 41 more items; name the ones that realise this claim with mix surfex.relate. It is information for the person relating the claim, never recorded and not counted as a suggestion. A single-dispatch core is one line, not every module behind it. What a call reaches isSurfex.Scan.reacher/1's answer, the closureSurfex.Scan.reach/1follows for a test, asked once per called item. - A claim with no verifying test gets nothing: a claim without evidence earns no relation.
- A pair related in any state, a declined (retired) one included, is left out, as are
the pairs a citation already proposes. Accepting records each as proposed, as for any
implements(§18).
The filter is the direct-call rule, not the section. Decided at #206's evaluation: a
claim's proposals are bounded by what its test author wrote, plus the one-line reached
summary. A section keeps no permanent coarse implements as the pool its claims draw
from: that would keep both grains alive, and with them the section-wide dangling waves
a section-grained relation causes. A migration aid may read a section's existing
relations as its input while the section is migrated; that ends with the migration.
a claim's verifying tests' direct calls are proposed, a hidden callback as its module and
a module named alone not at all; code reached only through a called item is one reached
line with its count; a claim with no verifying test, or a section, gets nothing; a
declined pair isn't proposed again; nothing without claim_implements: true15.2 Classed text suggests nothing
A classed section's citations are narration: an architecture map names modules to orient a
reader, not to say what they realise. mix surfex.suggest (Surfex.Suggest.all/6)
proposes no
implements from a citation in a classed section's own text, and no verifies to a
classed unit from a declaration, which is contradicted instead (§13.6). The citation is
still read and checked: one that resolves to nothing is broken (§6) whatever its section's
class. A citation inside a named claim within the section, a block or a hint, is the
claim's and is suggested as before.
a citation in a classed section's text yields no implements suggestion and a broken one
is still reported; a declaration naming the classed section yields no verifies
suggestion; the same citation unclassed does16. The specification guide
guides/writing-specs.md is a guide to writing a specification that Surfex can relate
precisely. It covers:
- what the scanner sees
- sizing sections to what changes together, and that a section's prose is a claim (§13.4): name its claims, class it, or verify it
- keeping headings stable
- naming the code in the section that describes it, and relating code to the claim it
realises rather than to every section that mentions it, by hand where no citation
proposes the pair (
relate, thenvalidate, with--throughfor a path no call shows) - stating requirements as checkable claims
- naming a claim smaller than a section (§11.1): which form for which claim, and why bold is never one
- examples and invariants
- test-first work with Surfex
- excusing code the spec shouldn't describe
- working with an LLM
- a worked example
- a checklist
It ships in the package and the docs.
Its worked example is a claim about Surfex's behaviour, so it is tested. The guide's two
versions of a spec are scanned, related as mix surfex.suggest --accept would relate
them, and then edited. The test asserts exactly the dangling, current and orphaned
relations the guide describes.
16.1 Text the spec doesn't verify
Beside "Code the spec doesn't describe", the guide has "Text the spec doesn't verify": when to class a section (§11.7) rather than verify it, when to name a claim instead, that a class never hides a claim named inside its section (§13.6), that a rule a tool checks is verified by a check (§11.4) rather than classed, and when to defer a unit by its issue.
the guide has a "Text the spec doesn't verify" section beside "Code the spec doesn't
describe", saying a class never hides a named claim, pointing at checks for tool-verified
rules and at deferrals by issue17. Test evidence
People or agents judge meaning, and evidence judges behaviour. A test run shows whether a test passes against the code as it is. Recorded at the exact versions of the test and the code, that is evidence a confirmation can rest on.
Surfex.Evidence is one record per test per run: the test's id and version (§11), whether
it passed, when, which run, and the versions of the code the test reaches at that moment
(§13.1: what it calls, and what that code calls).
Evidence is scratch data. It lives under _build (Surfex.Evidence.path/1) and is
never committed; what it justifies is written into the relation log (§14), and the raw
runs aren't needed afterwards.
Surfex.Evidence.append/2appends records, andSurfex.Evidence.load/1reads them back, oldest first.- A test version discriminates once it has failed against one version of its code
and later passed against a different one, the test itself unchanged:
Surfex.Evidence.discriminating?/3, withSurfex.Evidence.red_then_green/3giving the two runs that show it. A test changed in any way (body, helpers, setups, cases) is a new version, which must fail and pass again. - Discrimination is kept in the log. The evidence file is scratch:
mix clean, a fresh checkout, another machine or another worktree loses it. Somix surfex.confirm --evidencerecords each test version that has discriminated as ared_greenobservation (§12.1), once. From then on that version counts as discriminated wherever the log is, and a green run against the current code is all a re-confirmation needs (tests,implements, andrequire_red). The guards stay: the red and green runs were against different code, and the observation is for one test version only.Surfex.Status.derive/4lists the discriminated test versions (discriminated). - A red→green that can't be credited is reported. A test version that failed and
later passed against the same code versions, and never discriminated, showed a
change its reach doesn't hold: a call the scanner doesn't see, a change outside the
scanned code, or a flaky test (
Surfex.Evidence.uncredited/1). Given evidence,Surfex.Status.derive/4lists each scanned test version so (uncredited, with the code its red run recorded) unless the log records it as discriminated, andmix surfex.status --evidencereports it. It is information, never a failure: without it the red→green would be lost in silence.
a test version that failed and then passed against the same code versions, never
discriminating, is listed as uncredited with the code it reached, without failing; one
that discriminated, in the evidence or the log, or another version, is notSurfex.Evidence.latest/3is a test version's most recent record.
Surfex.ExUnitFormatter records it. A project adds it beside the usual formatter
(ExUnit.start(formatters: [ExUnit.CLIFormatter, Surfex.ExUnitFormatter])):
- At the start of the suite it scans the tests
tests:names and the code, asmix surfex.statusdoes. Withouttests:it raises. - A finished test is matched to its scanned record by file and line, so a test a comprehension generates counts for the record that defines it. A record whose test ran once per case failed if any case failed.
- An excluded test (a tag the run's
exclude:or--onlyleaves out) is recorded asexcluded, and a skipped one (@tag :skip) asskipped, at its version with no code versions. They are neither red nor green:Surfex.Evidence.latest/3andSurfex.Evidence.red_then_green/3pass over them. Invalid tests, and tests outsidetests:, record nothing. - When the suite finishes, the run's records are appended to the evidence file. The formatter never touches the relation log.
a test that failed against one version of the code and then passed against another,
unchanged itself, discriminates; never red, red with the code unchanged, green before
red, or a changed test, does notWho confirms what. People or agents judge meaning, and evidence judges behaviour:
| Relation | Confirmed by |
|---|---|
verifies (test → spec) | recorded on the red step: the test's current version has failed (§18). After a spec rewording that changes no behaviour, a named judgement (Surfex.Record.confirm/6) |
tests (test → code) | evidence: the test's current version has discriminated, and its latest run passed against the code's current version |
implements (spec ↔ code) | derived: a test with a current verifies relation to the spec unit, or to a block or hint inside it, has a current tests relation to the code or reaches it (§13.1), and such evidence. When the spec side is what changed, the verifies must be to that exact unit: someone has judged that the test expresses the reworded requirement |
Surfex.Record.confirm_by_evidence/4 records these, and mix surfex.confirm --evidence
runs it after a test run: mix test && mix surfex.confirm --evidence. Each entry's note
records the evidence (the red run and the green one, with versions), so the conclusion is
kept in the log and the raw runs aren't needed afterwards. Once a test version has
discriminated, later green runs re-confirm its relations as the code changes: a refactor
never goes red, and doesn't need to. A test that has never been red leaves its relations
for a confirmation by hand, unless require_red: true in .surfex.exs refuses that too
(Surfex.Status.Config.require_red!/1), which is strict test-first work.
a code change whose test went red then green confirms the tests relation and then the
implements relation; a test never red, failing now, or green against other code confirms
nothing; a changed test's failing run confirms its verifies relation, but after a spec-only
rewording that is a judgement; after a spec change only a test verifying that exact unit
carries implementsCode reached through an entry point. A spec unit cites the function that does the
work; its test drives the public entry point that hands the work on. The run records the
versions of everything the test reaches, so evidence carries implements to the cited
function as it does to code the test calls: a change to it dangles the relation, and a
green run of a discriminated test re-confirms it. Reach credits a relation someone
recorded and never makes one. Code reached only at run time (a message to another
process, a dispatch by name) isn't reached by reading the source, and a test reaching it
that way is credited for what it calls.
A review (§18) is narrower than evidence. Its note is a judgement about what was read, so
it credits the code its test calls, and reached code only where the reviewer names it
(mix surfex.validate … --code ID). Otherwise one note would stand for every function
behind an entry point, including code nobody read. A named id the last green run didn't
reach is an error, unless the review says how the test exercises it (§18.2).
a test calling an entry point that calls the cited code carries the implements relation
by evidence, and the check bears it out against the code's version or reports it run
against other code; with the path cut, nothing carries it; the run records the versions
of what the test reachesa review credits implements to the code the test calls; code the test only reaches is
credited when the review names it, and not otherwise; naming code the green run didn't
reach is an errorCI validates; it records nothing. A CI job runs the tests with the formatter, from an
empty evidence file (a cached _build would otherwise carry evidence from other runs),
then mix surfex.status --verify --evidence. That checks that every relation holds and
that every claim a run can bear out (Surfex.Evidence.claimed?/1: its basis is
evidence or baseline (§18.1), or, recorded before bases existed, its note begins
Surfex.Evidence.note/0) is borne out by this run. The basis decides, not the note: a
move keeps a relation's basis and replaces its note, and the relation stays checked. A claim the run contradicts fails the
pipeline. A project whose backlog of unvalidated relations is done adds --validated, so
no relation is current without validation. CI never appends to the log and never saves
evidence: the log's claims are made where the work is done, and CI only checks them.
a relation is checked against a run by its basis, evidence or baseline, so a moved one
stays checked whatever its note; a note marks only an entry written before bases; a
review or judgement is never required to have runa run where the test passes against the code as confirmed bears the claims out; a test
that didn't run, failed, or ran against other code disproves them and fails the check;
without evidence nothing is checked, and relations confirmed by hand are never required
to have runA failed test fails its relations, whatever validated them. The basis decides
whether a run must bear a relation out, not whether a red run counts. A current
verifies or tests relation whose test failed at the version the relation holds is
failed (§13.1), whether it was validated by evidence, a review, a judgement or a
baseline: a review judged that the test covers the claim, and relied on the run to say
whether it passes. With several jobs' evidence, a failure in any job's file counts. A
test excluded, skipped or absent from the run fails nothing here. The relation isn't
invalidated, so once the test passes again it holds with no new review. A project
validated entirely by review (adoption: :reevaluate) is therefore held to its run as
well: its gate can't pass while the tests behind its requirements fail.
A deviation test's run is its declared state. A test tagged deviation: (§11) is
expected to fail: the code doesn't meet its requirement yet. When it runs and fails, its
relations aren't failed. The deviating unit is listed with its result instead
(§13.1). Only the tag does this: any other test's failure fails its relations as above.
When it runs and passes, at its version, the deviation is stale and fails the check:
the fix landed, and the tag must come off. Evidence of that needs a run that includes the
test (mix surfex.test --deviations runs exactly the deviation tests with their skips
included; or mix test --include skip, or the skip removed): a skipped test is recorded as
skipped, never as passed. With several jobs' evidence, a pass in any job makes it stale.
a deviation test that failed is listed with its deviation, not as failed, while an
untagged test's failure still fails; one that passed at its version is a stale deviation
and fails, in any job's evidence; skipped, or without evidence, nothing is judged stalea test that failed at the version its relations hold fails every current verifies and
tests relation it is an end of, whatever their basis, and the check; a pass afterwards
clears it with no new review; another version's failure, an excluded, skipped or absent
test fails nothing; with several jobs, a failure in any fails it; a claim by evidence
whose test failed is reported as failed, not also unprovenThe check grows with the project in proportion. It runs on every merge request, over every claim, against a run's whole evidence file, which accumulates across work items. So its work over a project twice as large (twice the tests, code, claims and evidence records) is about twice, not four or eight times. Each claim finds its candidate tests, and each test its latest record, from indexes built once per check. It doesn't scan every relation, reached pair or record for each claim.
the check's work over a project twice as large, in tests, code, claims and evidence
records, is about twice the work, counted in reductionsA suite split across jobs. Projects often run some tests in a job of their own
(mutation, integration, slow or external-service tests) and exclude them elsewhere. A
claim whose deciding test this run excluded or skipped is not checked here
(unchecked, beside unproven): it is listed by test and reason in every report, and
doesn't fail the check. A test with no record at all still didn't run, and fails: a
job that silently stopped running a test can't hide behind an exclusion. For an
implements carried by several tests, a disproof outranks a test that didn't run, which
outranks an exclusion.
The jobs together check every claim. Each job passing alone isn't enough: a claim
could then be checked by none of them. A final job reads every job's evidence file (its
CI artifact) with mix surfex.status --evidence --merge PATH (once per file;
Surfex.Status.derive/4 with evidence: {:merged, [records, …]}), and judges each claim
against each file:
- a disproof in any file (its test failed, or ran against other code) fails it, whatever another file shows;
- otherwise, a file that bears it out passes it;
- otherwise no job's evidence ran it, and it fails (no job).
A test absent from one job's file is normal in a split. Only the named files are read, and a named file that doesn't exist fails, rather than reading as an empty run. CI still records nothing: the evidence files are artifacts, not log entries.
merged evidence passes a claim one job bears out, fails one any job disproves, and fails
one no job ran; only the named files are read, and a missing one failsRecording reads other runs too. A test that needs an environment the local host lacks
(a directory server, a filesystem watcher) is excluded locally and run in CI. The commands
that record from evidence (confirm --evidence, validate, relate, suggest --accept)
take --merge PATH, once per file, typically CI jobs' artifacts, and read the local run
and those files as one history ordered by time (Surfex.Evidence.combined/1). A test
excluded here and run there reads as run; a later failure in any run is the latest. A
named file that isn't there fails, as for the check.
a test excluded here and passed in another run reads as passed in the combined history,
and a later failure in any run is the latest; --merge refuses a file that isn't therean excluded or skipped test is recorded at its version as such, neither red nor green;
a claim whose test this run excluded or skipped is not checked here, listed and not
failing, while a test with no record still failsa test version's red then green is recorded in the log once; with it, a green run
re-confirms after the evidence file is lost; a changed test version, or red and green
against the same code, records nothinga test run's results are recorded per scanned test at its versions, under _build, and
read back oldest first17.1 Evidence from a check
A check's run (§11.4) is evidence in a test's form (Surfex.Check): its test is the
check's id, its test_hash its declaration's version, and its code its inputs'
versions at the run. So a check discriminates as a test does
(Surfex.Evidence.red_then_green/3): its declaration, unchanged, failed against one set
of input versions and later passed against another. Breaking an input on purpose to show
the check can fail (the rule's config, or the code it inspects), then restoring it, is
that run.
mix surfex.confirm --evidencerecords a check's discrimination as ared_greenobservation of the check at its declaration's version (§12.1). It confirms the check'sverifiesrelations once the check has discriminated, in the evidence or by that observation, and its latest run passed against the inputs as they are. A check that has never failed is confirmed only by a review (§18.3). After a spec-only change it is a judgement, as for a test.- CI's check covers checks. A
verifiesrelation confirmed by a check's evidence is a claim a run bears out (§17): the run's latest record of the check, at its declaration's version, passed against the inputs that make the version the relation holds. A check the run didn't run didn't run, so CI runsmix surfex.checkin the job whose evidence the check reads; a checks job of its own is merged in with--merge, as any job is. A pass against other inputs ran against other code. - A failed check fails its relations, whatever validated them, as a failed test does: its latest record at the declaration's version failed against the inputs of the version the relation holds.
a check that failed and then passed with its inputs changed is observed as discriminated
and confirms its verifies by evidence, and a green run after an input change re-confirms
it; one that never failed, or failed and passed against the same inputs, confirms nothinga claim a check's evidence confirmed is borne out by a run where the check passed against
its inputs, and is unproven as not run without one, as failed when it failed, and as other
code against other inputs; merged evidence from a checks job bears it out17.2 Evidence comes from an asserting block
Evidence is a run of something that asserts: a test or property block, which the formatter
matches by its lines (§17), or a declared check (§17.1). A tag outside such a block makes
no test record (§11.5), so no run carries it. The formatter records nothing for a helper
under test/support/ or a module with no test, and nothing it records can credit the
requirement such a tag names.
a module with a verifies tag and no test, and a helper under test/support, give no test
record, so no run of them is evidence for the unit the tag names18. Validation by process
A current relation means the spec, the tests and the code were shown to belong together, not that someone said they do. Saying code is implemented without following the process is a guess, and a guess must never pass for validation. The process:
a spec unit changes, or has no relation
→ ensure a failing test: it may exist already; if not, write it
→ update the test relation (verifies), on the failing run
→ build the code until the test is green
→ update the code relation (implements), on the red run and the green oneThe balance. A relation established by the process gives a reasonable understanding that the code is correct while its tests pass, and CI checks that on every run (§17). Nothing is revalidated unless the spec, the test or the code changes. A refactor that stays green is re-confirmed by evidence.
Each entry records its basis, how it validates the relation (§12.1):
| Basis | How the relation got it |
|---|---|
evidence | test runs: a failing run for verifies; a red run and then a green one for tests and implements (Surfex.Record.confirm_by_evidence/4) |
review | a test examined against its spec unit and judged to validate it, then run green against the code (Surfex.Record.validate/6) |
judgement | a relation confirmed by name, with a note (Surfex.Record.confirm/6): for verifies, a spec reworded without a change of behaviour; for excuses, the item is what its class says; for implements, only to a shape (§4): a reviewer read the type against the spec |
proposed | a claim nothing has validated: a citation, a tag with no failing run, a pair named by hand (Surfex.Record.relate/7) |
A move carries its tip's basis across (§14, Surfex.Record.move/5): a move changes where a relation points, not
what validates it. A resolve (Surfex.Record.resolve/7) carries the chosen tip's: picking between judgements
already made validates nothing new.
- What makes each relation current:
implements: only evidence or a review. Never by hand.verifies: evidence of the failing run, a review, or the judgement path.excusesand the other types: a judgement by name.- Structural relations,
refinesandtests, record facts read from source and need no basis.
- Unvalidated relations are current
implementsorverifiesrelations with no validating basis, such as those recorded before validation existed. They are reported, andmix surfex.status --validatedfails on them, so a project moves over once its backlog is done.Surfex.Status.derive/4reports them, and fails on them undervalidated: true. - Validating an existing relation means doing the work. Read the spec unit's claims
and the test. Judge whether the test accurately validates that component of the spec:
its cases, its boundaries, what must stay unchanged. Fix or extend the test where it
falls short; a changed test is a new version and follows the process. Then run it green
against the code, and record it with
Surfex.Record.validate/6(mix surfex.validate TEST SPEC_UNIT --note N). The note says which claim each assertion checks. It needs theverifiesrelation, and green evidence at the current versions of the test and of the code it reaches (§13.1). It credits the code the test calls, and reached code only when named (§17). Code usually implements a section while its tests verify the hints inside it, so the unit may be that section: the review judges that the test validates the section's claims for the code it exercises, and needs the test's relation to the hint to be current already (validated first, against the hint). A review records only what isn't validated already: averifieson its failing run keeps that basis, so CI goes on checking it (§17), and with nothing left to recordvalidaterefuses rather than write the same judgement again. Breaking the code to get a red run was rejected: a test that fails against a mutant shows it is sensitive to the code, not that it reflects the spec. - One relation at a time.
confirm(Mix.Tasks.Surfex.Confirm) names one relation, andvalidate(Mix.Tasks.Surfex.Validate) one test and one unit. The note is required, and records what was checked. There is no form that confirms every relation touching an id. - A batch is many single judgements (
Surfex.Record.batch/3).--file PATHonconfirmandvalidatereads one relation per line (tab-separated:FROM,TO,TYPE,NOTEforconfirm;TEST,UNIT,NOTEforvalidate), and records them in one run, which saves the project load per relation that made agents script loops. Each line is still one relation, judged as the single form judges it, with its own note. The notes must be distinct: one note copied across many relations is a template, not a judgement of each, and such a batch is refused before anything is recorded. A line its own rule refuses fails the whole batch, naming the line; nothing is recorded.
a batch records each line in order, each seeing the ones before it; notes that aren't
distinct are refused, naming the lines; a line its rule refuses fails the batch, naming
it; the file is tab-separated with numbered lines, and an empty one is refuseda citation, a tag with no failing run, or a pair named by hand is proposed and fails the
check until evidence or a review validates it; accepting suggestions validates nothingan implements relation becomes current only by evidence or a review, never by hand; a
current relation without a validating basis is unvalidated, and fails only under
--validated; a move, and a resolve, keeps its basisA shape is judged. A type has no behaviour for a run to exercise: no test calls it,
so neither evidence nor a review can show it. An implements relation to a shape (§4) is
confirmed by judgement (mix surfex.confirm SPEC TYPE --type implements --note N): a
reviewer reads the type against the section, and the note says what was compared. It
dangles when the type changes. Code with behaviour is never judged: a judgement on its
implements, even one written by hand, is unvalidated.
an implements to a shape is confirmed by judgement and validates; code with behaviour is
still never confirmed by hand, and a judgement on its implements is unvalidatedconfirm takes one relation and a note, refuses implements unless its code is a shape, and
records a judgement; validate needs the verifies relation and green evidence, and records
a review; against a section it needs the test's current relation to a hint inside it, and
records the section's implements alonea review records only what isn't validated already: a verifies on its failing run keeps
that basis, and validate with nothing left to record refuses18.1 Adopting an existing suite
The process (§18) suits new work. A project with an established suite has another
problem: its passing tests can't honestly go red, so every relation would need a review
up front, and again after every refactor until its test next discriminates. A project
chooses how it adopts, with adoption: in .surfex.exs, read against the test files under the
project root by Surfex.Status.Config.adoption!/2 (and asked of one file by
Surfex.Status.Config.trusted?/2):
:reevaluate, the default: nothing is taken on trust. Every relation is validated by evidence, a review or a judgement, as §18 describes.:trust: the existing tests may be adopted once, by a baseline.[trust: globs, reevaluate: globs]: per area, for a codebase with both a well-kept core and tests nobody has read in a while. The globs lie withintests:and don't overlap; a test in neither is re-evaluated.
Surfex.Record.baseline/4 takes the baseline; Mix.Tasks.Surfex.Baseline is mix surfex.baseline --note N:
- Once. It refuses when the log already holds a baseline, under
:reevaluate, withouttests:, and when a trusted test's current version hasn't run green. - It adopts the tags already written. The baseline doesn't create
verifies:tags, and a tag added afterwards is an ordinary claim, validated on its own (§18). So tagging comes first. With no tags on the trusted tests it refuses, since a one-shot step would spend itself on test versions alone, unless--no-tags(meta[:no_tags]) says that is intended.Surfex.Record.baseline_summary/2reports what it adopted, and the task prints it: the trusted test versions, theverifiesrelations, and the spec units no adoptedverifiesreaches. - What it records: a
baselineobservation (§12.1) for each trusted test version, basisbaseline, with the adoption setting and the note; and, for each trusted test'sverifies:declarations at that commit, averifiesrelation with basisbaseline. - What it means: a baselined test version counts as if it had discriminated. A green
run then re-confirms its
testsandimplementsrelations after a code change, with basisbaseline, so they stay counted as trusted. - It only shrinks.
- A changed test version isn't covered by its record.
- A test's first real red→green records a
red_greenobservation (§17), and its relations move toevidence. - Narrowing
adoption:(:trustto:reevaluate, or a smaller trusted glob) stops the records it no longer covers from counting. Their relations are reported unvalidated: the backlog to review. - Moving a test carries its baseline record to the new id when its version is unchanged (§14): the same trust under a new name, not a second baseline.
- Widening trusts nothing new: the baseline is one-shot, and only the test versions it recorded are trusted.
- It is visible. A
baselinerelation is validated while its test's current version is baselined and trusted; for animplements, while some such test verifies the unit and exercises the code (Surfex.Status.validated?/2answers it for any relation).--validatedaccepts it. Status, its JSON andRELATIONS.mdcountbaselinerelations (Surfex.Status.baseline_count/1) and name the adoption mode, andmix surfex.status --no-baseline(baseline: :fail) fails on them, for a project finishing that backlog.
adoption: is :reevaluate by default, :trust, or trusted and re-evaluated globs that lie
within tests: and don't overlap; anything else is refusedthe baseline refuses under :reevaluate, a second time, without tests:, or before the
trusted tests run green; it records each trusted test version and its declared verifies,
basis baselinethe baseline adopts the verifies: tags already written: with none it refuses, unless
--no-tags says that is intended, and it reports the trusted test versions, the verifies it
adopted and the spec units left without onea baselined test re-confirms a refactor as baseline; its first red→green moves it to
evidence; a changed test version, or narrowed trust, leaves its relations unvalidated;
baseline relations are counted, and fail under baseline: :fail18.2 Reviews through a path no call shows
Some tests exercise code by a path no call in the source states: a route a router maps to
a controller, a message to a process, a JavaScript hook driven from Elixir, a capture
handed to a helper. Reach (§13.1) can't follow it, so neither evidence nor a review that
credits only reached code can validate the code's implements, though the test does
exercise it. The path is recorded by hand, and named.
Code relates to the claim it realises, not to every section that mentions it. Where no
citation proposes the pair, mix surfex.relate CODE CLAIM --type implements --note N
records it (proposed). Then
mix surfex.validate TEST CLAIM --code ID --through HOW --note N reviews it: HOW says
how the test exercises the named code (route GET /settings, message {:feed, _},
js hook Canvas, capture via growth/3).
- Each named item the test's green run didn't reach is credited: its
implementsto the unit is recorded with basisreviewand the entry'sthrough(§12.1). Named code the run did reach is credited as any review credits it, with nothrough. - The review still needs everything a review needs (§18): the
verifiesrelation, green evidence at the current versions, and a note. The named code must be scanned and already related to the unit (in any live state);--throughwithout--code, or with every named item reached, is refused. A batch (--file) takes no--through, as it takes no--code. Without--through, an unreached--codestays an error. - A move, a resolve and a new note (
annotate) keepthroughwith the basis.mix surfex.historyshows it.Surfex.Status.through_count/1counts the current relations resting on such a review, and the status text, its JSON (through, and each relation's) andRELATIONS.md(when there are any) report the count, so an audit can find them. - The triangle (§13) takes the review's word: code whose current
implementsrests on athroughreview counts as exercised by the unit's verifying tests. It dangles when the code changes, like any relation, and is judged again by a review naming the path.
a review naming how its test exercises code the green run didn't reach credits that code
with through recorded, counted, shown and kept by a new note, and the triangle closes;
without --through it is refused; --through without --code, for reached code, for code
not scanned or not related to the unit, is refused18.3 Reviewing a check
A check that has never failed hasn't shown it can, so evidence can't confirm it (§17.1).
mix surfex.validate CHECK UNIT --note N reviews it instead. CHECK is the check's id (a
check: prefix picks it where a test has the same id), and the note says what the check
would catch. The review needs the live verifies relation from the check to the unit and
a green run of the check at its current version. It records that relation with basis
review, and nothing else, since a check carries no implements.
a review of a check records its verifies to the unit on a green run at its current
version, with the note; without a green run, a relation or a note, or when already
validated, it is refused18.4 A review of several arities
A function with default arguments is one definition the scanner reports as several items
(§11): put/3 and put/4 from one def put whose last argument has a default. A review
credits code by definition, so a review of a test calling one credits every arity the
unit's implements names, each under the review's one note. A note written about one
arity ("put/3 is the arity with no options") is then wrong on the other's relation.
mix surfex.validate says when it records several arities of one definition, naming them
(Surfex.Record.shared_definitions/2), so the reviewer writes the note for the
definition; mix surfex.annotate corrects one already written.
a review crediting a unit's implements to two arities of one definition records both, and
validate names them as one definition; a review crediting one arity names none19. Handing off found work
What Surfex finds is work: a spec unit marked as needing an update (§13.1), an id the policy says is unmet, a gap in the triangle. That work is a change to the spec, the tests or the code, and it goes through the change process of the environment Surfex runs in: a tracker, a proposal queue, a pull-request template, a person's inbox. Surfex knows no tracker. It drafts, and the environment's process decides.
Surfex.Change makes a change draft for one piece of found work:
- a title;
- the problem: the mark's note, who made it and when; the unmet requirement; or the gap;
- what it touches: the spec unit (with its location and current text), the tests that verify it and the code that implements it, each with its relation's state;
- the steps the process implies (§18): spec, failing test, test relation, code, code relation.
Surfex.Change.drafts/3 gives a draft for every open mark, unmet id and triangle gap of a
status, or, given ids, for those items: a marked unit's marks, or a draft to change the
item. Surfex.Change.markdown/1 renders one for a person; Surfex.Change.json/1 renders
a list for tools and agents.
The hand-off is the project's own setting, process: in .surfex.exs
(Surfex.Change.hand_off/3):
:print(the default): the draft is printed, for a person or an agent to carry into their process;{:command, argv}: the command runs once per draft, with{title},{body}(the markdown) and{file}(a file holding it) substituted intoargv, and never through a shell. A command that exits non-zero stops the hand-off with its output. The draft is given as arguments, not on standard input: an argument needs no wrapper around the command, and{file}serves a command that reads a file.
Mix.Tasks.Surfex.Draft is mix surfex.draft [ID…] [--format markdown|json] [--file].
It prints the drafts and changes nothing. With --file it hands each draft to the hook
instead. Surfex never hands anything off on its own initiative: only --file runs the
hook. mix surfex.mark prints the new mark's draft.
Surfex's own .surfex.exs hands drafts to this environment's proposal system:
{:command, ["glab", "issue", "create", "--label", "Proposal", "--title", "{title}", "--description", "{body}"]}.
a draft is made for each open mark, unmet id and triangle gap, or for a named item, with
its title, problem, the unit, tests and code it touches, and the process's steps; it
renders as markdown and JSONprocess: :print prints each draft; {:command, argv} runs once per draft with {title},
{body} and {file} substituted and no shell; a failing command stops, with its output;
mix surfex.draft prints unless --file is given20. Completeness
Status answers whether anything is broken. Completeness answers how much of the project
is covered: which spec units, tests and code are missing a relation they need, and a
score to move. Surfex.Completeness.report/1 (Surfex.Completeness) derives it from a status, and it is a pure
function of the scans and the log.
What counts. Every spec unit that is a claim (§20.1: a unit with text of its own,
Surfex.Scan.Markdown.empty?/1; a container, such as a heading over subsections, carries
no claims of its own), every
test, and every code item. What's complete is judged on validated relations (§18):
complete means validated, not asserted.
| Kind | Complete when | Missing, as listed |
|---|---|---|
| spec unit | a test or check verifies it by a current, validated relation of its own (§20.1); and every implements relation to it is current and validated. A unit no code implements (a policy, say) is complete with its test alone: every unit needs a test, and code only where the spec describes code. A deviating unit (§13.1) is never complete, whatever its basis: a verifies recorded on its test's red run is validated, but the code doesn't meet the unit | no_relation (in none), no_test, contained (<unit>, …) (§20.1), unvalidated, deviates (<ref>, …) |
| test | it verifies a spec unit by a current, validated relation, and exercises code (a current tests relation). A test verifying only units no code implements is complete with its verifies alone | no_relation, verifies_nothing, exercises_nothing |
| code | it implements a unit by a current, validated relation, or is excused by a current excuses relation; and, when implemented, a test verifying the unit exercises it (no code_untested gap, §13.1). Code is one item per definition (§11): a function's default-argument arities are judged together and counted once, listed under the first of their ids | no_relation, not_described (neither implemented nor excused), unvalidated, untested |
The score is complete items over all items, per kind and overall (every item weighted the same), as a percentage to one decimal with the counts. With nothing to count, a kind scores 100.
Surfex.Completeness.text/1: the scores, then each incomplete item with what it lacks and where it is.Surfex.Completeness.json/1: the work list for an agent: the scores, and each incomplete item with its kind, id, what it lacks and its location.Surfex.Completeness.golden/2: the report as a golden,COMPLETENESS.mdby default, forgoldens:(§10.3). It holds counts and lists, with no hashes and no times.Surfex.Status.Config.completeness!/1readscompleteness: [min: N](a number from 0 to 100; no key, no minimum), raising on anything else. It is the maintainer's intent, and loud: below it (Surfex.Completeness.below?/2),mix surfex.statusfails with the score. Off by default, since the score is information first.
Mix.Tasks.Surfex.Completeness is mix surfex.completeness [--format text|json]. It prints
the report, and fails below completeness: [min: N]. With --summary it prints only the
score lines (Surfex.Completeness.summary/1), for a gate step whose work list is already a
golden; the floor holds as without it.
mix surfex.completeness --summary prints the overall and per-kind score lines and nothing
else, and below completeness: [min: N] fails with the same message as without ita spec unit is complete when a validated test verifies it (its own evidence) and any
implementing code is validated, with a test alone for a unit no code implements; a
heading-only section isn't counted; a test is complete verifying and exercising code, or
verifying only code-less units; code is complete when validated and exercised, or
excused, one item per definition whatever its default-argument arities; each incomplete
item names what it lacksa deviating unit is incomplete, missing deviates with its references, whatever validated
its relations and whether or not code implements it; with the tag removed and its
relations validated, it is completethe score is complete over all per kind and overall, to one decimal, 100 with nothing to
count; it reads as text, JSON and a golden with no hashes or times; below completeness:
[min: N] the status check fails20.1 Own text in the score
Completeness (Surfex.Completeness.report/1) reads a spec unit's text as verification
does (§13.4): a unit is counted when it is a claim, and
a claim is complete only with its own evidence.
- A claim is complete when a test or check verifies it by a current, validated
relation, and every
implementsrelation to it is current and validated, as before. Averifiesto a unit inside it doesn't complete it: that unit's evidence is the inner unit's, not the claim's own text's. - A
:containedclaim, one with noverifiesof its own while a claim inside it is verified, missescontained, which names the verified claims inside it (contained (<unit>, …)), so the work list says where evidence already is. A claim with nothing, own or inside, missesno_test, as before. - Containers (§13.4) aren't counted: a heading over subsections, a section whose body is only named claims, and a marked table, whose rows are its claims.
A contained claim is settled by adding it to the tag of a test that really checks its prose, by naming the prose's claims (§11.1) and migrating its relations (§14.1), or by classing the prose as asserting nothing.
a contained section is incomplete, missing contained with the verified unit inside it; the same section tagged by a validated test is complete; a
container, a marked table among them, isn't counted20.2 Excused and deferred in the score
Completeness reads classes and deferrals as verification does (§13.6): an excused unit and a deferred unit are left out of the spec score's total, and counted on lines of their own, so a growing excusal moves a number the summary shows:
spec units: 91.2% (52/57)
excused by class: 14 spec units, out of the score
deferred: 2 spec units (#12, #40), out of the scoreThe claims inside a classed section are counted as any claim is.
Surfex.Completeness.report/1 gives them as excused (the units) and deferred (each
unit and its reference), and Surfex.Completeness.json/1 under the same keys.
an excused section and a deferred section are out of the spec total and counted on their
own lines in text and JSON, while a rule inside a classed section is counted20.3 Code described by an earning implements
Completeness (Surfex.Completeness.report/1) reads code as require: does (§13.9): code is described only by an implements that earns (§13.5).
Code with implements relations of which none earns misses unearned, whatever
validated them; one earning relation describes it. Code with no implements misses
not_described, as before, and an excuses still describes code nothing implements.
code whose only implements is to an unverified hint misses unearned, and no longer does
once a test verifying the hint reaches it21. Discovering the tool
Surfex's main user is an agent working out the state of a spec, its tests and its code.
The agent shouldn't have to piece the tool together from the README, the guides and each
task's moduledoc. Mix.Tasks.Surfex.Info is mix surfex.info [TOPIC], and it tells the
agent how the tool works from the installed version itself:
- With no topic, a directory (
Surfex.Info.directory/0) of fewer than 100 lines: what Surfex is in a few lines, every topic with a one-line summary, and everymix surfex.*command, grouped by job, one line each. - With a topic, its page (
Surfex.Info.page/1): a focused, dense page with exact commands, written for an agent. An unknown topic is refused, and the refusal names the topics. - The pages live with the code; the usage rules point to them. The directory is
priv/info/index.mdand each topicpriv/info/TOPIC.md, except the agent topic, which is the package'susage-rules.md: the one short page theusage_rulestool copies into a project'sAGENTS.md. It holds what surfex is and the rules for an agent, and points tomix surfex.infofor the rest, so a project's agent context stays small and the detail always matches the installed version. All of them are the docs' "Using surfex" section. They are shipped in the package and built intoSurfex.Infoat compile time, so they always match the installed version.Surfex.Info.topics/0lists each topic with its summary. - The docs show the tool. Surfex is used as
mix surfex.<cmd>, so its published docs lead with the README and the usage pages, then the mix tasks, and the three modules a project writes code against: the evidence formatter (§17, intest_helper.exs), and the scanner behaviour with its item (§5, for a project scanner). Every other module keeps its documentation in the code, forhin iex, without being presented as the package's. - Two modules are supported library API (§23), so they are on hexdocs too.
- Using isn't adopting. A project that renders goldens with surfex but keeps no
relation log (
.surfex/) hasn't adopted it. The directory says so when run there (Surfex.Info.adoption_note/1), and points to the adoption topic.
the directory is under 100 lines, names every topic and every mix surfex.* command; each
topic prints its page; an unknown topic is refused, naming the topicsusage-rules.md is the short agent page and points to mix surfex.info; the directory and
every other topic are pages under priv/info, listed as items; the package ships them and
the docs carry them; hexdocs show the mix tasks, the formatter, scanner and item modules,
and the golden and source-scan library modules, and no other modulethe directory notes when the project has no relation log, pointing to adoption, and says
nothing once the log exists22. A model of the log
Unit tests sample the relation log's behaviour; a model explores it. The log is checked by an exhaustive model in extla, the TLA+-style checker for Elixir: branches record, retire and resolve relations, main takes each branch by git's union merge, and a branch catches up with main the same way. Every entry is written by the real recording code and every judgement made by the real status derivation, called from the model, so the model is an oracle for the code rather than a second copy of it.
In every state the bounded model reaches:
- The order of lines doesn't matter. A log's status is the same for its lines in any order, as a union merge writes them.
- A conflict is a disagreement. A relation is conflicted exactly when the tips in force record different judgements; tips that agree are one, so two resolutions that pick the same side don't conflict again.
- Resolving ends a conflict. Picking a side always leaves one tip.
- Every entry reads back. Each entry written decodes under its own id.
Two more models follow the log through versions and validation, on one log: a spec unit, its code and a test that verifies the unit and calls the code, each at one of two versions, with test runs that pass or fail against the code as it is. The evidence path changes the code and confirms by evidence; the review path changes the spec and the test and validates by a review or a judgement. In every state each reaches:
- Current means current. A current relation's ends are at the versions scanned now.
- Shown, not asserted. A current
implementsrests on evidence or a review. - Evidence is borne out. Every entry on evidence is backed by runs that happened: a
verifiesby its test version's failing run, anything else by a red then a green. - A review rests on a green run of the test at the versions it records.
Two more follow what a change asks of the log. Moves: a spec section and a test are
renamed and moved (mix surfex.move, or suggest's move); a move keeps every claim with its
basis, every retirement and every red→green record, under the new ids. Recovery: from a
triangle established test-first, the spec, the code and the test change in any order, each
test version runs once against each code version, and recording is done when it can be
(confirm --evidence, validate, confirm by judgement, suggest's refresh). Under that
fairness, a dangling relation whose test passes is brought back to current, unless the test
stops passing: nothing is current on a red test. It is a liveness property, so nothing in
the model is capped; a stranded relation would be a finding, not the bound.
The models run in a Mix environment of their own (MIX_ENV=model), so the rest of the
suite neither fetches nor compiles the checker, and CI runs it as a job of its own. A
violation fails it with the shortest trace to the state that breaks the property.
in every state branches and merges reach, a log's status is the same for its lines in any
order, a relation is conflicted exactly when the tips in force disagree, resolving ends
the conflict, and every entry reads back under its idwith versions changing and tests run red or green, a current relation's ends are at the
current versions, a current implements rests on evidence or a review, every entry on
evidence is borne out by runs, and every review rests on a green runrenaming and moving a spec section and a test keeps every claim with its basis, every
retirement and every red→green record, under the new idswhatever changes, a dangling relation whose test passes is brought back to current by the
work surfex asks for, unless the test stops passing23. The library API
Some projects use surfex for one thing: a surface golden, a committed document generated
from a source scan, which CI regenerates and byte-compares, keeping no relation log. They
call two modules directly, and their gates rest on them, so both are supported library
API: Surfex.SourceScan, which reads Elixir source without compiling it, and
Surfex.Golden, which renders a golden from a plain data spec. Both are on hexdocs, with a
guide (guides/using-surfex-as-a-library.md). They carry a promise:
- Their documented functions and types keep their names, arities and shapes, as they have since v0.1.0.
Surfex.SourceScan.definition_hash/1gives the same version for the same code, release to release, so a golden doesn't churn on an upgrade.- A change to either never comes in a patch release. It comes in a minor release, listed under "Changed" in the CHANGELOG and marked for library users.
A test pins the documented surface of both, so a change is always deliberate.
the documented functions and types of Surfex.Golden and Surfex.SourceScan are exactly the
pinned ones, the one-argument project_root stays callable, and definition_hash gives the
same version for the same code23.1 The scanner contract
A project scanner, and above all a DSL plugin (§4.1), is code another project writes
against surfex, often in a package of its own. Surfex.Scanner and Surfex.Item are
therefore supported API too, under §23's promise:
Surfex.Scanner's callbacks keep their names, arities and argument and return shapes. A callback added later is optional, so an existing scanner keeps working.Surfex.Item's fields keep their names and meanings, a field added later has a default, andSurfex.Item.key/1keeps its rule, so a plugin's keys, and every relation recorded against them, survive an upgrade.- A change to either never comes in a patch release. It comes in a minor release, listed under "Changed" in the CHANGELOG and marked for scanner authors.
A test pins both.
Surfex.Scanner's callbacks and Surfex.Item's fields are exactly the pinned ones, only kind,
name, file and hash are enforced, and key/1 keeps its rule