Spec-Driven

Spec-Driven Behavior: Interface, Portability, and Non-Goals

one public function, four regex engines, and the limit on the reproducibility claim

Synopsis:
This page covers what the specification promises across implementations, and where each promise stops.
  • The whole public interface is one function, addSkipDirectory(name), called at build time. It extends the 11 defaults rather than replacing them, so a build that adds .vs cannot lose .git by accident.
  • One other division is fixed across languages for the opposite reason: the traversal component holds the two run counts, and the entry binary asks for the line once.
  • Three non-functional requirements - portability, no third-party TextFinder dependency, and the same match set from every implementation.
The consistency claim is stated with its limits rather than overstated.
  • Reproducible order only over the same tree on the same platform and filesystem, since traversal follows filesystem order.
  • Only for patterns all four engines accept, and only for command lines every argument vector can carry.
  • The guarantee is over stdout and the exit code. What reaches stderr is each implementation's own.
No regex engine spans the four languages, so the specification names one per language and fixes the subset they accept alike.
  • The subset works because TextFinder asks one question of a line and reports the whole line, leaving greediness and capture groups unobservable.
  • A pattern outside the subset is accepted anyway, and Section 5 names the two costs - the larger being a difference not in output but in whether there is output.

1.  The One Public Function

The skip list of §3.2 is extended at build time, not at run time. §3.5 defines one function for it:
addSkipDirectory(name)
Every call is compiled into the program and takes effect before traversal begins; once traversal begins the list is fixed for the run. The function extends the defaults of §3.2 rather than replacing them, successive calls accumulate, and duplicate entries are ignored. The extend-rather-than-replace rule is what keeps the 11 defaults from becoming optional. A replacing function would let a build that wanted to add .vs lose .git by accident, and two implementations could then prune different trees while both satisfying the specification. Each language's specification fixes the exact function name, parameter type, and return type in its own idiom, and says whether the function is exported from a library or confined to the component that owns the list. Nothing in §3.5 requires it to be callable from outside the program. The C++ implementation takes the narrow reading: the function is void addSkipDirectory(const std::string& name), defined in the binary's own translation unit and exported from nothing, so no library and no test can call it. §3.5 also says what has not been decided. A run-time mechanism - a configuration file read at startup, say - may be specified later; until it is, a deployed build searches with the skip list compiled into it and the user has no means of changing that. Naming the absent feature is the point: a reader who wants it knows it was considered, and an implementer knows not to invent one. This is the whole of the public interface. TextFinder is a command-line program, so its real interface is the command line, and §3.5 exists because the skip list is the one thing the command line deliberately cannot reach. One other interface is fixed across implementations, and it is worth setting beside addSkipDirectory because the two are fixed for opposite reasons. §3.6 requires a run to close its output with a line counting the files and directories it reached, and rather than leave each language to decide which component keeps those counts, it fixes that too: the traversal component holds them, increments them where it already makes its selection decisions, never resets them between root paths, and owns the summary's text, while the entry binary calls for the line once - after the last root path, the one fact no component inside the library can know. §2 otherwise leaves internal structure to each language, so this is a deliberate exception and §2 records it as one. The reason is comparability, the same reason §3.4 fixes the block form: a count kept in a different place in each implementation is a count the four cannot be held to agree on, and agreement is exactly what makes a mismatched pair of numbers evidence of a defect. What each language still fixes for itself is the call's name, parameter list, and return type - emitRunSummary() in C++ and C#, emit_run_summary(&mut self) in Rust - in the idiom §3.5 already leaves open for addSkipDirectory. No implementation exposes an accessor for either count: the line is the whole of what they are for, and a unit suite reads them by reading that line through its own output double.

2.  Three Non-Functional Requirements

§6 carries three, and they constrain the implementations more than their length suggests.
  • Portability. Each implementation runs on Windows and on POSIX systems. That is what forces the platform-dependent comparison rules for skip-list entries and extensions, the three line terminators, and the binary-mode requirement behind the LF rule.
  • Dependencies. Implementations use only the standard library and, where necessary, packages from the language's supported ecosystem for regex and filesystem access. No third-party TextFinder library is used. That rule is why the C++ test suites carry a hand-written Checker struct rather than a test framework.
  • Consistency. For the same inputs, every implementation produces the same match set, emitted in depth-first traversal order and, within a file, in line order.
Three further things are fixed rather than left to the implementation, so that a comparison can rest on them: the two-level block form and its indent, the exit code, and the form of the option listing. The guarantee is over stdout. Two implementations given the same command line write the same bytes there and return the same exit code; what they write to stderr is each one's own. That boundary follows §2, which fixes what a user sees on stdout and leaves the rest - the argument vector's type and encoding, the skip-list signature, the wording of anything on stderr, and the standard-library facilities behind each rule - to each component specification, in its own language's idiom. A test comparing two implementations therefore compares stdout and the exit code and leaves stderr to whichever implementation it belongs to. The Command Line page covers what that leaves fixed among the diagnostics, which is more than the wording suggests.

3.  The Limit on the Reproducibility Claim

The consistency requirement carries its own boundary, stated in the same paragraph rather than in a footnote. Because §3.2 leaves a directory's entries in filesystem order, the total emission order is reproducible only across runs over the same tree on the same platform and filesystem. There every implementation agrees, because §3.2 requires each to enumerate through its platform's own facility and forbids reordering what that facility yields, and two runs can be compared line for line. Elsewhere the match set still agrees but the order of matches from different directory entries may not. Two further scopings narrow it. §6.1 confines the guarantee to the patterns the four engines accept alike, and §4 confines it to the command lines every implementation's argument vector can carry - which for C++ means ASCII, since it takes char* argv[] and Windows does not decode that system-codepage vector to Unicode. Three statements, each narrowing the one before, describe a guarantee that is smaller than "the four implementations agree" and true. The alternative was on the table: an earlier draft required sorted sibling order and could claim reproducibility across platforms. The project traded the stronger claim for simpler traversal and rewrote the claim to match. Against those limits §6 sets four things it fixes outright, so that a comparison has something to rest on: the two-level block form and its indent, the exit code, the form of the run summary, and the form of the option listing. The run summary is the newest of the four and the one that carries a number rather than a shape, which makes it the sharpest test of the rest - its two counts follow from selection rules the specification already fixes, so two implementations run over the same tree with the same command line report the same pair, and a disagreement is a defect in one of them rather than a difference of convention. Run over the shared documents of this project, all three implementations built so far report accessed 47 files, 36 directories.

4.  Four Regex Engines and the Portable Subset

No regex engine in common use spans the four languages, and no third-party engine gives one syntax across all four without violating the dependency rule. Each implementation therefore uses the engine its own language uses, and §6.1 names it rather than leaving it to the implementation.
Language Engine
C++ std::regex constructed with std::regex::ECMAScript
Rust the regex crate
C# System.Text.RegularExpressions.Regex
Python the re module of the standard library
A language-specific specification names the engine assigned to it and no other. Substituting a different engine is a change to that table, not a local decision. What the four agree on rests on how little TextFinder asks. It asks one question of a line - does the pattern occur anywhere in it - and under /L reports the whole line, never a match position, a matched substring, or a capture group. Greediness, alternation preference, and capture-group numbering are unobservable, so the four engines differ on nothing TextFinder observes so long as the pattern is one they all accept. That portable subset is:
  • literal characters, and \ before any of . \ * + ? ( ) [ ] { } | ^ $ /
  • .
  • the quantifiers *, +, ?, {n}, {n,}, and {n,m}, each also in its lazy form
  • alternation |, grouping (...), and non-capturing grouping (?:...)
  • character classes [...] and [^...], ranges included
  • the anchors ^ and $
  • the class escapes \d, \D, \w, \W, \s, \S, \b, \B
  • the character escapes \n, \r, \t, \f, \v, and \xHH
Two entries in that list rest on rules stated elsewhere. ., ^, and $ agree because §3.3 splits lines before matching and a line holds no terminator, so the engines' differing treatment of a terminator inside the subject never arises. The rest agree on ASCII lines: std::regex over char matches one byte where the other three engines match one Unicode scalar value, and \d, \w, \s, and \b are Unicode-aware in those three and ASCII-only in std::regex. On a line holding a non-ASCII character, a pattern using ., a character class, or a class escape can match in three implementations and not the fourth.

5.  What Falls Outside, and What It Costs

Outside the subset: backreferences, lookahead, lookbehind, named groups, inline flag groups such as (?i), atomic and possessive quantifiers, Unicode property escapes such as \p{L}, POSIX class names such as [[:alpha:]], and the anchors \A, \z, and \Z. Each is accepted by some of the four engines and rejected by others, and two that accept the same construct do not always spell it the same way. TextFinder accepts a pattern outside the subset. It does not inspect a pattern for portability, and §5.2 defines no diagnostic for one. Two costs follow, and the specification accepts both:
  1. An engine that rejects the pattern reports the diagnostic §5.2 calls for and exits non-zero, while an engine that accepts it searches the tree and exits 0. The two runs then differ in exit code, in stderr, and in every record. This is the larger cost of the two, and it is not a difference in output so much as a difference in whether there is output.
  2. Two engines that both accept the pattern may still disagree on which lines it matches.
The match-set guarantee holds for a pattern in the subset over ASCII lines. Outside that, each implementation's output is the output of the engine named above for it, and a difference between two implementations is evidence about those engines rather than a defect in either. One sentence in §6.1 is a rule for whoever writes the next test: a test that compares implementations states the pattern it uses and stays inside the subset, or it is testing the engines. The demonstration set applies it. Every pattern in that set stays inside the subset, and none of the patterns that match uses ., a character class, or a class escape, so none turns on which engine an implementation names. Where the set does reach for a pattern in one language's own vocabulary - ^export  for C++, ^pub  for Rust, ^public  for C# - it confines that pattern to an invocation rooted in that implementation's own component directories, so the pattern is language-specific but the invocation it runs in searches nothing shared.

6.  What Every Implementation Must Provide

§6.2 requires two kinds of automated test and one demonstration from each implementation, so that a claim about behavior can be checked rather than read.
  • Unit suites, one per library component, each beside the code it tests and exercising that component's own specification.
  • One integration suite, driving the built executable end to end. It covers the entry binary, whose behavior is its startup sequence, its exit codes, and its stream routing, none of which a unit suite reaches.
  • One demonstration, running the built executable against this project's own tree and capturing what it produces. It is a record of observed behavior rather than a test: it asserts nothing and fails nothing.
Each implementation also provides a runner per kind, announcing each suite it starts and the status that suite returned, and exiting with the number that failed. A suite never built counts as a failure rather than passing by absence, so a green run cannot mean that nothing ran. The dependency rule of Section 2 applies, so a suite uses the standard library and the packages that rule already permits and introduces no third-party test framework - which is why the C++ suites carry a hand-written Checker rather than a framework. Assertion wording and counts belong to each implementation; what the suites must agree on across implementations is the observable behavior §3 through §5 already fix. A demonstration's output moves as this project's tree changes, so a capture states its date and is replaced wholesale rather than edited. The C++ Testing and C++ Demonstration pages are that requirement satisfied once.

7.  Two Non-Goals

§7 holds two:
  • TextFinder does not modify files.
  • TextFinder does not follow symbolic links (§3.2) and does not search binary files (§3.3).
Both restate a decision §3 already made, and restating it is the point. §3.2 says a symbolic link is never opened and §3.3 gives the NUL test, so a reader who works through §3 already knows. A reader who wants to know what the program will not do reads §7, and finding the answer there rather than inferring it from two scattered sentences is the difference between a stated non-goal and an accident. The second bullet also carries the cross-reference that names which test makes a file binary. Without it, "does not search binary files" would be a claim with no operational meaning, and two implementations could disagree about what a binary file is while both satisfying §7. With it, the non-goal is a test case. The list was longer once. An earlier version carried three, and a trimming pass folded them to two after the reviews had moved their content into §3. The Non-Goals sections of the four C++ component specifications were trimmed in the same pass - Entry from five bullets to two, and Cmdline's regex bullet corrected from a claim of no validation to "does not compile the regular expression; it checks only that the argument is non-empty", which is what the code does.