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 {#scope}
1.1 What Surfex is for {#purpose}
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 {#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.
test deterministic-output 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 another
## 2. Surface goldens {#goldens}
Surfex.Golden is the one renderer. Surfex.Golden.render/1 takes a spec (a map) and
returns the golden's text.
test golden-renders 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 text
2.1 Shape {#golden-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.
test golden-stats 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 gate
### 2.2 Cells are typed {#golden-cells}
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 |
### 2.3 Determinism {#golden-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.
test golden-order rows come out in natural-key order whatever order they went in, and a golden never carries a date, a time or a commit
3. Reading source {#reading-source}
Surfex.SourceScan holds the primitives every scanner of Elixir source shares.
test source-not-compiled source is read as text and parsed, never compiled: code no compiler would accept is still read
### 3.1 Finding source {#finding-source}
- Surfex.SourceScan.project_root/2 ascends 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/1 starts 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/1 lists the .ex files of every first-party lib/
under a root, sorted. It serves a library and a multi-project tree alike, with nothing
to configure. A lib/ is first-party when:
- its parent has a mix.exs, so it is a Mix project's own source, and not a
lib/ 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.
test finding-lib-sources 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 directories
3.2 Modules and definitions {#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
test public-definitions 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 hidden
### 3.3 Content versions {#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() is fee/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 and uses
- its defstruct or defexception fields
- its @type, @opaque, @callback and @macrocallback declarations
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.
test structure-versions 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 only
4. Items {#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 |
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.
test item-key an item's key is its name, or parent.name when it has a parent
## 5. Scanners {#scanners}
### 5.1 The contract {#scanner-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.
test scanner-behaviour a project scanner implements Surfex.Scanner, and a module that doesn't is refused, named
5.2 The built-in Elixir scanner {#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 skipped, with its functions. A module nested in it is not, since Elixir documents it separately. - 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.
- 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.
test name-boundary 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 module
test elixir-scanner-items the 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 namespace
test type-citations a 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 key
## 6. Citations {#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.
test citation-status-kinds each citation ends in exactly one status: resolved, ambiguous, unresolved, external or a documented absence
6.1 No new notation {#citation-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 {#citation-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 {#citation-reading}
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.
test citation-sections 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 sorted
### 6.4 Resolution {#citation-resolution}
A span is normalised (trimmed, then the profile's rewrites in order) and then resolved by the first rule that applies:
1. a documented absence for this name in this file
2. a known external name
3. a member of a subject of an enclosing section (§6.6)
4. an item key (Surfex.Cite.index/2): one item resolves, several are ambiguous
5. an alias: every item sharing it is cited together as a family
6. a member path (§6.5)
7. the profile's shape: unresolved
8. otherwise, the tokens inside the span that have the shape, or the known_shape and
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.
test citation-resolves 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 own
6.5 Member paths {#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 {#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.
7. Coverage {#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.
test coverage-verdict 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 map
## 8. Profiles {#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).
test exclude-both a file under exclude: contributes neither spec sections nor citations; excluding every file is an empty spec, which is an error
test rule-parent a 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 version
test profile-validated a 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 kind
9. The v0.2 trace, removed {#traces}
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 {#gating}
10.1 The gate {#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 row is keyed by its table'sItemcolumn, or the first column when there is none, and compared by itsVersioncell, or the whole row when there is none. 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.
test gate-drift 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 changed
### 10.2 Project goldens {#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.
test project-golden a module implementing Surfex.Surface is rendered and gated like any golden, and a module that doesn't is named
10.3 Every golden at once {#all-goldens}
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.
test goldens-entries goldens: defaults to [:status]; each entry is validated, :trace is refused with its reason, and every golden's drift is reported in one run
### 10.4 The task {#golden-tasks}
Mix.Tasks.Surfex.Goldens is mix surfex.goldens:
- Without --write, the task checks. It fails if any golden is missing or has drifted.
- --write regenerates. 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 PATH reads 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?/2 says 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.
test goldens-task mix surfex.goldens checks every golden and fails on a drift; --write regenerates them all, even when some have drifted, and succeeds
11. Scan records {#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 and its parent as within.
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. Since a tag is ordinary ExUnit,mix test --only verifies:idruns exactly the tests of one requirement. Averifiesvalue that isn't a literal string or list of strings raises, naming the file and line. - 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 is listed under each module it imports. 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).
test property-tests a property is scanned as a test: its id, its declarations, what it calls, and a version that changes with its generators
Surfex.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. It gives {:error, :unknown} when nothing has the id,
and {:error, {:ambiguous, ids}} when more than one file does.
Surfex.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/3
leaves out those under an exclude: prefix, §8); 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 file and the path of headings down to it. Two sections with the
same path in one file are told apart as ~2, ~3, in order. A heading anchor,
{#cart-add} at the end of the heading, makes the id spec.md#cart-add instead: it
survives renaming the heading or any heading above it. The anchor is not part of the
heading text, so subsections' paths don't include it.
- 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.
Two 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 is file#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 ID. Its id is file#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. A test fence without an id is an ordinary code
block.
Anchors, block ids and hint ids share one namespace per file with the section ids, and
are lowercase 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 block that nests or spans a heading;
- a close marker with no block;
- a malformed id;
- a duplicate id, naming both lines.
test section-versions given 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 changes
test fence-rule a # 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 heading
test unit-versions editing a marked block, a test hint, or a section's own prose changes that unit's version and no other's
test test-versions a 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 changes
test scan-records-pure scanners produce scan records only, each a pure function of the source: spec units, code items, tests and classes
test test-alias-scope a 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 form
## 12. The relation log {#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 {#log-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 verifies a spec unit) 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) 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 |
| 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 |
- An observation is a fact the log keeps about one test version, not a relation:
op: observe, type red_green, one test end at its version, basis evidence. 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?/1 tells 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, type
needs_update, one spec end at the unit's version, and a note (required) saying what is
wrong in the result. A retire of the same type and end, naming the mark as its parent,
withdraws it. Surfex.Log.Entry.mark?/1 tells 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/3 gives 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 |
|---|---|---|---|
| implements | relate, retire | code ↔ spec | proposed, evidence, review, judgement, baseline; or none (legacy) |
| verifies | relate, retire | test → spec | proposed, evidence, review, judgement, baseline; or none (legacy) |
| tests | relate, retire | test → code | none, evidence, judgement, baseline |
| refines | relate, retire | spec → spec | none, judgement |
| depends_on | relate, retire | code → code | none, judgement |
| excuses | relate, retire | class ↔ code | proposed, judgement; or none (legacy) |
| needs_update | mark, retire | one spec | none |
| red_green | observe | one test | evidence |
| baseline | observe | one test | baseline |
A retire has 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
an implements or verifies among them unvalidated (§13.1). Surfex.Record never
writes one: every implements and verifies it records carries a basis. An
implements on judgement is 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 --verify lists every violation with its file
and line.
- Surfex.Log.Entry.build/1 makes an entry from its fields and computes its id, or says
which field or rule is broken. Surfex.Log.Entry.new!/1 raises 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/1 reads 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!/1 raises instead. Text that is not JSON at all always
raises, since the file is then corrupt. Surfex.Log.Entry.json/1 reads one JSON value
with null as nil.
test mark-entry 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 ids
test entry-tamper an entry whose line is edited no longer decodes under its id, and verifying the log reports it
test entry-canonical an 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 id
test grammar-ends each type joins only its kinds of end, and a directed type only from → to; a mark is one spec end and an observation one test end; there is no config kind
test grammar-bases each 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 own
test grammar-loud a 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 unchanged
### 12.2 The log {#log-segments}
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/1 closes it as the
next surfex_N.log and starts a new one.
- Headers: every file starts with {"segment": N, "previous": HASH}, where HASH is
the SHA-256 of the previous segment's sorted entry ids (null for the first). Headers
are not judgements, so Surfex.Log.rechain/1 may rewrite them. That's needed only when
two branches each started a segment and a merge combined them.
- Surfex.Log.init/1 creates the log and adds .surfex/*.log merge=union to
.gitattributes, so git merges concurrent appends by keeping both sides' lines.
- Surfex.Log.append/2 adds entries to the open segment.
- Surfex.Log.load/1 reads every segment, drops duplicate ids, and orders entries by at
and then id. Every checkout derives the same order, whatever order the lines arrive
in, which is what makes the union merge correct.
- Surfex.Log.verify/1 reports 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.
test union-merge two branches that each append to the log merge without a conflict, and the merged log loads to one state
test log-append-only the 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 truncation
## 13. Relation status {#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)) are
pure: the same scans, log and options always give the same status.
test status-pure the status is derived from the scans and the log alone: the same inputs always give the same status
13.1 States {#status-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 |
test agreeing-tips 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_on relation 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 implements relation 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/2 from one def 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.
- Unproven: a current relation confirmed by evidence (§17) that the evidence given to
Surfex.Status.derive/4 as evidence: doesn't bear out: its test, at the version the
relation holds, didn't run, failed, or ran against another version of the code. For an
implements relation, some test with a current verifies relation to the unit (or
inside it) and a current tests relation to the code must pass. Without evidence:
nothing is checked, and a relation confirmed by hand never is.
- Undeclared: a live verifies relation 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. A
verifies relation recorded by hand, with no declaration, is undeclared too: a test must
say what it verifies.
- 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 implements or verifies relation whose tip has no
validating basis (evidence, review or judgement, §18), such as one recorded before
validation existed. It is reported, and fails the check only when the status is derived
with validated: true (mix surfex.status --validated).
- Marks (§14): a needs_update mark 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's
marks); resolved and withdrawn ones live on in the history.
- Stale: a live excuses relation whose code item its class no longer covers,
judged by Surfex.Coverage.verdict/3 as 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, which Surfex.Status.derive/4 takes as
coverage:.
Surfex.Status.failing?/1 is true when any relation is dangling, orphaned, conflicted or
proposed, any id is unmet, any verifies relation is undeclared, any declaration or
citation is broken, any excuse is stale, or any confirmation by evidence is unproven. New,
retired, impacted, planned, unimplemented and unvalidated don't fail on their own. 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.
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 and test hints.
test dangling-side a relation whose end changed since it was confirmed is dangling, and names which end changed
test planned-state a 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 current
test triangle-gaps given a spec unit something implements: no verifying test, a verifying test that calls none of its code, and implementing code no verifying test calls are each reported, naming the test or the code
test mark-states a 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: :fail
test status-failing the check fails on a dangling, orphaned, conflicted or proposed relation, an unmet id, an undeclared verifies relation, a broken declaration or citation, a stale excuse, or an unproven confirmation, and on nothing else by default; planned: :fail adds planned relations, and validated: true unvalidated ones
test units-by-role the spec units scanned are counted by role: sections, blocks and test hints
test undeclared-verifies a verifies relation whose test no longer declares its spec unit is undeclared and fails; suggest --accept retires it
13.2 Configuration {#status-config}
Surfex.Status.Config reads .surfex.exs. It holds the profile keys (§8) and the
log's own: scanner, scanner_opts, namespace, goldens (§10.3), require, tests,
triangle, require_red (§17), 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.Surfex.Status.Config.items/2runs the code scanner: the built-in Elixir scanner, or the project'sscanner:module (Surfex.Scanner) with itsscanner_opts:.Surfex.Status.Config.scans/2scans each markdown unit ofsources, each item the code scanner (scanner,scanner_opts) finds, and, whentests:names globs of test files, each test in them (Surfex.Scan.ExUnit). 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.
test status-config-read .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 profile
### 13.3 Reports and the check {#status-reports}
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, impacted and planned relation,
the unimplemented, new and unmet ids, the broken citations, the unproven confirmations,
the undeclared relations, 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 with
validated: true, where they fail, since a project moving over has many.
- Surfex.Status.Report.golden/2 (Surfex.Status.Report.golden/1 writes RELATIONS.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 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. It leaves 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 and impact, 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 unproven confirmations, the stale excuses, the
broken declarations, the triangle's gaps, the unvalidated relations 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 JSON null.
Mix.Tasks.Surfex.Status is mix surfex.status:
- --format text|json (text by default)
- --verify verifies the log (§12.2) before loading it, so a tampered log gets its
report, not the first decode error
- --evidence checks every confirmation by evidence against the evidence the last
mix test recorded (evidence:), and fails on any that is unproven. It only reads.
- --no-planned fails 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
- --validated fails on any unvalidated relation as well (validated: true, §18), and
lists them
- --no-marks fails on any open or orphaned mark as well (marks: :fail), for a release
that must not ship with a known spec problem
- --no-baseline fails 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 below N, printing the score
- it fails when the status is failing, or the log doesn't verify
- it compiles the project first only when .surfex.exs names a project scanner
test status-report-forms the status reads as text for people, JSON for tools and a golden for review, the golden with no hashes or times
14. Recording {#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 exercises (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.
test confirm-named confirming a relation with nothing to confirm is an error, never a silent no-op
test move-carries a move records the recorded versions under the new id: if the text changed as it moved, or the other end changed, the moved relation dangles
test move-carries-observations moving 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 behind
test move-carries-retirements a 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 pair
test decline-recorded retiring 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 parent
test mark-recorded mark 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 them
test recording-by-name relate, confirm, validate, retire, resolve, move and history each record or read entries by name, and never edit or remove one
test annotate-current annotate 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 refused
15. Suggesting relations {#suggesting}
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. - 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.
test suggest-linear suggest's work over twice the relations is about twice the work, counted in reductions
test decline-shown a 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 none
The 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 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.
test suggest-never-confirms a pair already related, in any state, is never suggested again, so accepting suggestions never confirms a dangling relation
test suggest-code-moves a 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 ambiguous
test suggest-test-moves a 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 suggested
test suggest-moves an 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 suggested
test suggest-refresh a 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 is
test suggest-proposes suggest proposes relations the spec and tests already imply and records them only on --accept
16. The specification guide {#spec-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
- keeping headings stable
- naming the code in the section that describes it
- stating requirements as checkable claims
- 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.
17. Test evidence {#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 calls at that moment.
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). Surfex.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.
test evidence-discriminates 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 not
Who 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, 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.
test evidence-confirms 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 implements
CI 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.
test evidence-claims-by-basis 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 checked against a run
test evidence-checked a 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 never are
A 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.
test evidence-merged 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 fails
Recording 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.
test evidence-merged-recording 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 there
test evidence-excluded an 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 fails
test red-green-recorded a 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 nothing
test evidence-recorded a test run's results are recorded per scanned test at its versions, under _build, and read back oldest first
## 18. Validation by process {#validation}
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.
- excuses and the other types: a judgement by name.
- Structural relations, refines and tests, record facts read from source and need no
basis.
- Unvalidated relations are current implements or verifies relations with no
validating basis, such as those recorded before validation existed. They are reported,
and mix surfex.status --validated fails on them, so a project moves over once its
backlog is done. Surfex.Status.derive/4 reports them, and fails on them under
validated: 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 the verifies relation, and green evidence at the current
versions of the test and of the code it exercises. 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: a verifies on its
failing run keeps that basis, so CI goes on checking it (§17), and with nothing left to
record validate refuses 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, and
validate (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 PATH on
confirm and validate reads one relation per line (tab-separated: FROM, TO,
TYPE, NOTE for confirm; TEST, UNIT, NOTE for validate), 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.
test batch-distinct-notes 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 refused
test process-proposed a 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 nothing
test process-validated an 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 basis
A 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.
test shapes-by-judgement 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 unvalidated
test process-one-at-a-time confirm 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 alone
test review-records-once a review records only what isn't validated already: a verifies on its failing run keeps that basis, and validate with nothing left to record refuses
### 18.1 Adopting an existing suite {#adoption}
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 within tests: 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,
without tests:, 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/2 reports what it adopted, and the task
prints it: the trusted test versions, the verifies relations, and the spec units no
adopted verifies reaches.
- What it records: a baseline observation (§12.1) for each trusted test version,
basis baseline, with the adoption setting and the note; and, for each trusted test's
verifies: declarations at that commit, a verifies relation with basis baseline.
- What it means: a baselined test version counts as if it had discriminated. A green
run then re-confirms its tests and implements relations after a code change, with
basis baseline, 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_green observation (§17), and its
relations move to evidence.
- Narrowing adoption: (:trust to :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 baseline relation is validated while its test's current version
is baselined and trusted; for an implements, while some such test verifies the unit
and exercises the code (Surfex.Status.validated?/2 answers it for any relation).
--validated accepts it. Status, its JSON and RELATIONS.md
count baseline relations (Surfex.Status.baseline_count/1) and name the adoption mode,
and mix surfex.status --no-baseline (baseline: :fail) fails on them, for a project
finishing that backlog.
test adoption-modes adoption: is :reevaluate by default, :trust, or trusted and re-evaluated globs that lie within tests: and don't overlap; anything else is refused
test baseline-one-shot the 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 baseline
test baseline-adopts-tags the 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 one
test baseline-shrinks a 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: :fail
## 19. Handing off found work {#hand-off}
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 into argv, 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}"]}.
test change-drafts 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 JSON
test change-hand-off process: :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 given
## 20. Completeness {#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 with text of its own (a section, marked block or test
hint whose own body isn't empty, Surfex.Scan.Markdown.empty?/1; a heading over
subsections carries no claims), 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 verifies it, or a unit inside it, by a current, validated relation; 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 | no_relation (in none), no_test, unvalidated |
| 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) | 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.md by default, for
goldens: (§10.3). It holds counts and lists, with no hashes and no times.
- Surfex.Status.Config.completeness!/1 reads completeness: [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.status fails 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].
test completeness-rules a spec unit is complete when a validated test verifies it (or a unit inside it) 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; each incomplete item names what it lacks
test completeness-score the 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 fails
## 21. Discovering the tool {#info}
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 every mix 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.md and each topic priv/info/TOPIC.md, except the agent topic, which
is the package's usage-rules.md: the one short page the usage_rules tool copies into
a project's AGENTS.md. It holds what surfex is and the rules for an agent, and points
to mix surfex.info for 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 into
Surfex.Info at compile time, so they always match the installed version.
Surfex.Info.topics/0 lists 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, in test_helper.exs), and the
scanner behaviour with its item (§5, for a project scanner). Every other module keeps its
documentation in the code, for h in 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.
test info-directory 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 topics
test usage-rules-shipped usage-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 module
test info-without-log the directory notes when the project has no relation log, pointing to adoption, and says nothing once the log exists
22. A model of the log {#model}
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.
test log-model 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 id
test log-model-validation with 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 run
test log-model-moves renaming and moving a spec section and a test keeps every claim with its basis, every retirement and every red→green record, under the new ids
test log-model-recovery whatever changes, a dangling relation whose test passes is brought back to current by the work surfex asks for, unless the test stops passing
23. The library API {#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.
test library-api-stable 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 code