Surfex specification {#surfex}

Copy Markdown View Source

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:

  1. 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.status checks it: nothing changed since it was confirmed, every item described or deliberately excused, and every name the spec cites exists (§6–§8).
  2. 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 a Surfex.Profile and 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}

  1. Header block, three contiguous lines: # <name>, the one-line purpose, and the attribution Generated by `mix <task> --write` · gate `<gate>` (HARD|ADVISORY) — do not edit; a drift FAILS the gate. Free prose may follow.
  2. Stats lines, each **<lead>** · <label> <value> · …. Surfex.Golden.stat/2 builds one as data (lead, dimensions and rendered text), so a report can read the counts back without parsing markdown. Surfex.Golden.stat_line/2 renders just the text.
  3. 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/1 returns every defmodule node in a quoted AST, in source order, nested ones included.
  • Surfex.SourceScan.hidden_module?/1 says whether a module declares @moduledoc false.
  • Surfex.SourceScan.line_range/1 gives the first and last source lines a node spans, from its metadata (parsed with token_metadata: true). Metadata never reaches a hash, so a line range is where something is, never what it is.
  • Surfex.SourceScan.defs/1 returns a module's public definitions, from its own body only (a nested module's definitions are that module's):
    • public means def, defmacro, defdelegate and defguard
    • excluded: defp, defmacrop, defguardp; anything under @doc false; an @impl callback with no @doc of 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)

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).

FieldMeaning
kindthe scanner's category (:module, :function, :wire_field, …)
nameunique within its parent, or globally when it has none
filewhere it is declared, relative to the scanned root
hashits content version (§3.3)
value, detaila declared value, or scanner-specific extra, shown in the Value column
parentthe enclosing item's key, for a member
typethe key of the item a member is an instance of (a field holding a struct)
aliasesother names that cite it (§6.4)
linesits first and last line in file, when the scanner knows them: never part of its key or its hash
shapetrue 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:

ItemKindKeyAliases
a module:moduleMyApp.Cartnone
a public function (§3.2):functionMyApp.Cart.add/2MyApp.Cart.add
a public macro or guard:macroMyApp.Cart.is_cart/1MyApp.Cart.is_cart
a public type (@type, @opaque):typet:MyApp.Cart.t/0MyApp.Cart.t
  • A module under @moduledoc false is 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 whose require: 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 :paths option (globs relative to the root) defaults to lib/**/*.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 arity
  • token: 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 normalise rule that drops a call's arguments, so MyApp.Cart.add(cart, item) names MyApp.Cart.add, and one that reads ExDoc's t:MyApp.Cart.t/0 as MyApp.Cart.t: a type cited as MyApp.Cart.t() or t:MyApp.Cart.t/0 resolves 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:

StatusMeaning
:resolvedit names one item, or one family (§6.4), or one item per step of a member path (§6.5)
:ambiguousit names several unrelated items. Reported, never guessed
:unresolvedit has the shape of a claim about the code and names nothing
:externalit names something real outside the scanned tree (known_external)
:documented_absencea section names it because the code lacks it (documented_absences)

6.3 What is read {#citation-reading}

  • Surfex.Cite.sources/2 lists 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 defmodule in an .ex source), 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 # comment in 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:

  • :cited when the item's key is in it
  • a gap for a never_excused kind 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 traceThe relation log
a citationa suggested implements relation (§15)
citeda current relation (§13)
excused by a classan excuses relation (§11, §15)
GAPan unmet id under require: (§13.1)
an unresolved or ambiguous citationa broken citation, failing the check (§13.1)
drift since the goldena 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/4 writes 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/2 compares committed bytes with a fresh render. Identical texts give nil. Otherwise it names the rows that changed, appeared and went. A row is keyed by its table's Item column, or the first column when there is none, and compared by its Version cell, 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!/1 evaluates 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:

  • :status or {:status, output}: the relation status as a golden (§13.3), RELATIONS.md by default. Its drift fails here; whether the relations are healthy is mix surfex.status's question.
  • :completeness or {:completeness, output}: the completeness report as a golden (§20), COMPLETENESS.md by default. Its drift fails here; whether the project is complete enough is mix surfex.status's question, under completeness: [min: N].
  • {output, module, opts}: a Surfex.Surface

With no key, the list is [:status].

  • Surfex.Goldens.entries!/1 validates the list. It raises naming an entry that is neither form, the removed :trace entry (§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/6 renders every entry, writes or checks each, and returns every drift in entry order. A module that does not implement Surfex.Surface raises, 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: :spec or :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, :block or :test_hint (below); for code, the item's kind (:module, :function, …); nil for 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); nil when 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 describe if 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 setup that applies to it (the module's and its describe's, including named callbacks) and the generators of a comprehension that defines it are also part of it. A private helper defined inside a describe block 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, @describetag to a describe, and @moduletag to a module. Each becomes {:verifies, id} in the record's declares. Since a tag is ordinary ExUnit, mix test --only verifies:id runs exactly the tests of one requirement. A verifies value that isn't a literal string or list of strings raises, naming the file and line.
  • What it calls: the record's calls lists the functions the test's body and the private helpers it reaches call, as Module.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, a describe'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 for Surfex.Suggest to 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).

StateWhen
conflictedtips 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
retiredthe tip retires the relation
orphanedan end recorded at a hash is no longer scanned (removed or renamed)
plannedan 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.
danglingboth ids are scanned, but at least one is at a different hash than the tip recorded; the report names which ends changed
currentboth 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!/1 reads 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/2 runs the code scanner: the built-in Elixir scanner, or the project's scanner: module (Surfex.Scanner) with its scanner_opts:.
  • Surfex.Status.Config.scans/2 scans each markdown unit of sources, each item the code scanner (scanner, scanner_opts) finds, and, when tests: names globs of test files, each test in them (Surfex.Scan.ExUnit). A missing sources, or one that matches no section, raises. So does a tests: that matches no file.
  • Surfex.Status.Config.options!/1 reads triangle: :report | :fail (:report by default) for Surfex.Status.derive/4, and raises on any other value. When classes: is present it also gives the class rules as coverage:, 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/3 gives everything Surfex.Status.derive/4 needs 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 with Surfex.Cite under Surfex.Status.Config.profile!/2: the config's profile keys (Surfex.Profile.keys/0) over the Elixir scanner's defaults for namespace:, which defaults to the project's app name.
  • Surfex.Status.Config.status/4 derives the whole status as mix surfex.status does: 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!/1 validates process: (§19): :print, the default, or {:command, argv} with a non-empty list of strings, and raises on anything else.
  • Surfex.Status.Config.require!/1 validates the require: 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.

FunctionRecords
Surfex.Record.relate/6, Surfex.Record.relate/7a 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/7a 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/7one 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/6a 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/4every 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/6a 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/6that 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/7the 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:

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, describe or 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.move does 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/2 became render/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.move works for any id, code included).
  • refines: each marked block and test hint refines the 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 tests relation whose test still has the code in its calls, a refines relation whose block or hint is still within the 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 verifies relation (§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 implements candidate in the same run will, paired with the class of the first rule it matches. Matching is Surfex.Coverage's (§7), where a parent counts as cited when something implements it. A never_excused kind is never proposed. With require: [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/2 appends records, and Surfex.Evidence.load/1 reads 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, with Surfex.Evidence.red_then_green/3 giving 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. So mix surfex.confirm --evidence records each test version that has discriminated as a red_green observation (§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, and require_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/4 lists the discriminated test versions (discriminated).
  • Surfex.Evidence.latest/3 is 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, as mix surfex.status does. Without tests: 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 --only leaves out) is recorded as excluded, and a skipped one (@tag :skip) as skipped, at its version with no code versions. They are neither red nor green: Surfex.Evidence.latest/3 and Surfex.Evidence.red_then_green/3 pass over them. Invalid tests, and tests outside tests:, 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 one

The 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 implements rests on evidence or a review.
  • Evidence is borne out. Every entry on evidence is backed by runs that happened: a verifies by 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/1 gives 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