Name

Test::Consumer::Contract - check that code reading a result only reads the fields that are always there

Version

Version 0.03

Synopsis

1. The Usual Case: Results Are Hashes, Readers Live in Other Distributions

A geocoder hands back a hash. x and y are always in it; colour is only sometimes there. This test fails if any reader in the workspace reads colour without saying why that is safe.

use Test::Most;
use Test::Consumer::Contract qw(run_consumer_contract);

run_consumer_contract(
    source      => 'Widget::Maker',       # readers mention this name
    methods     => [qw(geocode)],         # the call that returns a result
    guaranteed  => [qw(x y)],             # always set
    conditional => { colour => 'only the painted path sets it' },
    producers   => { 'lib/Widget/Maker.pm' => [qw(x y colour)] },
    consumers   => { 'Widget-User/lib/Widget/User.pm' => {} },
    require_workspace => ['Widget-User'],
);

done_testing();

2. Allowing One Reader to Read an Optional Field

The reader in render() reads colour on purpose, and handles it being missing. Say so, with the reason, under the name of the sub that reads it:

consumers => {
    'Widget-User/lib/Widget/User.pm' => {
        may_rely_on => {
            render => { colour => 'defaults to undef on purpose' },
        },
    },
},

3. Results That Are Objects With Accessor Methods

When a result is an object, readers call methods ($entry->memo) instead of reading hash keys. Name the methods a reader may call, and let the checker compare that list with what the class documents:

run_consumer_contract(
    source        => 'Ledger::Entry',
    methods       => ['Ledger::Entry->new', 'entry'],  # 'Class->new': only this class
    list_methods  => ['results'],                      # returns a list of results
    value_object  => {
        class   => 'Ledger::Entry',
        from    => { amount => 7 },       # build one like this...
        derives => { cents => 700 },      # ...and expect these values
    },
    guaranteed    => [qw(amount memo cents)],
    accessors     => [qw(amount memo cents)],
    not_accessors => { new => 'the constructor' },
    inspection    => [qw(can isa DOES)],
    conditional   => { note => 'only some entries carry a note' },
    include       => ['Ledger-Store/lib'],
    own_consumers => 1,                   # this distribution reads them too
    consumers     => { 'Ledger-UI/lib/Ledger/UI.pm' => {} },
    require_workspace => ['Ledger-UI'],
);

4. A Call That Returns Many Results at Once

geocode_many() returns one hash: the results under their names, plus counts. The counts are not fields of a result, so name the call in batch_methods:

methods       => [qw(geocode geocode_many)],
batch_methods => ['geocode_many'],

5. Using the Scanner Yourself

The object's methods can be used outside a test, for example to see what the checker finds in one file:

my $contract = Test::Consumer::Contract->new(
    source => 'Widget::Maker', methods => ['geocode'], guaranteed => [],
);
my $code = $contract->strip($source_text);       # drop POD and comments
foreach my $read (@{ $contract->reads_of_results($code) }) {
    my ($key, $line, $kind, $sub) = @{$read};
    print "line $line, in $sub: reads $key ($kind)\n";
}

6. Changing the Wording of the Messages

# For every contract built after this line:
$Test::Consumer::Contract::config{'messages'}{'skip.siblings'}
    = 'Geschwister fehlen: {missing}';

# For one contract only:
run_consumer_contract(%declaration, messages => {
    'check.producers' => 'les producteurs posent toujours les cles promises',
});

Description

The Problem

One piece of code (the producer) makes a result: a hash or an object with named fields. Other code (the consumers, or readers) reads those fields. Some fields are always set. Others are set only on some paths - for example, only when a lookup found a postcode.

A reader that reads an only-sometimes field works in testing and then fails for the one input where the field is missing. This module finds those reads before that happens, by reading the source code of every reader.

Words Used in This Document

producer      code that makes a result (for example geocode())
result        the hash or object the producer hands back
value object  the class of a result, when the result is an object
key, field    one named value in a result: $r->{'x'} or $r->x
guaranteed    a key every result has
conditional   a key only some results have
consumer      a file that reads keys of a result
distribution  one project folder, such as Widget-Maker/
workspace     the folder that holds several distributions side by side
declaration   the options you pass to new() or run_consumer_contract()

A typical workspace:

workspace/
    Widget-Maker/       the producer and the value object
    Widget-User/        a consumer
    Widget-Contract/    holds t/contract.t, which declares the contract
                        (this is dist_root; it may also be Widget-Maker)

The Five Checks

run() runs these checks. Each one is a subtest in the TAP output.

Checks 3 and 5 need the other distributions. If one named in require_workspace is missing (for example when the module is installed from CPAN), those checks are skipped with a message.

The checker only reads source code. It never runs the producer or the consumers. See "LIMITATIONS" for what it cannot see.

Requirements

Load a test module (Test::Most or Test::More) before calling, and call done_testing() after. This module adds subtests; it does not start or end the test plan.

Perl 5.14 or later.

Configuration

%Test::Consumer::Contract::config holds settings shared by all contracts. It has the shape Object::Configure produces. Each contract copies it when new() is called, so a change made later does not affect a contract that already exists.

Key                Meaning                                      Default
-----------------  -------------------------------------------  ---------------------
sibling_dirs       folders read in another distribution         lib bin t
own_dirs           folders read in dist_root (own_consumers)    lib bin scripts
source_extensions  file extensions that hold Perl code          pm t pl PL pm6
skipped_dirs       folder names never entered                   blib xt inc local
                                                                node_modules .git
                                                                .build
messages           message key => text, replacing the built-in  (empty)

new() does not call "configure" in Object::Configure itself, because that would let an environment variable change a contract that should be written in code.

Common Pitfalls

Encoding

Methods

New

my $contract = Test::Consumer::Contract->new(%declaration);

Purpose

Builds a contract from a declaration, validating it before any check runs.

Arguments

A list of pairs, or one hashref. source, guaranteed and methods are required; every other option has a default. An optional option given as undef takes its default, as if it had not been given.

Every path in a declaration is written with / between the directories, whatever separator the platform itself uses, and the checks name files the same way, so a path can be copied from a failure message into the declaration.

Returns

The new object.

Side Effects

Each existing include directory is moved to the front of @INC (through lib, so it appears there once however many contracts name it); the last one listed ends up first. @INC is process-wide: the change outlives the object.

Example

my $contract = Test::Consumer::Contract->new({
    source      => 'Warehouse::Stock',
    methods     => [qw(fetch scan)],
    guaranteed  => [qw(grams name)],
    conditional => { bin => 'only scan() knows the bin' },
});

Api Specification

Input
{
    source              => { type => 'string', min => 1 },
    guaranteed          => { type => 'arrayref' },
    methods             => { type => 'arrayref' },
    dist_root           => { type => 'string', optional => 1 },
    workspace           => { type => 'string', optional => 1 },
    include             => { type => 'arrayref', optional => 1 },
    value_object        => { type => 'hashref', optional => 1 },
    conditional         => { type => 'hashref', optional => 1 },
    producers           => { type => 'hashref', optional => 1 },
    workspace_producers => { type => 'hashref', optional => 1 },
    consumers           => { type => 'hashref', optional => 1 },
    ignored             => { type => 'arrayref', optional => 1 },
    accessors           => { type => 'arrayref', optional => 1 },
    not_accessors       => { type => 'hashref', optional => 1 },
    inspection          => { type => 'arrayref', optional => 1 },
    batch_methods       => { type => 'arrayref', optional => 1 },
    list_methods        => { type => 'arrayref', optional => 1 },
    own_consumers       => { type => 'boolean', optional => 1 },
    require_workspace   => { type => 'arrayref', optional => 1 },
    messages            => { type => 'hashref', optional => 1 },
}
Output
{ type => 'object', isa => 'Test::Consumer::Contract' }

Messages

Message                                         Meaning / Resolution
----------------------------------------------  ----------------------------------------
"...::new() does not know the option(s): X"     An option is misspelt or unknown.  Fix
                                                the name; nothing is silently ignored.
"...::new() needs a 'X'"                        A required option is missing or undef.
"...::new(): <validator message>"               An option has the wrong type, for
                                                example a string where an arrayref is
                                                expected.
"...::new(): each 'ignored' rule must be        An 'ignored' entry is not an arrayref
 [prefix, reason], not X"                       starting with a prefix.  Write
                                                ['Dist/', 'why'].
"...::new(): consumer 'X' must be {...}"        A consumers entry is not a hashref, or
                                                its may_rely_on is not sub => {key=>why}.
"...::new(): value_object needs a 'class'       value_object without a valid module name
 that is a module name, not 'X'"                in 'class'.
"...::new(): dist_root 'X' is not a             An explicit dist_root or workspace does
 directory: <OS error>"                         not exist; the OS error says why.

Pseudocode

normalise the arguments into one hash
croak on any option outside the schema
croak on any required option that is undef
validate the types; croak with the validator's reason on failure
croak on malformed ignored rules, consumer entries or value_object class
croak on an explicit dist_root or workspace that is not a directory
fill in defaults, take a copy of %config
put the include directories at the front of @INC
return the object

Run

$contract->run();

Purpose

Runs the five checks described in "DESCRIPTION", each as a subtest. Called for you by run_consumer_contract().

Arguments

None.

Returns

The object, so calls can be chained.

Side Effects

Emits TAP through Test::Builder. Loads the value object's class.

Example

Test::Consumer::Contract->new(%declaration)->run();
done_testing();

Api Specification

Input
{}
Output
{ type => 'object', isa => 'Test::Consumer::Contract' }

Messages

None of its own: each check's messages are listed in "DIAGNOSTICS".

Run_Consumer_Contract

my $contract = run_consumer_contract(%declaration);

Purpose

Builds the contract and runs it: new(...)->run() as one exportable call.

Arguments

The declaration, as for "new".

Returns

The object.

Side Effects

Those of "new" and "run".

Example

use Test::Most;
use Test::Consumer::Contract qw(run_consumer_contract);

run_consumer_contract(source => 'S', methods => ['fetch'], guaranteed => ['id']);
done_testing();

Api Specification

Input

As for "new".

Output
{ type => 'object', isa => 'Test::Consumer::Contract' }

Messages

Those of "new".

Workspace_Present

if($contract->workspace_present()) { ... }

Purpose

Says whether every distribution named in require_workspace is in the workspace, which is what checks 3 and 5 need to run.

Arguments

None.

Returns

1 when every one is there, otherwise 0.

Side Effects

Caches the answer in the object.

Example

plan skip_all => 'no siblings' unless $contract->workspace_present();

Api Specification

Input
{}
Output
{ type => 'boolean' }

Messages

None.

i18n

my $text = $contract->i18n('check.producers');
my $text = Test::Consumer::Contract->i18n('skip.siblings', { missing => 'X' });

Purpose

Turns a message key and its arguments into the text the user sees. Every diagnostic, test name and error this module produces goes through here, so all of them can be reworded or translated.

Arguments

The template is looked up in the object's messages option, then in $config{messages}, then in the built-in catalogue.

Returns

The message text.

Side Effects

None.

Example

local $Test::Consumer::Contract::config{'messages'}{'check.producers'}
    = 'die Erzeuger setzen noch die zugesagten Schluessel';

my $contract = Test::Consumer::Contract->new(%declaration, messages => {
    'skip.siblings' => { one => 'missing: {missing}', other => 'missing ({count}): {missing}' },
});

print $contract->i18n('skip.siblings', { missing => 'A, B', count => 2 });
# missing (2): A, B

Api Specification

Input
{
    key  => { type => 'string', min => 1, position => 0 },
    args => { type => 'hashref', optional => 1, position => 1 },
}
Output
{ type => 'string' }

Messages

Message                          Meaning / Resolution
-------------------------------  ---------------------------------------------
"no message called 'X'"          The key is in no catalogue.  This is a
                                 programming error and is raised with confess,
                                 so the stack trace shows the caller.
"...::i18n() needs a 'key'"      The key is missing or undef.

Pseudocode

validate the key and the arguments
add package => this module's name under the caller's arguments
take the first template defined in: object messages, %config messages, built-in
confess if there is none
if the template is a hash of forms, choose one by context, else by count
replace each {name} or {name:%fmt} that has a value; leave the rest
return the text

Sets_Key

$contract->sets_key($source_text, 'database')

Purpose

Says whether the text assigns the key, in any of the spellings a producer might use: key => value, 'key' => value, $obj->{'key'} = value, $obj->{'key'} ||= value and $obj->{'key'} //= value.

Arguments

Returns

1 or 0.

Side Effects

None.

Example

my $text = $contract->strip($source);
ok($contract->sets_key($text, 'colour'), 'the producer still sets colour');

Api Specification

Input
{
    text => { type => 'string', position => 0 },
    key  => { type => 'string', min => 1, position => 1 },
}
Output
{ type => 'boolean' }

Messages

Message                                      Meaning / Resolution
-------------------------------------------  ----------------------------------
"...::sets_key(): <validator message>"       An argument has the wrong type.
"...::sets_key() needs a 'X'"                An argument is missing or undef.

Strip

my $code = $contract->strip($source);

Purpose

Removes what is not code, so that prose about a key is not mistaken for a use of it: POD (to its =cut, or to the end of the file when it has none), whole-line comments, and everything after __END__ or __DATA__.

Arguments

Returns

The source with POD and comments blanked. The lines are kept, so a line number found in the result is the line number in the file.

Side Effects

None.

Example

my $code = $contract->strip("=head1 X\n\n\$w->{'pod'}\n\n=cut\nmy \$x = 1;\n");
# "\n\n\n\n\nmy \$x = 1;\n"

Api Specification

Input
{ text => { type => 'string', position => 0 } }
Output
{ type => 'string' }

Messages

Message                                      Meaning / Resolution
-------------------------------------------  ----------------------------------
"...::strip() needs a 'text'"                The text is missing or undef.

Scanner_Capabilities

my $seam = Test::Consumer::Contract->scanner_capabilities();
my @rules = sort keys %{$seam};

Purpose

Every rule the scanner applies to source beyond reading the file, as rule name => the name of the sub the rule is exercised through. shebang_programs is an extensionless program being a consumer; accessor_headings is where a value object documents an accessor; code_not_prose is producer evidence having to be code. The list is here so that the tests and the code cannot drift apart: t/contract.t mutates each named sub back to its behaviour before the rule existed, and fails when a rule here has no mutation subtest, when a mutation names a rule that is not here, and when a row names a sub this module does not define. A rule therefore cannot be added without the test that shows what it catches.

Arguments

None. May be called on the class or an object.

Returns

A new hashref, in any context: a list of names where a hash of names to seams was asked for would leave every seam undef. The caller may change it freely.

Side Effects

None.

Example

my $seam = Test::Consumer::Contract->scanner_capabilities();
print "$_ -> $seam->{$_}\n" for sort keys %{$seam};

Api Specification

Input
{}
Output
{ type => 'hashref' }

Messages

None.

Reads_of_Results

my @reads = $contract->reads_of_results($stripped_text);
my $reads = $contract->reads_of_results($stripped_text);

Purpose

Finds every place in the text that reads a field of a result: a hash key, $r->{'key'}, or a method call, $r->key or $r->key().

First it works out which variables hold a result. A variable holds a result when:

Then it lists every read of those variables.

Arguments

Returns

In list context, the reads; in scalar context, an arrayref of them. Each read is [key, line, kind, sub]: kind is key or accessor, sub is the enclosing sub or (file scope). They are in line order.

Side Effects

None.

Example

my $reads = $contract->reads_of_results($contract->strip(<<'CODE'));
my $w = $maker->make(size => 2);
return $w->{'colour'};
CODE
# [ ['colour', 2, 'key', '(file scope)'] ]

Api Specification

Input
{ text => { type => 'string', position => 0 } }
Output
{
    type    => 'arrayref',
    element => { type => 'arrayref' },   # [key, line, kind, sub]
}

Messages

Message                                      Meaning / Resolution
-------------------------------------------  ----------------------------------
"...::reads_of_results() needs a 'text'"     The text is missing or undef.

Pseudocode

batches  := variables assigned from a batch method call
bound    := variables assigned from a producing call, minus batches
add the loop variables and topics over a list method's results
add the variables assigned a value selected out of a batch
sinks    := hashes holding batch values; subs returning one
for each sub returning a batch value:
    add the variables assigned from a call to it
    record reads written straight off a call to it
repeat until nothing new: add the parameter that receives a bound variable
record reads off every bound variable
record reads straight off a batch selection
record reads through an element of a hash that holds results
sort the reads by line

Consumer_Path

my $path = $contract->consumer_path($relative);

Purpose

Where a registered consumer is, as a path on this platform. A consumer in a sibling distribution is named relative to workspace; one in the distribution being checked is named the way a producer is, relative to dist_root, because the directory a distribution is installed under is not the name the contract spells.

Arguments

Returns

The workspace path when a file is there, otherwise the dist_root path (whether or not that exists).

Side Effects

None.

Example

my $path = $contract->consumer_path('Widget-User/lib/Widget/User.pm');

Api Specification

Input
{ relative => { type => 'string', min => 1, position => 0 } }
Output
{ type => 'string' }

Messages

Message                                         Meaning / Resolution
----------------------------------------------  ----------------------------------
"...::consumer_path() needs a 'relative'"       The path is missing or undef.

Documented_Accessors

my @names = $contract->documented_accessors($source);
my $names = $contract->documented_accessors($source);

Purpose

Every name the value object's source documents as part of its interface: its =head2 headings and the public subs it defines. =head1 headings are left out (NAME, VERSION and the rest of the boilerplate), as are deeper headings, which are sections about the accessor above them rather than accessors, and private subs. A method implemented through AUTOLOAD is found by its heading, which is what makes this the list of what is documented rather than of what is compiled.

Arguments

Returns

The names, sorted and unique: a list in list context, an arrayref in scalar context.

Side Effects

None.

Example

my $names = $contract->documented_accessors("=head2 x\n\n=head3 PURPOSE\n\n=cut\n\nsub y {}\n");
# ['x', 'y']

Api Specification

Input
{ text => { type => 'string', position => 0 } }
Output
{ type => 'arrayref', element => { type => 'string' } }

Messages

Message                                          Meaning / Resolution
-----------------------------------------------  ----------------------------------
"...::documented_accessors() needs a 'text'"     The text is missing or undef.

Diagnostics

Every message is a key in the catalogue (see "i18n"); the English text is summarised below. Diagnostics after a failed assertion are indented a tab.

Key                  Meaning / Resolution
-------------------  ---------------------------------------------------------
file.unreadable      A producer, consumer or value object could not be read;
                     the OS error says why.  Fix the path in the declaration.
producer.sets        (test name) A producer no longer sets the key.  Set it
                     again, or take it out of the producer's list.
class.unavailable    (skip) The value object is not in @INC.  Add its lib to
                     'include', or accept the skip on a CPAN install.
class.compiles       (test name) The value object is in @INC but does not
                     load; the error follows.  Fix the class.
derivation.made      (test name) The constructor died or returned false; the
                     error follows.
consumer.offender    A consumer reads a key not every producer sets.  Stop
                     reading it, or register it in may_rely_on with why.
consumer.stale       A may_rely_on entry is no longer read.  Remove it.
skip.siblings        (skip) A require_workspace distribution is absent.
accessor.documented  (test name) A name in accessors/not_accessors is not
                     documented or defined by the value object.
accessor.drift       The value object documents a name the contract does not
                     mention.  Put it in accessors, conditional or
                     not_accessors.
discovery.header     A file reads a result and is neither registered nor
                     ignored.  Register it, or ignore it with a reason.

Limitations

Author

Nigel Horne, <njh at nigelhorne.com>

Formal Specification

These schemas use the Z notation. They say exactly what each public method promises, for readers who want that; nothing else in this document depends on them.

Types and State

[PATH, KEY, NAME, SUBNAME, OPTION, VALUE, RULE, CHAR, VAR]
FILES : ℙ PATH                           -- the files that exist
KIND ::= key | accessor
LOADSTATE ::= loaded | missing | broken

┌─ CONTRACT ────────────────────────────────────────────────
│ options : OPTION ⇸ VALUE
│ dist_root, workspace : PATH
│ missing_siblings : ℙ NAME              -- cache, filled on first use
│ cached : 𝔹
│ class_state : NAME ⇸ LOADSTATE         -- cache of loaded classes
├───────────────────────────────────────────────────────────
│ {source, guaranteed, methods} ⊆ dom options
│ ∀ o : dom options • options(o) ≠ undef
└───────────────────────────────────────────────────────────

┌─ InitCONTRACT ────────────────────────────────────────────
│ CONTRACT'
├───────────────────────────────────────────────────────────
│ ¬ cached' ∧ class_state' = ∅
└───────────────────────────────────────────────────────────

New

┌─ New ─────────────────────────────────────────────────────
│ spec? : OPTION ⇸ VALUE
│ contract! : CONTRACT
├───────────────────────────────────────────────────────────
│ dom spec? ⊆ KNOWN_OPTIONS
│ {source, guaranteed, methods} ⊆ dom spec?
│ ∀ o : dom spec? • spec?(o) ∈ TYPE(o)
│ ∀ d : {dist_root, workspace} ∩ dom spec? • spec?(d) ∈ DIRECTORY
│ contract!.options = DEFAULTS ⊕ spec?
│ INC' = rev(includes(spec?)) ⁀ (INC ∖ ran includes(spec?))
└───────────────────────────────────────────────────────────

Run

┌─ Run ─────────────────────────────────────────────────────
│ ΞCONTRACT
│ tap! : seq SUBTEST
├───────────────────────────────────────────────────────────
│ tap! = producers ⁀ derivation ⁀ consumers ⁀ accessors ⁀ discovery
│ #tap! ≤ 5
└───────────────────────────────────────────────────────────

Run_Consumer_Contract

RunConsumerContract ≙ New ⨾ Run

Workspace_Present

┌─ WorkspacePresent ────────────────────────────────────────
│ ΞCONTRACT
│ present! : 𝔹
├───────────────────────────────────────────────────────────
│ present! ⇔ ran require_workspace ⊆ dirs(workspace)
└───────────────────────────────────────────────────────────

i18n

┌─ I18n ────────────────────────────────────────────────────
│ key? : KEY
│ args? : NAME ⇸ VALUE
│ text! : seq CHAR
├───────────────────────────────────────────────────────────
│ catalogue = BUILTIN ⊕ config.messages ⊕ self.messages
│ key? ∈ dom catalogue
│ text! = interpolate(choose(catalogue(key?), args?), {package ↦ PACKAGE} ⊕ args?)
└───────────────────────────────────────────────────────────

Sets_Key

┌─ SetsKey ─────────────────────────────────────────────────
│ text? : seq CHAR
│ key? : seq₁ CHAR
│ sets! : 𝔹
├───────────────────────────────────────────────────────────
│ sets! ⇔ (∃ q : {ε, ', "} • q⁀key?⁀q⁀"=>" in text?)
│       ∨ (∃ q : {ε, ', "}; op : {"=", "||=", "//="} •
│             "->{"⁀q⁀key?⁀q⁀"}"⁀op in text?)
│ -- "==" and "=~" are comparisons, not assignments
└───────────────────────────────────────────────────────────

Strip

┌─ Strip ───────────────────────────────────────────────────
│ text?, code! : seq CHAR
├───────────────────────────────────────────────────────────
│ let n == #lines(text? before __END__/__DATA__)
│ #lines(code!) = n
│ ∀ i : 1 ‥ n • lines(code!)(i) ∈ {ε, lines(text?)(i)}
│ ∀ i : pod(text?) ∪ comments(text?) • lines(code!)(i) = ε
└───────────────────────────────────────────────────────────

Scanner_Capabilities

┌─ ScannerCapabilities ─────────────────────────────────────
│ seam! : RULE ⇸ SUBNAME
├───────────────────────────────────────────────────────────
│ seam! = SCANNER_RULE_SEAM
│ ran seam! ⊆ dom SUBS(Test::Consumer::Contract)
└───────────────────────────────────────────────────────────

Reads_of_Results

┌─ ReadsOfResults ──────────────────────────────────────────
│ ΞCONTRACT
│ text? : seq CHAR
│ reads! : seq (KEY × ℕ₁ × KIND × SUBNAME)
├───────────────────────────────────────────────────────────
│ holds = μ H : ℙ VAR | H = bound(text?) ∪ helpers(text?, H)
│ ran reads! = { k, l, kind, s | (∃ v : holds ∖ batches(text?) •
│                  read(text?, v, k, kind) at l in s) }
│ ∀ i, j : dom reads! | i < j • line(reads!(i)) ≤ line(reads!(j))
└───────────────────────────────────────────────────────────

Consumer_Path

┌─ ConsumerPath ────────────────────────────────────────────
│ ΞCONTRACT
│ rel? : PATH
│ path! : PATH
├───────────────────────────────────────────────────────────
│ workspace ⁄ rel? ∈ FILES ⇒ path! = workspace ⁄ rel?
│ workspace ⁄ rel? ∉ FILES ⇒ path! = dist_root ⁄ rel?
└───────────────────────────────────────────────────────────

Documented_Accessors

┌─ DocumentedAccessors ─────────────────────────────────────
│ text? : seq CHAR
│ names! : seq NAME
├───────────────────────────────────────────────────────────
│ ran names! = head2(text?) ∪ { s : subs(text?) | s(1) ≠ '_' }
│ names! is sorted ∧ names! ∈ iseq NAME
└───────────────────────────────────────────────────────────

State Diagram

A contract object moves through these states. Square brackets are states, arrows are triggers, and the lines under each arrow are what happens.

(no object)
     |
     | new(%declaration)
     |   - checks every option; croaks on the first mistake
     |     (no object is made, @INC is not changed)
     |   - copies %config; fills in defaults
     |   - puts 'include' directories at the front of @INC
     v
[CONSTRUCTED]  caches empty: sibling list unknown, no class loaded
     |
     |  workspace_present()   - fills the sibling cache, returns 1 or 0
     |  i18n(), strip(), sets_key(), reads_of_results(),
     |  documented_accessors(), consumer_path(),
     |  scanner_capabilities()  - read only; the state does not change
     |
     | run()  (or run_consumer_contract(), which is new() then run())
     v
[CHECKING PRODUCERS] ---- no producers declared ----------+
     |  subtest 1: one ok() per file read and per key     |
     v                                                    v
[CHECKING DERIVATION] --- no 'derives' ------------------+
     |  loads the value object class (cached)             |
     |    class file not found  -> subtest skipped        |
     |    class does not compile -> subtest fails         |
     |    constructor dies       -> subtest fails         |
     v                                                    v
[CHECKING CONSUMERS] ---- no consumers declared ---------+
     |  fills the sibling cache if empty                  |
     |    a required sibling missing -> subtest skipped   |
     v                                                    v
[CHECKING ACCESSORS] ---- no value_object, or no lists --+
     |  uses the cached class state (skip / fail / run)   |
     v                                                    v
[CHECKING DISCOVERY]  (always runs)
     |  a required sibling missing -> subtest skipped
     |  otherwise walks the workspace and reads files
     v
[CHECKED]  run() returns the object
     |
     | run() again
     |   - the same checks run again; the cached sibling list
     |     and class state are reused, files are read again
     v
[CHECKED]

A failed check never stops the run: each later check still runs. An error in a check's own code (a bug in this module) ends that subtest only.

Copyright 2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.