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.
- 1. the producers still set the keys the contract claims
-
Each file in
producersandworkspace_producersmust still contain an assignment to each key listed for it. POD and comments do not count: a key shown in an example is not a key being set. - 2. the value object still derives what it promises
-
Only when
value_objecthasderives. An object is built fromfrom, and each method named inderivesmust return the value given. If the class cannot be found, this check is skipped. If the class is found but does not compile, or its constructor dies, the check fails. - 3. registered consumers read only what every producer sets
-
Each file in
consumersis read. Every read of a result is listed with its line and the sub it is in. Reading a guaranteed key is fine. Reading any other key fails, unless the consumer lists that key inmay_rely_onunder the name of the sub that reads it. An entry inmay_rely_onthat the code no longer reads also fails, so the list stays true. - 4. the accessor lists match the value object
-
Only when
value_objectis given andaccessorsornot_accessorsis not empty. Every name you list must exist in the class. And every name the class documents (its=head2headings and public subs) must appear inaccessors,not_accessorsorconditional. So when the class gains a new field, this check fails until the contract says what the field is. - 5. every discovered consumer is registered
-
Every
.pm,.t,.plfile and every program (a file with no extension that starts with#!) in the other distributions of the workspace is read. A file that mentionssourceand reads a result must be listed inconsumersorignored. So a new reader cannot be added without being checked.
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
undef means "use the default".
Giving an optional option the value
undefis the same as not giving it. For a required option (source,guaranteed,methods),undefis an error. Inside lists and hashes,undefis not cleaned up: anundefinguaranteedis a key that no read can match.Nothing is merged deeply.
An option you give replaces the default completely; it is not merged with it. A message you give in
messagesreplaces the whole built-in message. If the built-in message has several forms (one,other), your replacement must give every form you need, or it is used for all counts.The contract keeps your lists, it does not copy them.
The arrays and hashes you pass are used as they are. If you change them after
new(), the contract changes too. Build the declaration first, then callnew().%configis copied only one level deep.A new contract gets its own copy of each list and of the
messageshash. But a message that is itself a hash of forms is shared. Replace such an entry; do not change it in place.Paths always use
/.Write
'Widget-User/lib/Widget/User.pm', also on Windows. Consumer and ignored paths are relative to the workspace. Producer paths are relative todist_root, unless they are given inworkspace_producers.An
ignoredrule is a plain prefix.'Widget-List'also ignoresWidget-Listing/. End the prefix with/to ignore exactly one folder.A bare
newinmethodsmatches every constructor.methods => ['new']makesAny::Class->new(...)a result. Write'Widget->new'so that only that class counts.Reads outside any sub belong to
(file scope).A program's main code is not in a sub. To allow a read there, use the name
'(file scope)'inmay_rely_on.With an empty
require_workspace, check 5 may fail.If no other distribution is present, check 5 finds no consumer at all, and fails rather than pass on nothing. Name the distributions your consumers are in.
Variable names count for the whole file.
If
$pointholds a result in one sub, every$pointin the same file is treated as a result. Use different names for different things.List context or scalar context.
reads_of_results()anddocumented_accessors()return a list when called in list context and an array reference in scalar context.my $n = $contract->reads_of_results($t)is a reference, not a count.includechanges@INCfor the whole program.The last directory listed ends up first. The change stays after the contract is gone.
Other tests may match the English text.
The test names and messages are part of the output. If you change their wording with
messages, anything that searches the output for the English text will stop matching.
ENCODING
Source files are read as bytes.
Files are not decoded. Any bytes - UTF-8, Latin-1, emoji - may appear in strings, comments and POD of the files that are read; they do no harm.
Keys and method names must be ASCII to be found.
A read is found only when the key or method name is made of
A-Z,a-z,0-9and_. A read of$r->{'grosse'}is found; a read of a key with a non-ASCII letter in it is not found, so it can never fail check 3. The same is true of producer keys passed tosets_key()for check 1 when the text is bytes and the key is a character string.strip(),reads_of_results()anddocumented_accessors()accept bytes or characters.If you pass them text you decoded yourself (for example with
:encoding(UTF-8)), non-ASCII letters count as word characters, and non-ASCII keys are found.sets_key()compares the key and the text exactly.Give both as bytes, or both as decoded characters. Mixing them can make a key that is in the text appear to be missing.
sourceis compared with the file's bytes.Keep it ASCII (a Perl package name always is).
Paths should be ASCII.
Paths are passed to the operating system as they are. On Unix a non-ASCII path works when it is given as UTF-8 bytes. On Windows non-ASCII paths may not be found.
Messages may use any Unicode text, including emoji.
Message templates, reasons in
conditional,may_rely_onandignored, and the arguments toi18n()may be any character strings. When they contain characters above 255, Test::Builder prints aWide characterwarning unless its output handles have an encoding layer:binmode(Test::More->builder->$_, ':encoding(UTF-8)') for qw(output failure_output todo_output);
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.
source(string, required)-
The name that marks a file as one that may consume results: a file that does not contain it is not scanned during discovery. Usually the producer's class.
guaranteed(arrayref, required)-
The keys every producer sets on every result. A read of one of these is always safe.
methods(arrayref, required)-
The calls that hand a result to the caller, as your producer spells them:
[qw(geocode reverse_geocode)]for a geocoder,[qw(fetch find)]for a store. There is no default: the checker cannot guess what your producer calls its lookups, and a name it guessed would quietly find nothing. A name may be written with its class,'Widget->new'or'Widget::new', and then only that class's call matches. A variable bound from one of these is followed, as is a variable passed to a sub that this file calls with it. batch_methods(arrayref)-
The producing methods that return a batch of results rather than one result; none by default. A batch's keys are the results, plus counts about the batch itself, so a variable bound from a batch method is not treated as a result -
$batch->{'lookups'}is not a read of one - whilemy $point = $batch->{$value}makes$pointa result. list_methods(arrayref)-
The producing methods that hand back a list of results rather than one - a report's
results(), the checks a runner ran; none by default. The variable that takes a result out of the list is a result like any other: the loop variable offor my $entry (@{ $report->results() }), of a list assigned from that call first, and the topic of amaporgrepblock over it. value_object(hashref)-
{ class => 'Widget', from => {...}, derives => {...} }.classis required when this is given and must be a module name.fromis what an object is built from for check 2,derivesthe accessor => value pairs it must then return. conditional(hashref)-
Key => why it is not guaranteed, used in the failure messages and accepted by check 4 as accounting for a documented accessor.
producers(hashref)-
Path => arrayref of keys the file must still set. Paths are relative to
dist_root, or absolute. workspace_producers(hashref)-
Like
producers, but paths are relative to the workspace: producers in a sibling distribution that this distribution's value object comes from. consumers(hashref)-
Workspace-relative (or, for
own_consumers,dist_root-relative) path =>{ may_rely_on => { sub_name => { key => why } } }. A read outside any named sub is registered under the sub name(file scope). ignored(arrayref)-
[prefix, reason]pairs: a discovered file whose path starts with the prefix is not a consumer. Each rule must be an arrayref whose first element is the prefix. accessors(arrayref)-
Documented accessors a consumer may call on a result.
not_accessors(hashref)-
Documented accessors of the value object that are not a slot of one result - the constructor, a class constant - as name => why, so that the accessor check can be satisfied by saying what a name is instead of listing it as something a consumer may read. A name here that the value object does not document fails, as an entry in
accessorswould. inspection(arrayref)-
Methods such as
can,isaandDOESthat may be called on a result without being a read of a field. own_consumers(boolean)-
True when the distribution being checked is one of its own value object's readers; false by default. Its
lib,binandscriptsare then scanned for consumers and discovered like a sibling's, but not itst/andxt/: its tests are the proof of the value object rather than readers of it. Its paths are named relative to the distribution, the way a producer's are, so they still point at the right file when the distribution is installed under a versioned directory name. dist_root(string)-
The distribution being checked. Defaults to the parent of the directory holding the running test file, falling back to the current directory when that is not a distribution root. When given, it must be an existing directory.
workspace(string)-
The directory holding the sibling distributions. Defaults to the parent of
dist_root. When given, it must be an existing directory. include(arrayref)-
Workspace-relative lib directories to put at the front of
@INC, so a value object is read from the checkout in the workspace rather than from an installed copy. require_workspace(arrayref)-
Sibling distribution names that must exist for checks 3 and 5 to run. Pass the ones your consumers live in, or those checks cannot tell a missing checkout from a passing one.
messages(hashref)-
Message key => template, overriding the catalogue for this object only. See "i18n".
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
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
$key(string, required)-
The message key, such as
check.producers. \%args(hashref)-
The values for the template's placeholders.
{name}is replaced by the value;{name:%.2f}formats it with thatsprintfconversion (only the conversionss d f g e E x X o b u, with their flags, are honoured). An arrayref value is joined with,. A placeholder with no value is left as written, so a missing argument shows rather than vanishes.packagedefaults to this module's name.When the template is a hash of forms,
contextpicks one by name (a gender, say), and otherwisecountpickszero(if present),oneorother.
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
$text(string, required) - source, normally passed through "strip".$key(string, required) - the key.
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:
it is set from a call named in
methods:my $r = $maker->geocode(...);it takes one result at a time from a call named in
list_methods:for my $r (@{ $report->results }), or$_in amaporgrepover that call;it is set from one entry of a batch (a call named in
batch_methods):my $r = $batch->{$name}. A batch entry also counts when it is put into a hash, or returned from a sub - the callers of that sub then get results too;it is the parameter of a sub that this file calls with a result. For a method call (
$self->show($r)) that is the parameter after$self.
Then it lists every read of those variables.
Arguments
$text(string, required) - source, normally passed through "strip".
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
The scanner is lexical, not syntactic. It reads Perl with regular expressions, so a read inside a string or a heredoc can be taken for code, a trailing comment is not removed (only whole-line comments are), and an unusual layout can hide a read. PPI is the tool that would parse the code properly; moving the scanner onto it is the change that would retire most of the items below, at the cost of speed and a heavier dependency.
Variables are matched by name across the whole file. A
$pointbound from a producing call in one sub makes every$pointin every other sub a result too, and amaporgrepover a list method makes$_a result file-wide.A sub is found only when
sub NAMEstarts in column 0, and it ends at the next}in column 0 (or on its own line, when its braces close there). Subs inside an indentedpackage NAME { ... }block, or closed by an indented brace, are reported as file scope. Signatures (sub f ($self, $r)) are not read, so a helper declared with one is not followed.A single result stored in a hash or returned is not followed; only a value out of a batch is. Helpers are followed by variable name until nothing new is found.
Discovery requires the file to contain the
sourcestring. A consumer that is handed results without naming it is not picked up; add it toconsumersby hand.Extensionless files are discovered by their shebang. A program without one - a compiled binary, a file run through an explicit interpreter - is not seen.
xt/is never scanned, in any distribution.The producer evidence is static: a producer that sets a key by a route the recognised spellings do not cover (
$hash{key} = ..., a key computed at run time) is reported as not setting it.require_workspaceis what tells a missing checkout apart from a passing one. With nothing to require, a distribution checked on its own has no sibling consumers to find and the discovery check fails on finding none.The accessor check reads the value object's own file, so an accessor it inherits, or documents in another file, is not seen unless
can()finds it. A heading or sub that names something which is not an accessor of a result has to be listed innot_accessors.consumer_path()looks in the workspace first, so a dist-relative consumer whose path also exists under the workspace is read from the workspace.includechanges@INCfor the whole process, not for the object.The private subs are wrapped by Sub::Private in
enforcemode, and the_check_*subs by Sub::Protected. Enforce mode is a process-wide setting of Sub::Private, so loading this module switches any other module that uses:Privateinto enforce mode too. Calls from outside the package croak unless$Sub::Private::BYPASSorHARNESS_ACTIVEis set.Each line number is counted from the start of the file, so a file with very many reads is scanned in quadratic time.
The end-to-end tests match the English wording of the messages; a translation passed through
messagesis for users, not for this module's own test suite.
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? ¬in; 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.
LICENSE AND COPYRIGHT
Copyright 2026 Nigel Horne.
Usage is subject to the GPL2 licence terms.