diff --git a/.config/nextest.toml b/.config/nextest.toml index 0226a5fd3..8f92dba9d 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -99,6 +99,15 @@ success-output = "immediate" # budget and the test's own allowance meet. slow-timeout = { period = "60s", terminate-after = 10 } +[[profile.default.overrides]] +# The RFC 0006 coverage contract (obligation COV-4 in +# docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md). +# This test's whole point is the line it prints: how many capability groups are +# still unwritten. A half-finished split passes every other coverage check, so +# a stall is only visible if the count reaches the terminal on a green run. +filter = 'test(/^coverage_map_status_is_reported($|::)/)' +success-output = "immediate" + [profile.ci] # The CI-only profile. It exists solely to carry the whole-run budget, and # inherits everything else from `default`, including that profile's diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 9db8c9f1d..b63111cc9 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -16,9 +16,9 @@ concurrency: jobs: release: uses: ./.github/workflows/release.yml - with: - dry-run: true # This workflow runs on pull requests, so it forwards no secrets. The # release workflow reads only `secrets.GITHUB_TOKEN`, which a called # workflow receives without forwarding, so `secrets: inherit` would # only widen what a pull request can reach. + with: + dry-run: true diff --git a/.gitignore b/.gitignore index 3f04de14f..1821450a9 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ __pycache__/ .coverage .uv-cache/ .uv-tools/ +uv.lock .pytest_cache/ .ruff_cache/ .typos-oxendict-base.json diff --git a/Makefile b/Makefile index 33e203bf3..e4452262a 100644 --- a/Makefile +++ b/Makefile @@ -230,6 +230,10 @@ test: test-nextest doctest ## Run every Rust test with warnings treated as error test-nextest: check-build-tools ## Run all non-doctest Rust tests through cargo-nextest $(GATE_RUSTFLAGS) $(CARGO) nextest run --workspace --all-targets --all-features $(NEXTEST_BUILD_JOBS) $(NEXTEST_TEST_JOBS) +.PHONY: test-rfc-stdlib-coverage +test-rfc-stdlib-coverage: check-build-tools ## Check the RFC 0006 split and its parsers + $(GATE_RUSTFLAGS) $(CARGO) nextest run --test rfc_stdlib_coverage_tests --all-features $(NEXTEST_BUILD_JOBS) $(NEXTEST_TEST_JOBS) + doctest: check-build-tools ## Run doctests, which cargo-nextest cannot execute $(GATE_RUSTFLAGS) $(CARGO) test --workspace --doc --all-features $(BUILD_JOBS) diff --git a/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md b/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md new file mode 100644 index 000000000..a280a3ce7 --- /dev/null +++ b/docs/adr-040-focused-child-rfcs-for-survey-rfcs.md @@ -0,0 +1,677 @@ +# Architectural decision record (ADR) 040: Focused child RFCs for survey RFCs + +## Status + +Accepted. + +## Date + +2026-09-11 + +## Context and problem statement + +RFC 0006 is a survey RFC: it enumerates every ansible-core candidate it +considered and records a disposition for each — accept, defer, or reject. Its +section 7 is the candidate matrix, section 8 specifies the accepted helpers, +and sections 9 and 10 record the deferred and rejected remainder. The survey +accepted 57 new helpers, plus three options on existing ones. + +A survey RFC of that shape reaches a size at which its accepted set can no +longer be specified to the standard a normative contract needs. RFC 0006 +section 6 states the cross-cutting contract every accepted helper must +discharge — capability scoping, diagnostics, localization, purity, tests, and +documentation — and closes by saying that a child which does not satisfy every +clause is not complete. Discharging that contract for 60 helpers inside a +2,100-line survey is not reviewable. The per-helper obligations are the part a +reviewer must read closely, and they sit in the part of the document least +likely to be read closely, because the surrounding survey is a catalogue. + +The difficulty is not specific to RFC 0006. Any survey RFC with a large +accepted set has the same shape, and the same absence of a mechanical check +that its accepted set is fully allocated. Nothing in the build would notice a +helper that the survey accepted, no child RFC owns, and no roadmap task +schedules. + +## Decision drivers + +- **Reviewability.** A reviewer should be able to hold one capability group's + obligations in mind at once, without the rest of the catalogue competing for + attention. +- **Traceability.** Every accepted helper must resolve to one owner and one + scheduled task, and no candidate the survey rejected may reappear as a + registered helper under a rejected spelling. +- **Reversibility.** The split must be abandonable part-way. The survey's own + sections 7 to 10 must keep their meaning whether one child RFC exists or + eight do. +- **Derivation over transcription.** The enforcement must read the documents + rather than restate them, so a document edit and a test edit cannot disagree. +- **Narrow scope.** The convention must not be mistaken for a general + amendment mechanism, because the corpus already has one. + +## Requirements + +### Functional requirements + +- The survey RFC's accepted set is partitioned into capability groups, and each + helper in that set is owned by exactly one focused child RFC and delivered by + exactly one roadmap step. Groups and children are not in one-to-one + correspondence: a child may own more than one group where a single roadmap + step delivers them, and a group may be divided between two children where the + roadmap's boundary falls inside it. What is excluded is a helper claimed + twice, or claimed by nobody. +- Each child RFC carries the survey's cross-cutting contract, discharged + per clause, and a registry of the helpers it introduces. +- The survey RFC records the allocation in a coverage map, one row per child, + naming the section 8 subsections that child owns. +- A rejected or deferred candidate is absent from every child registry. + +### Technical requirements + +- The allocation is checked by a repository test that reads the survey RFC, + the child RFCs, and the roadmap, and derives from them every fact it asserts: + the accepted and deny sets, each helper's namespace and registration kind, + the table 11 counts, the section 6.1 purity aggregate, section 6's clause + list, each child's registry, and the roadmap steps. The test does carry a + small number of constants, and they are deliberately not facts about the + survey but anchors for reading it — which subsection numbers hold candidate + tables, the three renamed helpers, the three optioned helpers, and the + proposed-helper count used to cross-check the derived one. An anchor is prose + the parser has to be told where to look for; a fact is prose the parser is + asked to reproduce. Only the latter can go stale in a way a reviewer would + miss, and the amendment procedure below is what keeps the former aligned. +- The test names the file and line of a violation and states the violated + obligation. +- The survey RFC's own disposition sections are not moved, so an abandoned + split leaves every existing cross-reference and contract intact. + +## Options considered + +### Option A: focused child RFCs with a coverage map + +One child RFC per capability group, each owning a contiguous slice of the +survey's section 8 where the group boundaries allow it, with the allocation +recorded in the survey and enforced by a derived test. The survey keeps its +candidate matrix, its dispositions, and its clause list; it gains only the map. + +### Option B: registries in place, no child RFCs + +Keep the accepted set in the survey RFC and add a registry table per capability +group inside section 8, roughly 140 lines in total. No RFC number is spent and +nothing is irreversible, but the per-helper obligations still live in the +survey, and a normative contract discharge — which is what a reviewer signs off +— never receives its own review. + +### Option C: unnumbered per-group design documents + +Write one design document per group on the pattern the repository already uses +for roadmap step 6.11. Cheaper than Option A and fully reversible, but a +document that is not an RFC cannot carry a normative contract discharge, and +the commissioned task called for RFCs. + +| Topic | Option A | Option B | Option C | +| -------------------- | ------------- | ------------ | --------------- | +| Reviewable unit | One group | Whole survey | One group | +| RFC numbers spent | Eight | None | None | +| Reversible part-way | Yes | Yes | Yes | +| Contract discharged | Per child RFC | In place | In a design doc | +| Mechanically checked | Yes | Yes | Yes | + +_Table 1: Comparison of the split options._ + +## Decision outcome / proposed direction + +A survey RFC whose accepted set is too large to specify in place is split into +focused child RFCs, each owning one or more capability groups, and records the +allocation in a coverage map in its own delivery section. Option A is adopted +for RFC 0006. + +The convention has four parts: + +1. **The survey keeps its dispositions.** Sections 7 to 10, the clause list in + section 6, and the numbering are unchanged. The split adds a coverage map; + it does not move a specification. +2. **One child per capability group.** Each child RFC owns a slice of the + survey's accepted-helper sections, and carries that slice's registry, its + per-clause discharge, and its per-helper obligations. Usually that slice is + one whole group; sometimes several groups travel together because one + roadmap step delivers them, and sometimes one group is divided between two + children because the roadmap's boundary falls inside it. A slice is sized so + a reviewer can read the whole child in one sitting. +3. **The map is the allocation.** One row per child, naming the section 8 + subsections owned, the existing helpers gaining an option, the roadmap step + that delivers the group, and whether the child has been written. The rows + partition the accepted set exactly. +4. **A derived test is the guard rail.** The test reads the survey's section 7 + disposition column, section 8, section 14's map, each child registry, and + the roadmap, and fails on a dropped, double-owned, or forbidden name. + +The scope is deliberately narrow. The convention applies to **survey RFCs**: +documents that enumerate a large candidate set with a per-candidate +disposition. It does not apply to design documents, and it does not replace the +`Amends` convention that RFCs 0009 to 0011 use for normative amendments to RFC +0001. A normative change to an existing RFC is still an amendment to that RFC, +not a new child of it. + +### The child RFC template + +A child RFC follows the repository's RFC sections in the order the +[documentation style guide](documentation-style-guide.md) requires, and adds +section 5, which is where the cross-cutting contract is discharged. Copy the +skeleton below literally. Three of its headings are parsed by name, so a child +that renames one fails before any of its content is read; each carries a note +naming the constant that reads it. + +```markdown +# RFC 00NN: + +## Preamble + +- **RFC number:** 00NN +- **Status:** Proposed +- **Created:** YYYY-MM-DD +- **Parent RFC:** RFC 0006, Ansible-inspired template standard-library + expansion +- **Roadmap step:** 6.N +- **Originating issue:** [#596](https://github.com/leynos/netsuke/issues/596) + (closed) +- **Release target:** v0.1.x or later; must not widen the v0.1.0 hardening + release defined by [#594](https://github.com/leynos/netsuke/issues/594) + +## 1. Summary + +<What this group buys a manifest author, in three or four sentences.> + +## 2. Problem + +<The subprocess contortion this group removes, from RFC 0006 section 2.> + +## 3. Goals and non-goals + +- Goals: + - <Goal> +- Non-goals: + - <Every deferred or rejected candidate adjacent to this group, named.> + +## 4. Capability set + +<One entry per helper: name, one-line purpose, and a link to its contract in +RFC 0006 section 8.N. This section does not restate the contract.> + +## 5. Cross-cutting contract conformance + +### 5.1. Registry + +<The five-column table. Mandatory. The heading is parsed literally by +`registries.rs`, which matches `### 5.1. Registry`; a child that retitles it +fails with "has no registry table at ### 5.1. Registry" before any row is +read.> + +### 5.2. Manifest-query availability + +### 5.3. Determinism + +### 5.4. Capability boundary + +### 5.5. Platform contract + +### 5.6. Type and error contract + +### 5.7. Canonical value equality + +### 5.8. Resource bounds + +### 5.9. Diagnostics and localization + +### 5.10. Naming and alias policy + +### 5.11. Documentation and testing obligations + +### Clause discharge + +<The two-column discharge table: one row per clause of RFC 0006 section 6, the +clause id in backticks, and how this group meets it. It closes section 5, +after the eleven clause subsections, because it resolves all eleven rather than +adding a twelfth. Unnumbered on purpose: numbering it 5.6 would collide with +the clause subsection of that number, whose title — "Type and error contract" — +RFC 0006 clause 6.6 carries too. `clauses.rs` matches this heading, and the +table under it, as `Clause discharge`.> + +| Clause | Discharge | +| ------ | --------- | +| `6.1` | <...> | +| `6.11` | <...> | + +## 6. Dependencies + +<Crates from RFC 0006 section 13.4, and the child RFCs this one requires.> + +## 7. Delivery + +<The roadmap tasks that implement this RFC, by number.> + +## 8. Open questions + +<RFC 0006 section 16 questions assigned to this group, carried over +unresolved.> + +## 9. Recommendation + +<One paragraph: why this group's helpers belong in the v0.1.x line.> +``` + +### The registry row shape + +Section 5.1's table has exactly these five columns. The cells are parsed, not +prose, and each column has one accepted vocabulary: + +| Helper | Namespace | Registration | Purity class | Manifest query | +| ----------- | --------- | ------------ | ------------ | -------------- | +| `path_join` | Filter | New | Pure | Yes | +| `abs` | Test | New | Pure | Yes | +| `basename` | Filter | Option added | Pure | Yes | + +_Table 2: The registry row shape._ + +- **Helper** — backticked; a name registered twice in one registry is an error. +- **Namespace** — `Filter`, `Test`, or `Function`. +- **Registration** — `New`, or `Option added` for one of the three existing + helpers gaining a behaviour-preserving option. The distinction is + load-bearing rather than descriptive: the purity aggregate counts `New` rows + only, because RFC 0006 section 6.1's 52/4/1 counts the 57 proposed helpers, + and an `Option added` row is an existing helper being extended. +- **Purity class** — one of RFC 0006 table 2's six values. +- **Manifest query** — `Yes` or `No`, matching the column of the same name in + RFC 0006 table 2 rather than introducing a second vocabulary for the same + fact. The cell is cross-checked against the purity class: `Yes` is admissible + only for a pure helper, because clause 6.2 admits only pure helpers to the + manifest-query environment and registers the non-pure ones as always-failing + stubs rather than omitting them. A non-pure row therefore reads `No` and + still resolves in both environments; the `No` is the stub's disposition, not + its absence. + +### The worked section + +The template above is a shape. This is that shape filled in, for RFC 0013, the +group roadmap step 6.2 delivers: the first child written from it, and so the +one that settles what a discharge reads like before seven more inherit whatever +it settles. It is a specimen, not the artefact. When RFC 0013 is written it +carries this section, and that copy is the normative one. + +It is reproduced filled in rather than described, because the two things a +reviewer has to judge — whether a clause subsection says something the clause +does not, and whether the discharge table resolves all eleven clauses — are +visible only in a completed copy. One formatting note carries over with it: +`markdownlint` caps a fenced block at 120 columns and `mdtablefix` does not +reflow one, so the tables below are sized to fit rather than laid out for the +page. A child RFC is real Markdown rather than a fence, so it inherits neither +limit, and its tables may be as wide as they read best. + +```markdown +## 5. Cross-cutting contract conformance + +This section discharges RFC 0006 section 6 for the five helpers section 4 +lists. Where a clause's consequence follows from RFC 0006 alone and is the same +for every group, the subsection says so in one line; where this group forces a +decision, the subsection states the decision. + +### 5.1. Registry + +| Helper | Namespace | Registration | Purity class | Manifest query | +| --------------- | --------- | ------------ | ------------ | -------------- | +| `from_json` | Filter | New | Pure | Yes | +| `from_yaml` | Filter | New | Pure | Yes | +| `from_yaml_all` | Filter | New | Pure | Yes | +| `to_yaml` | Filter | New | Pure | Yes | +| `to_nice_json` | Filter | New | Pure | Yes | + +All five rows are `New`: this group introduces five helpers and extends none. +All five are pure, so all five fall inside RFC 0006 section 6.1's 52, and this +RFC accounts for 5 of them. + +### 5.2. Manifest-query availability + +All five are pure, so `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`, and +`to_nice_json` all register in `register_query_helpers`, and none registers in +`register_disabled_query_helpers` as a stub. The consequence is that +`netsuke help targets` gains five working helpers and no new always-failing +stub. One caveat, carried rather than resolved here: if RFC 0006 section 16 +question 1 is answered by registering `to_nice_yaml` solely to raise a +diagnostic naming `to_yaml(indent=4)`, that registration introduces no accepted +helper and so is not a section 5.1 row; it is decided in section 8. + +### 5.3. Determinism + +Two group-specific obligations follow from clause 6.3, and both are testable +without reading prose. + +- **Key order is an output, not a side effect.** `from_json` preserves object + order because `serde_json` is built with `preserve_order`; `from_yaml` + preserves mapping order; `to_yaml` and `to_nice_json` emit insertion order + unless `sort_keys=true`. A round trip therefore returns a mapping in the + order it went in, and the tests assert order, not merely equality. +- **Trailing-newline behaviour is a contract, not a convention.** `to_yaml` + ends with exactly one trailing newline and `to_nice_json` with none, so one + composes as a whole document and the other composes inside a larger one. The + asymmetry is deliberate and is pinned by a test at each end. + +### 5.4. Capability boundary + +No additional obligation beyond RFC 0006 section 6.4. The clause's substantive +rules all address helpers that reach outside their arguments, and no helper in +this group does: none takes a `cap_std` handle, none takes an injected reader, +and none can violate the clause's trapdoor rule about a filesystem predicate +reporting `false` for an out-of-scope path. + +### 5.5. Platform contract + +No additional obligation beyond RFC 0006 section 6.5. The clause's obligations +attach to helpers whose behaviour varies by platform or that parse another +platform's syntax: no helper here takes a `dialect` argument, none can fail +with a platform diagnostic, and all five emit LF on every platform. + +### 5.6. Type and error contract + +RFC 0006 section 8.1 specifies the kinds each helper accepts. What follows is +the conditions under which a kind, a key, or an option value is rejected, each +carrying a code from section 5.9. Per helper, because the three parsing +helpers and the two serializers do not share a rejection set. + +- `from_json` accepts a string. It rejects `wrong_kind`, `syntax`, + `duplicate_key`, `depth_exceeded`, and `length_exceeded`. +- `from_yaml` accepts a string. It rejects every `from_json` condition, and adds + `unsupported_key` for a sequence or mapping key, `special_tag`, `merge_key`, + `alias_budget`, and `document_count` for a stream that is not exactly one + document. +- `from_yaml_all` accepts a string and applies every per-document condition from + `from_yaml` to each document. `document_count` is the one condition it does + **not** inherit: RFC 0006 section 8.1 makes a stream of zero documents an + empty sequence, not an error, and multi-document input is the whole point of + the helper. The input-length and node budgets apply to the whole stream + rather than to each document. +- `to_yaml` accepts any value except undefined. It rejects `undefined_input` + and `indent_out_of_range` outright, plus `unsupported_key` when + `sort_keys=true` meets a mapping key with no canonical JSON form, + `unsupported_kind` for a value that has none, and `output_too_large` when + the rendered output would exceed the ceiling section 5.8 enforces. +- `to_nice_json` accepts any value except undefined, and rejects the same five + conditions as `to_yaml`, with the difference that section 8.1 states its key + rule directly: integer and boolean keys are rendered in canonical string + form and every other key kind is rejected rather than coerced. Distinct source + keys that render to one JSON key are rejected with `duplicate_key`. + +Three decisions this group adds: + +- **`none` is accepted; undefined is not.** Clause 6.6 makes undefined an error + and makes `none` a value. Every helper here follows that split, and the line + between them is where a manifest author is most likely to be surprised, so + each of the five documents it explicitly rather than leaving the clause to + imply it. +- **A duplicate key is rejected with a position.** `from_json` and `from_yaml` + name the duplicated key and the offset of its second occurrence. This is the + one place the group adds detection the underlying parser does not perform, + and the reason is that last-key-wins is a silent data-loss trapdoor in a + build manifest rather than a tolerable convenience. +- **The two `indent` ranges differ on purpose.** `to_yaml` accepts 1 to 8 and + `to_nice_json` accepts 0 to 8. Zero is meaningful for JSON, where it means + compact output, and meaningless for block YAML, where it would mean no + indentation at all. Both reject an unknown value with an error enumerating + the accepted range, satisfying clause 6.6's enumerated-option rule and + roadmap task 3.15.5. + +### 5.7. Canonical value equality + +Clause 6.7 governs serializer key sorting and canonical round-trip claims. +The obligation here is to say which of the clause's +exclusions this group can meet, and to fix how the round trips are stated. + +- `sort_keys=true` sorts mapping keys by their RFC 8785 canonical key, so two + mappings that differ only in insertion order serialize identically. That is + clause 6.7's relation applied to a key-ordering decision, and it is why the + option can promise deterministic output at all. +- The clause excludes undefined, callables, and the `now()` timestamp object, + because none has a canonical JSON form. Undefined is already rejected on + input by clause 6.6; the other two are rejected on output as + `unsupported_kind`, because a manifest can hold a callable or the result of + `now()` and pass it to a serializer, and clause 6.7 makes that a typed error + naming the value kind rather than a silent rendering. +- The group defines **no second equality relation** for round-trip testing. + Both `value | to_yaml | from_yaml` and + `value | to_nice_json | from_json` are asserted equal under clause 6.7 only + for values in its canonical-JSON domain: JSON-compatible values whose mapping + keys are strings at every nesting level. The serializer may accept other + values, but their conversion does not promise canonical equality. +- **Integer and boolean JSON keys convert lossily.** A single integer key `1` + serializes as the JSON property `"1"`; a single boolean key `true` serializes + as `"true"`. `from_json` reads either as a string key, so neither input is + within the JSON round-trip guarantee. The same applies when a mapping with + such keys is nested inside a sequence. String-keyed mappings, including + nested mappings, remain within the guarantee. +- **Rendered-key collisions remain errors.** A mapping containing integer key + `1` alongside string key `"1"`, or boolean key `true` alongside string key + `"true"`, is rejected with `duplicate_key`, naming both source keys. The + check is on rendered keys because that is where the collision occurs. + +### 5.8. Resource bounds + +The bounds are RFC 0006 table 3's, applied through checked comparison before +allocation. What this group adds is where each one is enforced, because the JSON +and YAML parsers do not share a code path. + +| Helper | Bounds enforced | +| --------------- | ---------------------------------------------------- | +| `from_json` | input 8 MiB; nesting depth 128 | +| `from_yaml` | input 8 MiB; depth 128; alias expansion 100000 nodes | +| `from_yaml_all` | the same three, over the whole stream | +| `to_yaml` | output 8 MiB; checked before the result is returned | +| `to_nice_json` | output 8 MiB; checked before the result is returned | + +Three consequences this group decides: + +- **`from_yaml_all`'s budget is stream-wide.** A stream of many small documents + is bounded in total, so a caller cannot evade the input ceiling by splitting + an expansion bomb across document boundaries. The diagnostic reports the + stream total rather than a per-document figure, which is what makes the bound + auditable after the fact. +- **The alias budget is the one bound that can fail the implementation slice + rather than the input.** Clause 6.8 requires the expansion to be rejected + before allocating, and if `serde-saphyr` cannot bound alias expansion then + this RFC rejects aliases outright and records that in the guide, per RFC 0006 + section 8.1 and roadmap task 6.2.2. That choice is carried as section 8's + open question 4 and is not resolved here. +- **The serializers bound their output because a shared value is not a bounded + input.** An earlier draft of this group read "none" into both rows, on the + reasoning that a serializer allocates proportionally to nothing but the value + it is handed. That reasoning is wrong, and the runtime shows how: MiniJinja + values are reference-counted, so a value built by repeated doubling is a graph + whose *logical* size is exponential in its construction depth, while its + in-memory footprint stays linear. One recursive macro with twenty doublings + emits `{{ v | to_nice_json }}` as **4,194,301** bytes — 2× per level, from a + four-line template with no large input anywhere. Clause 6.8 names + "materialized output" precisely for this and requires the rejection *before* + allocating, so counting bytes as they are written is not enough. Each + serializer counts first: a pass walks the value computing the output length + with checked arithmetic, abandoning the walk the moment the running total + passes the ceiling, so a doubled value stops after 8 MiB of *logical* nodes + instead of expanding. Only a value that fits is then written. The failure is + `output_too_large`, at the same 8 MiB ceiling the parsers apply to input. + +### 5.9. Diagnostics and localization + +The group defines one private domain error enum, `InterchangeError`, and +exactly one `impl From<InterchangeError> for minijinja::Error`, per clause 6.9. +Every message is a Fluent key and every error carries a machine code. The codes +are this group's contribution to clause 6.9's policy, so they are enumerated +rather than described. + +| Condition | Code | +| ------------- | ----------------------------------------------- | +| not a string | `netsuke::jinja::interchange::wrong_kind` | +| syntax | `netsuke::jinja::interchange::syntax` | +| duplicate key | `netsuke::jinja::interchange::duplicate_key` | +| key kind | `netsuke::jinja::interchange::unsupported_key` | +| special tag | `netsuke::jinja::interchange::special_tag` | +| merge key | `netsuke::jinja::interchange::merge_key` | +| alias budget | `netsuke::jinja::interchange::alias_budget` | +| document count | `netsuke::jinja::interchange::document_count` | +| depth | `netsuke::jinja::interchange::depth_exceeded` | +| length | `netsuke::jinja::interchange::length_exceeded` | +| undefined | `netsuke::jinja::interchange::undefined_input` | +| indent | `netsuke::jinja::interchange::indent_out_of_range` | +| value kind | `netsuke::jinja::interchange::unsupported_kind` | +| output length | `netsuke::jinja::interchange::output_too_large` | + +Each code's Fluent key is the code's reason in upper snake case under +`STDLIB_INTERCHANGE_`, so `wrong_kind` pairs with +`STDLIB_INTERCHANGE_WRONG_KIND` and `indent_out_of_range` with +`STDLIB_INTERCHANGE_INDENT_OUT_OF_RANGE`, per clause 6.9's +`keys::STDLIB_<MODULE>_<CONDITION>` form. + +The module segment is `interchange` rather than `json` or `yaml`, because one +enum serves both parsers and both serializers and the code names the capability +group, not the syntax. All five helpers — `from_json`, `from_yaml`, +`from_yaml_all`, `to_yaml`, and `to_nice_json` — reach their errors through this +enum: every `Error::new` call in the group's leaf functions is replaced by a +variant of it, so a caller can tell an interchange failure from a manifest +diagnostic by the code alone. Clause 6.9 rejects ad hoc construction at this +scale, and a group with fourteen conditions is the case it names. + +### 5.10. Naming and alias policy + +No additional obligation beyond RFC 0006 section 6.10. The clause registers one +name per capability and this group adds five, none an alias. One of the clause's +own examples is still live here rather than settled: `to_nice_yaml` is rejected +by RFC 0006 section 10.2 as redundant with `to_yaml(indent=...)`, and whether +that rejection is expressed as outright absence or as a diagnostic-raising +registration is RFC 0006 section 16 question 1, carried unresolved to section 8 +and decided at roadmap task 6.2.3. + +### 5.11. Documentation and testing obligations + +No additional obligation beyond RFC 0006 section 6.11. The clause's seven +obligations apply unmodified. One group-specific note rather than a +restatement: the two round-trip properties required by clause 6.11.4 use the +canonical-JSON domain stated in section 5.7. The serializer's lossy integer +and boolean key cases and collision errors are separate acceptance tests. The +serialization-determinism property it names is the same proposition as section +5.3's key-order requirement, tested from the other side. + +### Clause discharge + +| Clause | Discharge | +| ------ | ------------------------------------------------------------- | +| `6.1` | Five pure `New` helpers; the five section 5.1 rows are 5 of 52. | +| `6.2` | All five pure, so all register in `register_query_helpers`, none stubbed. | +| `6.3` | Mapping order in and out; one trailing newline for `to_yaml`, none for `to_nice_json`. | +| `6.4` | No filesystem, environment, or subprocess access; no handle taken. | +| `6.5` | No `dialect` argument; all five emit LF everywhere. | +| `6.6` | Undefined rejected, `none` accepted; duplicates rejected positionally; both `indent` ranges enumerated. | +| `6.7` | `sort_keys` sorts by canonical key; round trips use canonical-JSON domain; converted JSON keys are lossy. | +| `6.8` | Table 3's bounds, stream-wide for `from_yaml_all`; serializers pre-check output length. | +| `6.9` | One enum, one `From` impl, fourteen `netsuke::jinja::interchange::*` codes. | +| `6.10` | Five new names, no alias family, none reused across namespaces. | +| `6.11` | The clause's seven obligations, plus the two round trips and the determinism property. | +``` + +## Goals and non-goals + +### Goals + +- Make the survey's accepted set fully allocated and mechanically checked. +- Give each capability group one reviewable, normative document. +- Keep the split reversible at every milestone before the last. + +### Non-goals + +- Changing any disposition the survey recorded. +- Generalizing the convention beyond survey RFCs. +- Replacing the amendment convention for normative changes to existing RFCs. +- Scheduling the work outside the roadmap. Delivery is tracked by roadmap + tasks, not by per-child issues. + +## Amendment procedure + +When a helper is added, removed, or renamed after the split, edit the +touchpoints below in this order. The order matters only in that each step's +subject must exist before the next step can cite it; the coverage test then +fails until all of them agree. + +1. **The survey's section 7 row** — record or change the disposition, and cite + the section 8 subsection that specifies the helper. +2. **The survey's section 8 subsection** — add, remove, or rename the helper so + the specification matches. +3. **The survey's section 14 coverage map** — the owning row's `Owns` clause + must still claim the helper. A new section 8 subsection needs a row to own + it, and a ninth capability group needs a new child RFC number and a new row. +4. **The owning child RFC's registry** — its section 5.1 row carries the + helper, namespace, registration kind, purity class, and manifest query. +5. **The roadmap task** in the owning step — name or rename the helper there, + so the capability remains scheduled. + +Three further touchpoints are not per-helper, and each is reached by a change +of a different kind. They are listed separately because a helper edit does not +touch them and an edit that does is easy to forget: + +1. **The survey's section 3.2 namespace lists** — a helper added to a namespace + the survey does not already list there belongs in the corresponding list. + The test derives each helper's namespace from section 7, so a section 3.2 + list is not what it reads; the lists are what a _reviewer_ reads, and a + helper absent from both is a helper the survey never introduces. +2. **The survey's section 6.1 purity statement and table 11 counts** — both are + prose counts written in number words, and both are derived rather than + transcribed by the test. Changing a helper's purity class therefore means + editing the sentence that states how many helpers are pure, and adding or + removing an accepted helper means editing table 11's totals. The test fails + when they disagree, so this step is enforced rather than merely advised. +3. **The test's own anchors** — the subsection numbers, the renamed and + optioned helper lists, and the proposed-helper count named in the technical + requirements above. These are the only places a survey fact is written down + twice. A change that moves a candidate table to a different subsection, or + that changes which helpers are renamed or optioned, must edit the anchor and + the survey together; a change that edits only one of them stops the suite + reading the survey at all, which is a louder failure than a wrong count. + +Removing a helper without removing its registry row fails the ownership check, +which reports a name the survey no longer accepts. Renaming one moves the old +spelling into the deny set, because the deny set is the complement of the +accepted set; a child registry that reintroduces the old spelling then fails +the forbidden-name check. Neither failure needs a new rule: both follow from +the derivation. + +## Known risks and limitations + +- **The survey is not ratified.** RFC 0006 is `Proposed`, and no RFC in the + corpus has been ratified. Freezing its dispositions into a test may prove + premature. The mitigation is structural: because section 8 does not move, a + later change costs a registry row and a map row rather than a document + rewrite. +- **Eight consecutive numbers are spent.** Allocation is irreversible once + merged. Numbers are therefore allocated lazily, one per child at the commit + that creates it, and a reservation recorded in a branch reserves nothing. +- **The `Owns` grammar is small by design.** It expresses "every helper in + this section", "all but one", and "only this one", with clauses joined by + semicolons. A capability group that needs a different split requires a new + clause form and a parser change, which is the intended cost of keeping the + grammar reviewable. +- **The split is disproportionate for a small accepted set.** The convention + earns its cost only when the accepted set cannot be specified in place; for a + handful of helpers, Option B is cheaper and is the fallback. + +## Architectural rationale + +The repository's documents are the source of truth for their own contracts, and +its tests derive what they check rather than transcribing it. This decision +applies both: the survey remains the only place a disposition is recorded, and +the child RFCs remain the only place a group's obligations are specified, with +the coverage map as the single join between them. Splitting by capability +rather than by delivery slice keeps each child aligned with one roadmap step +and one purity profile, so a child's contract is homogeneous enough to review +as a unit. + +## Implementation references + +- Survey and coverage map: + [RFC 0006 section 14.13](rfcs/0006-ansible-inspired-template-standard-library.md) +- Coverage contract test: `tests/rfc_stdlib_coverage_tests.rs` and the + `tests/rfc_stdlib_coverage/` module tree +- Delivery tracking: [Netsuke roadmap section 6](roadmap.md) +- Child RFCs 0013 to 0020, reserved by RFC 0006 section 14.13 diff --git a/docs/contents.md b/docs/contents.md index 323b54933..0cd15077b 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -75,6 +75,9 @@ operator, user, and contributor references are easier to find. Survey of the ansible-core Jinja standard library, with an explicit disposition for every candidate helper and Netsuke-native contracts for the accepted set. +- [rfcs/0013-structured-data-interchange-helpers.md](rfcs/0013-structured-data-interchange-helpers.md): + First focused child of RFC 0006: the JSON and YAML interchange helpers and + the cross-cutting contract discharged for them. - [rfcs/0007-netsukefile-testing-framework.md](rfcs/0007-netsukefile-testing-framework.md): Proposed Netsukefile testing framework: the `netsuke test` command, the YAML test dialect, and its mocking model. @@ -232,6 +235,10 @@ operator, user, and contributor references are easier to find. Runtime annotation introspection declared unsupported for the workflow contract tests, with the loader gate's stale `TYPE_CHECKING` claim corrected and a revisit gate that reopens on a real consumer. +- [ADR-040](adr-040-focused-child-rfcs-for-survey-rfcs.md): Splitting a survey + RFC into focused child RFCs, each owning one or more capability groups, with + the accepted set partitioned by a coverage map and guarded by a + derivation-based coverage test. - [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md): `shell_quote` and `shell_join` as the canonical recipe quoting surface, with two dialects and a host-dependent default. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e36eb5c29..9c9dd03ed 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -4567,6 +4567,58 @@ provisions `pytest`, `pyyaml`, `hypothesis`, and `cmd-mox==0.2.0` through `uv run --with`, so `uv` is the only prerequisite and no virtual environment needs creating by hand. +### RFC 0006 standard-library coverage contract + +`tests/rfc_stdlib_coverage_tests.rs` runs seven repository checks: + +| Check | Responsibility | +| --------------------------------------------- | --------------------------------------------------- | +| `every_accepted_helper_has_exactly_one_owner` | Partition accepted helpers across child RFCs. | +| `no_forbidden_helper_is_registered` | Keep deferred and rejected candidates out. | +| `totals_and_purity_aggregate_agree` | Reconcile accepted totals and purity counts. | +| `coverage_map_status_is_reported` | Report written and unwritten capability groups. | +| `inter_document_links_resolve` | Resolve relative RFC links to files in the tree. | +| `every_capability_has_a_roadmap_task` | Match owned helpers to tasks in the assigned step. | +| `every_child_discharges_every_clause` | Check child RFC clause coverage and section 5 text. | + +The private modules in `tests/rfc_stdlib_coverage/` parse the source documents, +derive helper sets, and validate the partition. `survey`, `section7`, +`section8`, `totals`, and `assertions` read RFC 0006; `map`, `registries`, and +`clauses` read its coverage map and each child RFC; `roadmap` reads +`docs/roadmap.md`; `links` checks relative targets across the RFC corpus. +`document` and `markdown` provide the shared structural and lexical parsing +used by those readers. The contract therefore depends on the Markdown in RFC +0006, child RFCs, and the roadmap staying in the supported shapes. + +Partial coverage can pass: unwritten child groups are allowed while the split +is in progress, and the status check reports rather than rejects that state. +The output count is the liveness signal; it must reach zero before the split is +complete. `.config/nextest.toml` gives +`test(/^coverage_map_status_is_reported($|::)/)` the +`success-output = "immediate"` override so this count appears during the suite, +as soon as this test finishes, including on a successful run. + +Run `make test-rfc-stdlib-coverage` for the focused Rust contract binary, +`make test-workflow-contracts` for the workflow contracts, and `make test` for +the full Rust suite. The focused target uses the repository's standard Rust +gate flags and Nextest configuration. The parser tests live beside the private +functions they exercise; filesystem cases use isolated temporary fixtures via +the test-only `Repo` constructor, which is private to that module tree. Those +fixtures are not a general-purpose Markdown parsing API. The link property's +private depth helper belongs only to its reference model: it checks traversal +before the model renders the normalized path. The Nextest contract's private +policy helper checks only an override already selected by the exact filter; its +caller owns loading and selection. Neither helper is a shared parser or a +runtime interface. + +The parser intentionally handles a narrow subset: ATX headings, simple +pipe-delimited table rows, and fenced blocks with up to three leading spaces. +It does not parse full CommonMark or recognize fences nested in list items. +Relative links are extracted from inline Markdown link syntax and checked for +target-file existence; fragment anchors are not validated. See +[ADR-040](adr-040-focused-child-rfcs-for-survey-rfcs.md) for the split and its +coverage contract rationale. + ### Configuration-precedence regression tests The config-precedence ladder and display-policy domain are covered by three diff --git a/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md b/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md new file mode 100644 index 000000000..eae6f223f --- /dev/null +++ b/docs/execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md @@ -0,0 +1,4312 @@ +# 6.1.1. Split the RFC 0006 accepted set into focused child RFCs + +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances (exception triggers)`, `Risks`, `Progress`, +`Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`, +`Conformance basis`, and `Verification plan` must be kept up to date as work +proceeds. + +Status: IN PROGRESS + +## Purpose / big picture + +[RFC 0006](../rfcs/0006-ansible-inspired-template-standard-library.md) surveys +every filter, test, and function exposed by `ansible-core` 2.21.3, records an +accept, defer, or reject disposition for each, and specifies fifty-seven new +Netsuke helpers across ten capability groups in 2132 lines. Its section 14 says +it is deliberately not one implementation change, and its section 17 recommends +scheduling the capability groups as focused children after v0.1.0 final. + +Two things are missing, and only two. + +First, there is no per-helper record of the obligations that RFC 0006 section 6 +imposes. Section 6.1 requires every helper to carry a purity label "recorded in +its documentation entry and asserted by a test", and gives an aggregate — +fifty-two pure, four filesystem-observing, one environment-observing — but **no +table anywhere assigns a class to a named helper**. The same is true of the +resource bounds in table 3, the diagnostic codes in section 6.9, and the +manifest-query disposition in section 6.2. An implementer of a capability group +must currently re-derive all four from prose, for every helper, and no reviewer +can check the derivation. + +Second, there is no mechanical way to answer the question the roadmap asks: is +every accepted capability covered exactly once, and is every deferred or +rejected candidate covered not at all? That is a bijection over the accepted +set, which is sixty names — the fifty-seven new helpers plus the three existing +helpers that gain a behaviour-preserving option — and a bijection over sixty +names is not reliably checked by reading. + +After this change each of roadmap phase 6's eight capability steps has a +focused child RFC that discharges the cross-cutting contract for its own +helpers, headed by a five-column registry table naming every helper in the +group with its namespace, registration kind, purity class, and manifest-query +availability. That registry is simultaneously the artefact section 6.1 asks for +and the machine-readable anchor for the coverage check. RFC 0006 keeps its +survey and its per-helper contracts unchanged and gains a coverage map. + +The observable result is documentation plus one executable contract. A reader +can run `make test` and see a test binary derive the accepted set from RFC +0006's own section 7 disposition tables, derive the forbidden set the same way, +and assert that each accepted helper appears in exactly one child RFC's +registry and no forbidden name appears in any. Dropping a helper, listing it +twice, assigning it to the wrong child, or reintroducing a rejected Ansible +spelling all fail by name. + +This plan is approval-gated. It must be reviewed and explicitly approved before +implementation begins. + +## Scope: settled + +The roadmap and this plan now agree, so there is no divergence left to weigh. +This section records how that was settled, because the earlier drafts turned on +it. + +`docs/roadmap.md:744` used to read "Split the RFC 0006 accepted set into +focused child issues", with success measured as "exactly one open child issue". +The commissioned task asked instead for focused **child RFCs and accompanying +roadmap tasks**, and the reviewer has confirmed that reading and directed that +work be tracked in committed documentation wherever possible. Task 6.1.1 has +therefore been rewritten in place, and this plan implements that wording. + +Three consequences, all now resolved rather than open. + +**No separate issue tracker.** Delivery is tracked by the roadmap checkboxes in +steps 6.2 to 6.9, which are committed documentation and are reviewed with the +code. No child RFC gets a GitHub issue, and the amended task says so. + +**The burn-down survives.** The word *open* in the old wording was doing real +work: an issue closes when its capability lands, giving a live burn-down, and +an RFC never closes. That property is preserved by the roadmap checkboxes, +which already decompose every capability group into tasks. It is not lost, only +relocated — into the file the reviewer asked to track work in. + +**"Exactly one" applies to the RFC, not to the task.** Every one of the 60 +accepted helpers is already named in a task under its owning step, but some are +named by more than one: `product` appears in both 6.4.2 and 6.4.5. The amended +success criterion therefore reads "exactly one child RFC and at least one +accompanying roadmap task". `COV-6` checks the roadmap half mechanically. + +The originating issue [#596](https://github.com/leynos/netsuke/issues/596) was +closed on 2026-08-28 with this very split among its unfulfilled acceptance +criteria, so roadmap 6.1.1 is the surviving carrier of the obligation. Child +RFCs cite #596 as their **originating** issue, not as a tracking issue, so no +child claims an open tracker it does not have. + +## Constraints + +Hard invariants. Violating one requires escalation, not a workaround. + +- Do not implement this plan until the user explicitly approves it. +- **RFC 0006 section 8 is not moved, gutted, or reduced.** Its per-helper + contracts stay exactly where 39 roadmap task bullets already cite them. Child + RFCs are additive. See decision `D3`. +- This work is documentation-only apart from one new test binary and its + wiring. Do not add or change any Netsuke helper, template registration, + locale key, command-line flag, configuration field, or Cargo dependency. +- Do not renumber, rename, or delete RFCs 0001 to 0012. + `docs/documentation-style-guide.md:231` forbids renumbering after publication. +- Do not change any accept, defer, or reject disposition in RFC 0006 sections + 7, 9, or 10. This task partitions the accepted set; it does not relitigate + it. A disposition that appears wrong goes in `Surprises & discoveries` and is + escalated. +- Do not resolve any of RFC 0006's seven section 16 open questions. Each is + assigned to a delivery step and must be settled by that step's implementer. + Carry each into the child RFC that owns it, unresolved. +- Every child RFC records a release target of v0.1.x or later and must not + widen the v0.1.0 hardening release defined by + [#594](https://github.com/leynos/netsuke/issues/594). Note that #594 is + closed but the latest tag is `v0.1.0-beta3`, so v0.1.0 final has not shipped + and the constraint is live. +- No child RFC may register a capability that RFC 0006 section 9 defers or + section 10 rejects **on principle or as a redundant alias**. Names rejected + because Netsuke or MiniJinja already provides the capability are a different + class; see decision `D5`. +- Documentation prose follows `docs/documentation-style-guide.md` and uses + en-GB-oxendict spelling. Body prose wraps at 80 columns; code blocks at 120; + tables and headings are not wrapped. Every fence carries a language: `bash`, + `sh`, `plaintext`, `text`, or `rust`. An unlabelled fence is `MD040`, and a + bare fence under an indented list item also trips `MD031`; the red-control + transcripts were written as indented `text` fences first and failed both. +- Markdown must be `mdtablefix`-canonical. Run `make fmt` before + `make check-fmt`. Never write a backtick inside a backticked span in prose; + `mdtablefix --wrap` corrupts it. The first draft of this plan proved that. +- Run validation commands sequentially, never in parallel, capturing output + with `tee` under `/tmp`. +- Commit only after gates pass, using `git commit -F`, not `git commit -m`. +- Use the shared default Cargo cache. Do not create an isolated one. + +## Tolerances (exception triggers) + +- **Aggregate volume.** If the eight child RFCs together exceed 2400 lines, + stop and escalate. Per-file limits alone cannot catch a set that is + individually reasonable and collectively disproportionate. +- **Per-file volume.** If any child RFC exceeds 400 lines, stop and escalate. + A child carries no per-helper contract, so a larger one means section 5 has + become restatement. +- **Vacuity.** If any section 5 subsection cannot state a group-specific + consequence — a bound, a registry row, a diagnostic code, a purity + assignment, a named error condition — and cannot honestly say "no additional + obligation beyond RFC 0006 section 6.N", stop and escalate. See `D6`. +- **Effort stop-loss.** If any single child-RFC milestone needs more than four + gate cycles, stop and reassess against `Alternatives considered`. +- **Gate false positives.** If `rfc_stdlib_coverage_tests` fails on a change + that touches no section 5 registry table and no RFC 0006 section 7 table, + stop and rescope the parser. Nobody may add an ignore attribute to this test. +- **Number collision.** Re-enumerate remote branches before every child-RFC + commit, not once. If a number is taken, stop and escalate. +- **Scope.** If implementation requires changing a file outside the set in + `Interfaces and dependencies`, stop and escalate. +- **Dependencies.** If the coverage test needs a crate not already available, + stop and escalate. +- **Interface.** If the coverage test would require changing any `src/` file, + stop and escalate. +- **Ambiguity.** If two child RFCs both have a defensible claim on a helper, + stop and present options rather than choosing silently. +- **Iterations.** If `make markdownlint` or `make check-fmt` still fails after + three focused attempts on one file, stop and escalate with the log path. + +## Risks + +- Risk: the section 5 subsections are structurally perfect and semantically + empty — each a restatement of the clause it discharges. A structural test + would certify the emptiness, because heading presence is exactly the property + vacuous prose has. Severity: high. Likelihood: high. Mitigation: this is the + plan's principal risk and the reason for three controls. `D6` cuts section 5 + to five mandatory substantive clauses and permits a single declarative line + for the rest. The anti-vacuity rule gives a reviewer a one-line test. + `CONF-1` requires each subsection to name at least one helper the RFC owns + and to contain no Ansible-deference phrase. And `EP-M3` is a hard go/no-go: + if section 5 for the group `EP-M3` delivers — RFC 0013, the first, not the + smallest — tells a reviewer nothing they did not already know from RFC 0006 + section 6, the remaining seven are not written. + +- Risk: the split stalls half-finished, and nothing is red because the coverage + invariant is satisfied by both the finished and the abandoned state. + Severity: high. Likelihood: medium. Mitigation: because section 8 is not moved + (`D3`), a half-finished split leaves every roadmap citation working and + every RFC 0006 contract intact — the abandoned state is incomplete, not + incoherent. The coverage map carries a status column and `COV-4` reports the + unwritten count in the passing test output, so a stall announces itself on + every run. + +- Risk: an RFC number is taken by a concurrent branch. The repository's + measured base rate is not low: `docs/` contains `adr-003` twice, `adr-004` + three times, and `adr-014` twice — three collisions that all merged. + Severity: medium. Likelihood: medium. Mitigation: `D2` allocates lazily, one + number per child at the commit that creates it, and lands the reservation + rows in RFC 0006 as the first mergeable commit. A reservation on a branch + reserves nothing. Remote heads are re-enumerated before every child commit + and once more before merge. + +- Risk: a rejected Ansible alias is reintroduced while writing a child RFC, + because the alias reads naturally. Severity: medium. Likelihood: medium. + Mitigation: `COV-2`, whose forbidden set is **derived** from RFC 0006 section + 7's disposition column rather than typed by hand. The first draft hand-wrote + that list and omitted sixteen names, including `is_file` — the sibling of the + very alias this risk names. That is the evidence for deriving. + +- Risk: RFC 0006 is `Status: Proposed` and has never been ratified; no RFC in + the corpus ever has. Freezing its dispositions and encoding its totals in a + gate may prove premature. Severity: medium. Likelihood: medium. Mitigation: + nothing here changes a disposition, and because section 8 is not moved, a + later change costs a registry row and a coverage-map row rather than a + document rewrite. `ADR-040` carries the amendment procedure named in `D9`. + +- Risk: the reviewer concludes the split is not worth its cost. + Severity: medium. Likelihood: medium. Mitigation: `Alternatives considered` + states the fallback plainly, and `EP-M0` and `EP-M3` are both go/no-go points + before most cost is incurred. + +## Progress + +- [x] (2026-10-01) **Replay the review corrections onto current main.** + The published head `1f36a7ff6054e35dfdf22396988c72f7667606b8` became + conflicting. Rebase all 63 branch commits from the exclusive boundary + `7677c3886c0bd1cad470c3d1acd62041ec0ca0ab` onto fetched target + `563261b059cf985f0df0590f86ceb71baf18d192`; the replay ends at + `84de9442ff1c3b84083e6bebb67835e11e07ffa0`. The first branch commit's direct + parent and the earlier rebase receipt confirm that boundary; no merge commit + is replayed. Native text merge with `zdiff3` is used, with no selected custom + driver. + + Both conflicts are in `docs/contents.md`: retain the branch's ADR-040 entry + and its later wording correction alongside main's ADR-041 entry. Range-diff + shows only those two context changes and the generated spelling hunk main + already contains. All 130 target-only paths and 45 branch-only paths remain + byte-identical to their corresponding source trees. The changed-line + sequences for the four overlapping Markdown files are unchanged; the fifth + overlapping file, `typos.toml`, already matches both sides. Main's delivered + shell-quoting amendment and this branch's JSON-domain amendment both survive. + The ignored `uv.lock` was preserved outside the worktree and restored + byte-identically after its historical add/remove sequence. + + Main also changes workflows and the Ruff baseline, so previous green gates + cover historical candidates. Repeat the complete sequential gate set on the + rebased tree before a push leased to the recorded published head; + current-head CI and CodeRabbit confirmation remain required before approval + and merge. + + The rebased lint run exited zero but emitted a `comments-indentation` warning + at `release-dry-run.yml:21:5`. Treat that as a failed gate: move the existing + explanation before `with`, without changing workflow behaviour, then repeat + lint and the remaining gates. The focused coverage run passed all 272 tests; + workflow contracts passed 1,016 tests with three skipped. + + **The warning-free rerun is green.** Sequential `make fmt`, + `make test-workflow-contracts`, `make check-fmt`, `make lint`, + `make typecheck`, `make markdownlint`, `make doc-coverage`, `make nixie`, and + `make test` all pass at candidate `84de9442` with tree fingerprint + `bc2152748dfbfd6e9ccde9b456680c5d8eae224d7898fab2c12e7c39726550fc`. Workflow + contracts: 1,016 passed, three skipped. Rust: 3,911 passed, six skipped + across 111 binaries; doctests: 129 passed, 32 ignored across three targets. + Documentation coverage: 98.84%. The focused RFC binary passed 272 tests + before the comment-only workflow fix, and passes inside the full suite too. + Both local CodeScene reports score 10 with no findings. The immediate count + still reports one written group and seven remaining; these corrections do not + finish milestones EP-M4 through EP-M11. + + Canonical full-suite log: + `/tmp/test-5ffbb1df-4543-4fd2-8f84-30f81650519c-6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task-14.out`. + This receipt is the sole edit after that complete run; revalidate its + formatting, Markdown, focused coverage and ExecPlan status before committing. + Publication and hosted checks must cover the resulting commit separately. + +- [x] (2026-10-01) **Implement focused refactors for new CodeScene complexity + findings after the functional review commit.** At `e88d0d1a`, CodeRabbit + confirmed the four validation findings, JSON amendment, and aggregate-method + concern resolved, and accepted the five string-argument metric exceptions. + Three new CodeScene findings arose: complexity and nested condition blocks in + `reference_path`, and complexity in `immediate_output_offences`. The separate + atomic refactor splits independent depth validation from model rendering, and + override selection from policy-field checks. A repository sweep found no + equivalent helpers. Their ownership and permitted callers are recorded in the + coverage suite's developers-guide subsection. Preserve models, diagnostics, + test names, and parser semantics; add no suppression. Local CodeScene and the + complete sequential gate set passed after the refactor. Current-head CI and + CodeRabbit disposition evidence are required before merging. + +- [x] (2026-10-01) **Implement the coverage-validation review corrections and + fix the JSON key-type contradiction.** The starting local HEAD is + `1c35b1d076955cf773c28672089a5c6ddd7ee98c`; the reviewed remote baseline is + `2db228aabc60bde4f75d1d453ef78584706c51bc`. Inspect parser branches and + callers, add direct rejection tests and independent bounded properties, guard + the immediate-output override through the existing TOML loader, and document + the suite. Retain integer and boolean JSON key conversion as explicitly + lossy; restrict canonical round-trip equality to the canonical JSON domain, + including string keys at every nesting level. Amend the normative parent and + align the child and ADR specimen. These changes implement review corrections; + milestones EP-M4 to EP-M11 remain outstanding. + + Tests use isolated temporary capability roots through a private test-only + `Repo::fixture` constructor. Synthetic `World` constructors belong only to + this test module tree. Private validators accept those fixtures while the + seven repository-check wrappers continue loading the same documents once. No + runtime helper is implemented and no tracked document is mutated by a test. + CodeGraph tools are unavailable in this session and Leta has no registered + workspace; direct source and caller inspection is the fallback. + + The first focused run found a property-assertion format capture error; after + correction the binary passed all 272 tests, and workflow contracts passed 986 + tests with 3 skipped. Clippy then rejected assertion panics in fallible + tests, unchecked indexing, shadowing, and string construction. Those patterns + were corrected without suppressions. A second lint pass found sixteen further + statement-terminator and string-collection findings, also corrected without + suppressions. Python docstring sections and assertion failure messages were + corrected as their successive lint stages exposed them. The first full Rust + run found the focused Makefile target missing from the existing + `NEXTEST_TARGETS` inventory; that inventory now includes it and checks its + worker bounds. All required local gates then passed sequentially through one + scrutineer, including the full Rust suite. The verification subsection + records the measured results. Review replies and merge remain conditional on + current-head CI evidence and explicit CodeRabbit confirmation; no further + review will be requested. + +- [x] (2026-09-08) Rewrite roadmap task 6.1.1 to the child-RFC wording and + record that delivery is tracked by roadmap checkboxes, not issues (`D8`). +- [x] (2026-09-11) `EP-M0` Audit; confirm the partition and the derivation + rules. Go/no-go. Every derived count was confirmed except the forbidden-set + size, which the plan gave as 34; that number reconciles only as a row count. + Both stop conditions were raised and both are resolved: `D10` adopts the + complement rule and the deny set is 71, and the note column was **not** shown + to discriminate — the class split is not derivable and is not asserted, so + `D5` rule 3's remedy is deferred rather than needed. Audit results are in + `Surprises & discoveries`; the partition and the per-child registry contents + are confirmed unchanged, so `EP-M1` is unblocked. One further correction: + `D5` rule 2's claim that all three rename rows say `Reject` is wrong for + `hash`, whose row reads "Accept as `text_hash`". +- [x] (2026-09-11) `EP-M1` stage A/B derivation, re-verified against RFC 0006 + before the parser was written. Confirmed: 55 accept rows yielding 55 names + with no alias groups among them, 6 defer names, 67 reject names, an accepted + set of 60, and a deny set of 71. Every membership witness in `COV-2`'s + non-vacuity list holds (`is_file`, `is_dir`, `is_link`, `quote`, `fileglob`, + `lookup`, `win_dirname`, `expanduser` all denied; `basename`, `dirname`, + `abs`, `glob`, `shell_quote`, `splitdrive`, `text_hash` all permitted), and + `hash` is correctly neither — it is an existing helper RFC 0006 leaves + unchanged, so it leaves scope entirely rather than counting as accepted. + `EP-M0`'s class-split recovery was falsified: the recorded rule yields 8 + alias / 24 exists / 18 principle, not table 11's 22/10/18. The *principle* + third is right and every principle row is backtick-free, but the 32-row + remainder splits 8/24 against the table's 10/22, the difference being exactly + the two rename rows `win_splitdrive` and `fileglob`. Recovering 22/10/18 + requires special-casing those two out of *exists* while leaving `now` — also + a reject row naming an existing helper in backticked call form — inside it, + with nothing in the document to distinguish them. The split is therefore + **not derivable** and `COV-2` does not assert it; it asserts the parseable + totals plus that table 11's three class counts sum to the reject-row count. + Amended in place: `D10`'s tail, `COV-2`'s closing note, the audit table's + last row, and the `Surprises` bullet that had recorded the rule as working. +- [x] (2026-09-11) `EP-M1` Land the coverage test, `ADR-040`, the RFC 0006 + corrections and reservations, and the roadmap 6.1.1 rewrite. All seven + coverage checks are green on the unwritten map, `COV-4` reports 8 remaining, + and roadmap 6.1.1 was confirmed to already carry the `D8` wording. Three + parser defects were found and fixed on the way; see + `Surprises & discoveries`. Shipped on the task branch rather than as its own + pull request: the reviewer's instruction names one pull request, and PR #697 + already carries the task title, so the milestones stack there and each lands + as its own commit. Five seeded-fault controls runnable before any child + exists (a deleted coverage-map table, a corrupted section 7 row, a dangling + link, a deleted roadmap bullet, and a bullet moved to the wrong step) each + fail naming the missing table, row, link, or helper, and the suite is green + again once they are reverted; transcripts are in `Verification plan`. The + coverage map's caption was renumbered from table 12 to table 16 in the + process, since table 12 already existed further up the document. The test + module tree was split so that no module exceeds Whitaker's 400-line limit; see + `Surprises & discoveries`. +- [x] (2026-09-19) `EP-M1` second CodeRabbit pass, four findings, all actioned. + Two were latent defects rather than style: the parser was fence-blind, and + the purity aggregate did not apply the `New`-row scoping its own comment + described. Both are proven by evidence and covered by the fence control; see + `Surprises & discoveries`. The other two were the stale `child issue` quote in + `clauses.rs` and RFC 0006 table 1's `0006` row, which recorded the RFC's + `Status` in a column whose every other row records a merge state — the merge + claim was accurate, and the row now states the merge like its neighbours, + with a sentence under the table separating the two facts. +- [x] (2026-09-19) `EP-M1` third pass: clear the three `lint-clippy` errors and + settle the template against the parser. All three were in `markdown.rs` — two + `doc_markdown` backticks and a `missing_const_for_fn` on `is_closing_run`. + The last cascaded: making it `const` made its caller `Delimiter::closes` + eligible in the same run, so the second `make lint` failed one frame further + up. Fixed both in one pass and confirmed the cascade stops at `mark`, which is + `&mut self` and calls the non-const `Delimiter::opening`. The cheap check + (`cargo clippy --test rfc_stdlib_coverage_tests`) is enough to find the next + frame without spending a full gate cycle on it. Separately, and more + importantly: the skeleton `EP-M2` is told to copy literally **failed the + parser `EP-M1` shipped**, on both the registry heading and the manifest-query + cell vocabulary; see `Surprises & discoveries`. Both are fixed in the + skeleton now, before `EP-M3` could spend the go/no-go on a document written + to the wrong contract. +- [x] (2026-09-24) `EP-M2` the child-RFC template and one worked section 5, + both landed in `ADR-040`. The template left this plan for the ADR rather than + the developers' guide, and the worked section followed it: a template that a + parser reads must live where the child author is already sent, and that is + the ADR the convention is stated in. The worked section is RFC 0013's — the + group `EP-M3` spends the go/no-go on — filled in rather than described, so a + reviewer judges the pattern before eight RFC numbers depend on it. Both + parsed artefacts were validated mechanically against the shipped parser: the + registry heading matches `REGISTRY_HEADING`, all five rows parse to `New`/ + `pure`/`yes`, and the discharge table's eleven ids equal section 6's clause + list in document order. Two consequences of writing it are recorded in + `Surprises & discoveries`: the fenced copy is width-bounded by `MD013` in a + way the real child is not, and the first draft's error-condition vocabulary + had invented a `non_string_key` where RFC 0006 section 8.1 says sequence and + mapping keys are rejected. This milestone's acceptance evidence is a + reviewer's, not a test's, and is stated in `EP-M2`'s own entry. +- [x] (2026-09-24) `EP-M2` CodeRabbit pass, ten findings, all actioned. Two were + latent defects that would have fired at `EP-M3`, the go/no-go, rather than + here: the coverage-map link was resolved against `docs/rfcs` as though it + were a file, so `links::resolve` popped into `docs/` and every correct child + link would have been reported missing as `docs/0013-….md`; and `COV-1` + compared registry name sets only, leaving the namespace and registration + columns parsed but never checked, which is exactly what `COV-3`'s filter and + test totals count. `heading_depth` accepted a bare `#596` as a depth-1 + heading, silently truncating any scan over prose that cites an issue at line + start; the corpus does not do that today, which is why no test noticed. The + remainder were documentation: RFC 0006 section 14.13 said `Status` carries + the child link where the parser reads the link from `Child RFC`, the plan's + type sketch still showed three reject variants and `Row` fields the shipped + code does not have, and three plan passages still asserted the note column + discriminates the reject classes, which `D10` had already recorded as + falsified. Each fix was proven by a probe rather than by inspection; see + `Surprises & discoveries`. +- [x] (2026-09-24) `EP-M2` second CodeRabbit pass, thirteen finding records + collapsing to nine themes, all actioned. Three mattered beyond tidying. + `section7::apply_optioned` assigned `Namespace::Filter` to all three optioned + helpers while RFC 0006 section 3.2 lists `glob` under Functions, and the + namespace comparison added in the previous pass reads that value — so `COV-1` + would have failed RFC 0018, whose group owns `glob`, for being *correct*. + That is a defect this task introduced, not inherited. `CONF-1`'s mechanical + half was never implemented: the discharge *table* was parsed and compared, + but the section 5 subsections it points at were read by nothing, so the empty + stub, the generic discharge, and the Ansible-deference appeal were all + unchecked — the plan listed them as obligations and the code did not carry + them. And the link-target number was never compared against the reserving + row, so row `0013` could link to `0014-….md` and every check would resolve + the link, find the file, and pass while the registry filed those helpers + under the wrong RFC. The remainder were narrower: duplicate clause ids were + absorbed by a `BTreeSet` in two places, `child_number` accepted non-numeric + link text, the totals and purity aggregate were gated on all eight groups + being written so nothing could contradict them until the split was over, + ADR-040 claimed the test "transcribes none of them" when it carries four + anchor lists, RFC 0006 tied delivery to child-RFC closure where `D8` ties it + to roadmap tasks, and a stray `**` in this plan was followed by a newline and + so rendered literally. Every fix was proven by a probe. See + `Surprises & discoveries`. +- [x] (2026-09-24) `EP-M2` third gate run at `4c631e18`, red on `make lint`, and + both findings were defects the second pass introduced rather than inherited. + `clippy::too_many_lines` rejected `map::parse` at 73 lines against this + repository's 70-line ceiling, and `clippy::iter_skip_next` rejected + `Section::subsections`'s `.skip(index + 1).next()`. Fixed at `c74a0993` by + splitting `parse` into `parse_row` plus one function per rule that carries + its own reasoning, and by indexing with `slice::get`. The ten coverage tests + pass unchanged. **The gate caught what the review would not have**: + CodeRabbit reads the diff's semantics, and neither finding is a semantic + defect — the second pass had already been reviewed twice and passed. This is + the reason the standing instruction is to make every gate green *before* + requesting a review rather than to use the review as a gate. +- [x] Fourth gate run (2026-09-24), at `292bb9b3`. `make check-fmt` passed; + `make lint` failed in `lint-whitaker`, which is the third of `make lint`'s + five stages, so `lint-python` and `github-actions-lint` never ran. Two + findings, both `module_max_lines`: `Module checks spans 461 lines` and + `Module rfc_stdlib_coverage spans 448 lines`, against AGENTS.md's 400-line + cap. `lint-clippy` — the first stage — passes the same files, which is the + third time a targeted Rust gate has been green on a tree that `make lint` + rejects. Fixed at `7605c884` by splitting `tests/rfc_stdlib_coverage` into + three modules, none over 300 lines: `document` (the document layer, shared by + every parser), `partition` (`COV-1`, `COV-2`), and `progress` (`COV-3` to + `COV-6`, `CONF-1`). The ten tests pass unchanged and `COV-4` still reports + `0 of 8 capability groups written; 8 remaining`. +- [x] Fifth gate run (2026-09-24), at `0ca2cb18`. `make check-fmt` failed on two + `cargo fmt` diffs left by hand-writing the new modules — a trailing blank + line in `document.rs` and a `pub use` rustfmt breaks across three lines in + `mod.rs`. Fixed at `09018769`. The commit that introduced them was verified + with clippy, nextest, and Whitaker, but `make check-fmt` was skipped: the + first stage of the documented gateway set was the one omitted, and it was + omitted because the change was "just a module move". Every gateway now passes + on this tree, and `make lint` passes **in full for the first time on this + branch** — all five stages, including `lint-python` and + `github-actions-lint`, which had never run because the two prior invocations + both stopped at `lint-whitaker`. +- [x] Sixth gate run (2026-09-24), at `8852163e`, and the first full six-target + run on this branch. All six pass: `make check-fmt`, `make lint` (all five + stages), `make typecheck`, `make test` (2813 passed, 3 skipped; doctests 81 + + 2 + 32), `make markdownlint` (134 files), `make nixie`. The run also + confirmed the module split was behaviour-neutral: the + `rfc_stdlib_coverage_tests` set is byte-identical at ten tests, and a + normalized line-survival sweep of the pre-split `mod.rs` and `checks.rs` + found six lines without an exact post-split counterpart, all module-path and + import declarations the split necessarily rewrote. +- [x] (2026-09-24) `EP-M2` CodeRabbit pass at `8852163e`: twelve findings, one + major and eleven minor/trivial, all triaged. Seven were applied as stated + (`map.rs`'s dead `title` field; `section7.rs`'s silent `or_insert_with`; + `assertions.rs`'s half-checked `hash` invariant; `map.rs`'s unvalidated + `Owns` arity; the crate doc's "nothing here transcribes an inventory"; the + plan's seven-to-ten test count and its two wrong `0014`/`0015` slugs; + `ADR-040`'s doubly-listed `duplicate_key`). Two were applied against a *false + premise* in the finding: `document.rs`'s start scan was made fence-aware + because the doc comment promised a property `position()` could not deliver, + not because a live bug existed — no document in the corpus has a heading + inside a fence, and every looked-up heading is unique — and `section7.rs`'s + optioned comment claimed `glob` "also reaches `accepted` as an accept row", + which is false: all three optioned helpers appear only in reject rows. One + was applied differently than asked: `section7.rs`'s hardcoded + `Namespace::Filter` was replaced by a namespace parsed from the section 7 + tables, because the `Optioned` doc already argues a hardcoded namespace makes + a *correct* child fail. One was **partly refused**: see + `Surprises & discoveries` for the deference finding, whose requested + behaviour would have deleted the plan's own recorded seeded fault. + `clauses.rs` gained four unit tests, taking the binary from ten tests to + fourteen. Committed at `65665fcb`. +- [x] Seventh gate run (2026-09-24), at `65665fcb`. Five of the six targets + pass: `make check-fmt`, `make lint` (all nine tool invocations across its + five stages, including both Whitaker passes), `make typecheck`, + `make markdownlint` (134 files, 0 errors), `make nixie`. `make test` did + **not** pass locally, on two tests that this branch's diff does not touch: + `locale_stub_ui_tests::harness_compiles_under_a_split_build_dir` and + `packaging_smoke_tests::packaged_manifest_retains_build_script_sources`, both + nextest `TIMEOUT` at the repo's 300s budget. Both spawn live nested + `cargo build`s into private target directories, and six other agents' cargo + processes were running against the shared cache at the time. The resolver + commits for this historical trap are **not** ancestors of this branch, so the + live-build form is still what runs here. Re-running the two files alone made + it worse (four timeouts, not two), which rules out this branch's own test + load as the cause. Environmental, not a regression: the two files are + unmodified by this diff, and the full run reached 2815 passed against 2 timed + out. **Superseded by the eighth entry**: "environmental" was the right + verdict and the wrong reason — both tests are over budget on this base by + construction, not by load. +- [x] Eighth gate run (2026-09-24), at `f5300602`. Same shape as the seventh. + Five of six targets pass: `make check-fmt`; `make lint` (all five stages plus + both Whitaker invocations, 13s on a warm cache); `make typecheck`; + `make markdownlint` (135 files, 0 errors; spelling 34 passed, 92.37% + coverage); `make nixie`. `make test` fails again on exactly the two + live-build tests, both `TIMEOUT` at 300.015s — 2817 run, 2815 passed, 2 timed + out, 3 skipped, and **no** assertion failure of any kind. Because `make test` + is fail-fast, the `doctest` sub-target never ran at all: that is this run's + one evidence gap, and it is a gap the seventh run shared. The branch's own + `coverage_map_status_is_reported` passed in 0.223s, so the diff's assertions + are exercised and green. **Gap closed the same day**: `make doctest` was run + separately and passed, exit 0, `81 passed / 2 passed / 32 passed` across its + three targets with no failures. So `make test`'s two recognized sub-targets + are now both accounted for — `test-nextest` red only on the two + upstream-fixed live-build tests, and `doctest` green in full. +- [x] (2026-09-24) **`make test` cannot pass on this base, and that is not a + load story.** `e2fc2083` and `33a293a7` are on `origin/main` but are *not* + ancestors of this branch, so main has already fixed this failure class — and + not by widening a budget. It added a `[test-groups.nested-cargo-builds]` + group with `max-threads = 1` and **removed** + `harness_compiles_under_a_split_build_dir` from the override filter + altogether, trading the always-cold nested workspace build for recorded + parser coverage. The seventh entry's "environmental, load-dependent" reading + was too generous to this host. Measured for the record: the harness test + passes in 524.170s under a lifted budget, and + `packaged_manifest_retains_build_script_sources` in 270.658s, of which + `cargo publish --dry-run` alone is 269.86s — against a 300s cap. The seventh + entry read "re-running the two files alone produced four timeouts, not two" + as evidence of load; it is better read as evidence that neither can pass here + at all, so narrowing the selection only removes the queueing that was hiding + it. **The rebase is the fix; no local workaround is warranted.** +- [x] (2026-09-24) **No CI has ever run on this branch, and CodeRabbit has + reviewed nothing.** PR #697 reports `mergeable: CONFLICTING` and + `mergeStateStatus: DIRTY` against `origin/main` (55 behind, 23 ahead; the + conflicted paths are `.config/nextest.toml` and `docs/contents.md`). GitHub + cannot build the `refs/pull/NNN/merge` ref for a conflicted pull request, so + *every* `pull_request`-triggered workflow is suppressed — `ci.yml` and + `netsukefile-test.yml` among them. The last real CI run on this branch is + `3f02ee37`, its opening push on 2026-09-09. CodeRabbit is the sharper case: + its status context on the head reads `SUCCESS` while its only comment on the + pull request says "Draft PR not reviewed". So `EP-M3`'s "every gate green" + criterion could not have been met through CI, and the two controls this + milestone was counting on were not watching. +- [x] (2026-09-24) This branch's `.config/nextest.toml` addition from + `3a207c13` is stale against main's convention. It uses + `filter = 'test(=coverage_map_status_is_reported)'`; main's file now + documents at length that the `test(=NAME)` form compares the whole name and + so silently matches *none* of a parameterized `#[rstest]`'s instances, and + mandates the anchored `test(/^NAME($|::)/)` instead. That filter is correct + today only because the test is unparameterized, which is precisely the latent + defect main's comment was written to prevent. The rebase should adopt the + anchored form. +- [x] (2026-09-25) **`EP-M3` go/no-go: GO.** RFC 0013 was put to an independent + reviewer against the plan's own criterion, applied verbatim. Of the eleven + section 5 subsections, ten were judged SUBSTANTIVE and one VACUOUS. The + substantive ten are not re-wordings: five of them (5.3, 5.6, 5.7, 5.8, 5.9) + force decisions a reader of RFC 0006 section 6 could not have predicted — the + `indent` range asymmetry and its rationale, stream-wide budgets with the + stream total in the diagnostic, callables and `now()` rejected as + `unsupported_kind`, the `interchange` module segment rather than `json`/ + `yaml`, the trailing-newline asymmetry pinned by a test at each end, and an + explicit refusal to invent a looser test relation for `sort_keys`. The three + "no bite" clauses (5.4, 5.5, 5.11) satisfy the criterion's own exception by + naming the specific clause feature that cannot fire, which is checkable. The + reviewer's strongest passage was 5.8's serializer bound: **"The serializers + enforce nothing, and that is a decision rather than an omission. Neither + allocates proportionally to anything but its input, so a bound would reject + documents a parser had already accepted. The row reads 'none' instead of + being left blank so that a reviewer sees the absence was chosen."** The seven + remaining child RFCs are therefore written. +- [x] (2026-09-25) **Two defects the go/no-go found, both fixed in `e45c161e`.** + The one vacuous subsection was 5.10, which restated clause 6.10 and + re-reported an open question section 5.2 already carries; it now records the + real naming question this group creates. More seriously, 5.6 claimed + `from_yaml_all` "rejects every `from_yaml` condition", which contradicts RFC + 0006 section 8.1: an empty stream yields an empty sequence and multi-document + input is the helper's purpose, so `document_count` does not apply. That is a + rejection the implementer would have written and a test would then have had + to defeat. The same over-broad phrasing sat in the 5.8 bounds table. Both + corrected. **This is the go/no-go earning its keep**: a structural test + cannot see this class of error, because `CONF-1` only requires each + subsection to name a helper and avoid deference phrasing, and the wrong + sentence did both. +- [x] (2026-09-25) Rebased onto `origin/main` (`397fb589`, 55 commits). Two + conflicts, both predicted by `git merge-tree`: `.config/nextest.toml` and + `docs/contents.md`. Both were resolved by taking main's version and + re-applying this branch's addition, rather than transcribing the conflict + hunks — main had reorganized the nextest overrides into `nested-cargo-builds` + groups, and this branch's hunk was the extraction of the Windows override + main removed. That addition now uses main's mandated anchored filter form, + `test(/^coverage_map_status_is_reported($|::)/)`, closing the + latent-unhooking defect recorded above. Verified after: `main` is an ancestor, + `origin/main..HEAD` is 25, `HEAD..origin/main` is 0, `e2fc2083` and + `33a293a7` are now ancestors, and the coverage contract passes 14/14 with + COV-4 still reporting "1 of 8 capability groups written; 7 remaining". +- [x] (2026-09-25) Second rebase, onto `96aefc9c`, and the branch's own + contract test caught a defect the incoming commits brought with them. One + conflict, in RFC 0006 section 16 item 7, resolved by keeping both sides after + verifying each claim against ADR-008's "2026-09-11: Stdlib clock seam" + section and the coverage map's RFC 0020 row for group `8.10` — the clock seam + is answered by roadmap item 7.1.1, and RFC 0020 neither needs it nor depends + on it, so the two statements are compatible rather than contradictory. Then + `inter_document_links_resolve` failed on `96aefc9c`'s own + `docs/rfcs/0007-netsukefile-testing-framework.md:62`, which links to + `netsuke-test-framework-technical-design.md` without the `../` prefix its + line 22 correctly carries. From `docs/rfcs/` that resolves inside that + directory, where the file does not exist. Fixed at `64970ba8`. **The link fix + then failed `make check-fmt`**, `+5 -4` on that one file: the added `../` + pushed a wrapped line past the margin `mdtablefix` enforces, and the file was + re-wrapped in place. Re-verified after: check-fmt exit 0 (153 files + unchanged), `make markdownlint` exit 0 (0 errors), and the coverage contract + 14/14. Note the sequence, because it is the whole reason the instruction is + to gate locally rather than to trust the review: a link-only edit that a + reviewer would read as trivially correct was red on two separate + deterministic gates, and no review would have caught either. +- [x] (2026-09-25) **The rebase restored the CI channel, and CI is green on the + same commit where local `make test` is red.** This is the milestone's most + important result, and it settles the acceptance question by a better route + than the one the plan assumed. PR #697 flipped from `CONFLICTING` to + `MERGEABLE` at the first check after the force-push, which lets GitHub build + `refs/pull/697/merge` again; runs `36069242789` (`CI`), `36069242572` + (`Netsukefile Build Test`) and `36069243039` (`Release Dry Run`) all fired on + `288526a2` and all concluded `success`. `Netsukefile Build Test` proves + green, because it ran the tests rather than only compiling them: + `Summary [ 415.659s] 3408 tests run: 3408 passed (3 slow), 5 skipped`, + alongside `Doc-tests netsuke` 87 passed and `Doc-tests test_support` 39 + passed, both `0 failed`. **Zero failures and zero timeouts on CI.** +- [x] (2026-09-25) Ninth gate run, at `288526a2`, and the first on a tree where + the branch's own diff is what runs. Five of six targets pass: + `make check-fmt` (1s), `make lint` (64s, **all five stages and both Whitaker + invocations**), `make typecheck` (7s), `make markdownlint` (24s, 153 files, 0 + errors), `make nixie` (5s, 154 files). `make test` fails, and the failing + test is `command_env_ui_tests::cli_configuration_fixture_compiles` — + `TIMEOUT [300.008s]` against nextest's 300s cap — with 3392 passed and 15 + cancelled behind it. This is the **third** member of the nested-Cargo timeout + class, already recorded as measured `PASS 329.774s` under a lifted budget, so + it is over cap on this host by construction, not by regression. Because + `make test` is fail-fast, `doctest` never ran; run separately it passes (71s, + 2 targets, 0 failed). Gate logs are the seven unsuffixed + `/tmp/<action>-netsuke-<branch>.out` files. +- [x] (2026-09-25) **The two gates disagree, and the disagreement is the + evidence.** `make test` is red locally on one nested-Cargo compile test; the + identical commit is green on CI with 3408/3408 and no timeout. Nothing in the + branch differs between the two — the same commit, the same test set — so the + difference is the host. The local run competed with other agents' clippy, + nextest, and publish jobs (load average 74.39 on 24 cores) and the test + drives a cold nested `cargo check` into a private `CARGO_TARGET_DIR` that + cannot reuse the gate build; CI ran at **415.659s for all 3408 tests**, which + is less than the local budget for this one test. Two consequences. The first + is acceptance: `EP-M3`'s "every gate green" is satisfied on the authority + that the criterion was always pointing at, and the local red is recorded + rather than hidden. The second is a correction to the plan's own reasoning — + see `Surprises & discoveries` for why "every gate green locally" was the + wrong formulation to have written. +- [x] (2026-09-25) **Tenth gate run, red on three in-diff defects, all fixed.** + The run covered the uncommitted tree that carries the five CodeRabbit code + findings; `make typecheck`, `make nixie`, and `make test` passed, and + `cli_configuration_fixture_compiles` passed in 8.6s rather than timing out, + so the known 300s failure did not reproduce. Three gates failed, and every + failure was introduced by this branch rather than inherited: + + - `make check-fmt`, on `clauses.rs`'s `DEFERENCE_PHRASES`. That const was + formatted at `HEAD`, and rewriting it to a two-line form rustfmt rejects is + this branch's doing. `make fmt` resolved it. + - `make lint`, on Whitaker's `module-max-lines`: `clauses.rs` grew from 354 + lines at `HEAD` to **432**, past the 400 cap. Every sibling in that + directory is under 400, the next largest being `section7.rs` at 332. Fixed + by splitting the module at a seam rather than raising the cap. + - `make markdownlint`, on three MD013 lines this branch added: one in this + plan and two in RFC 0013. `make fmt` does not repair MD013, so all three + were rewrapped by hand. + + The `clauses.rs` split extracted the appeal-to-Ansible predicate and its five + unit tests into a new `tests/rfc_stdlib_coverage/deference.rs`. The seam is + the *kind* of judgement: `clauses` grades document structure (an empty body, + a body naming no owned helper), while the third vacuity shape `CONF-1` names + is a judgement about prose. `clauses.rs` is now 258 lines and `deference.rs` + 202, and the directory's largest module is unchanged at 332. The module doc + of each records the move, following the precedent `mod.rs` set when + `markdown.rs` and `document.rs` were split out of it for the same reason. + Re-verified after the split: `rfc_stdlib_coverage_tests` 15/15 green, + including all five moved `deference` tests and `inter_document_links_resolve`. +- [x] (2026-09-25) **`ADR-021` was a number collision, and it is now `ADR-040` + .** + Found while investigating a grep that showed two files sharing the number. + `main` published `adr-021-trust-aware-fetch-policy-merge.md` on 2026-09-09 + (`3348cc0a`, "Prevent project configuration from widening trusted fetch + policy (#644) (#663)"); this branch minted + `adr-021-focused-child-rfcs-for-survey-rfcs.md` with its `Date` field reading + 2026-09-11 and committed it at `9730a880`. Both files are tracked at `HEAD`, + so main's 021 was already an ancestor when the branch's was committed. This + is the exact failure the plan warned about: the `EP-M0` note at line 1361 + records "The highest existing ADR is 020; three earlier numbers collided, so + re-check before committing", dated 2026-09-08 — one day before main took 021. + The re-check did not happen, for any of the four subsequent commits that + touched the ADR. + + Renumbered to **040**, the lowest free number above the ceiling. A fresh + remote sweep puts that ceiling at 039 (`jm5/kani-change-scoped-gate`); + `origin/main`'s own highest is 038. Eight edits, all mechanical and all + verified: the file moved by `git mv` (preserving history), its H1 renumbered + (it carried no internal self-references), the RFC 0006 section 14.13 link + repointed, the two execplan path references repointed, all 17 execplan + `ADR-021` mentions renumbered, and — the defect that made this visible — the + missing `docs/contents.md` entry added. That file was the only one of 39 ADR + files with no index entry, so files and entries now both read 39 and a Python + set-comparison confirms they agree exactly, with no dangling target. + + Two guards held. `main`'s `adr-021-trust-aware-fetch-policy-merge.md` is + untouched, as are all three of its inbound citations (`docs/contents.md` and + two in `adr-026-manifest-environment-access-policy.md`, one of which is a + link-reference definition). And the 17 execplan mentions were checked before + the replace: grepping them for `fetch`, `trust`, `quarantin`, `network`, or + `policy` returns nothing, so no mention means main's ADR and a scoped global + replace was safe. The renumber is escalated rather than decided — see the + item below — but the remedy is preparable without touching `main`, so it is + prepared. +- [x] (2026-09-27) **Third rebase, onto `aa764819`, and all four named gates + are green on the result.** + PR #697 had gone `CONFLICTING` against `main`, which suppresses CI entirely, + so the rebase was the unblocking step rather than tidying. `main` had + advanced 18 commits past the branch's base `96aefc9c`; the replay was 33 + commits, linear, no merges, and stopped exactly once — at commit 6 + (`.config/nextest.toml`), with `docs/contents.md` auto-merging. The conflict + surface predicted before the replay was four files and the actual surface was + three plus a no-op, so the prediction held. + + The one real conflict is a **TOML trap worth recording**. Both sides had + appended a `[[profile.default.overrides]]` entry at the same anchor — `main` + adding the Kani mutation compile gate, this branch adding the `COV-4` + coverage-map override. Git matched the two independently written *header* + lines as common context, so it conflicted only on the bodies, leaving one + header serving main's body and this branch's header stranded below the + `=======`. Keeping both bodies under that single header would have produced + one TOML table with two `filter` keys: a duplicate-key parse error, not a + merge that merely looks wrong. The resolution is two separate array entries, + main's first. Provenance was checked rather than assumed: `typos.toml` was + expected to conflict and did not, because those 14 lines are tool-generated by + `make spelling` and both branches regenerated the same text, so the replay + correctly became a no-op there. + + The resolution was verified before `git add`, not after. The composed file + parses under `tomllib` with five override entries; removing this branch's + block reproduces `main`'s version exactly; and the branch never touched the + `[profile.ci]` prose that `main` rewrote from 300s to 600s, so taking + `main`'s copy verbatim is correct rather than a silent overwrite. The + semantic audit then ran clean: all 146 target-only paths byte-identical at + the new head, all 23 branch-only paths byte-identical to the old head, zero + unexplained deletions against `main`, and no duplicated block outside + branch-authored files. `range-diff` shows 33 commits against 33 with + identical messages in identical order; the single `!` is commit 6 and is + exactly the intended union. + + Gates, run sequentially on the rebased head `ada8b994`, each to completion: + `make check-fmt` exit 0; `make typecheck` exit 0; `make lint` exit 0 across + all five stages including both Whitaker invocations; `make test` exit 0, + **3491 run, 3491 passed, 0 failed, 6 skipped** in 127.6s, plus doctests. The + doctest gap that both earlier full runs shared is closed in this run rather + than separately. `COV-4` reports "coverage map: 1 of 8 capability groups + written; 7 remaining", which matches the plan's status: `EP-M0` to `EP-M3` + land, `EP-M4` to `EP-M10` do not. + + Recovery material was created before the replay and is retained: + `refs/recovery/20260927T231849/{old-head,old-base,target,remote-before}`, + plus native `--binary` patches of both series under + `/tmp/rebase-697-recovery/`. Recorded identities: `old_base=96aefc9c`, + `old_head=0190f3aa`, `target=aa764819`, `new_head=ada8b994`. +- [x] (2026-09-28) **CodeRabbit reviewed the pull request itself, at + `658b8157`: three findings, all actioned at `797178c9`.** Review `5332303125` + returned `CHANGES_REQUESTED` against the branch head — not a stale SHA, as + every earlier CodeRabbit signal on this branch had been — with three inline + comments (`4117169304`, `4117169313`, `4117169325`), one per touched file. + Nothing was ambiguous and nothing had to be refused; each was confirmed + against the artefact it describes before being accepted, and each fix is + committed with its confirmation rather than the reasoning left in a review + reply. + + This is the first CodeRabbit response the plan records that came from the + *pull request* rather than from a locally run `coderabbit review --agent`, + and it arrived unprompted. It is therefore not numbered against the earlier + passes: those were milestone-scoped (`EP-M1` second pass, `EP-M2` third pass) + and this one is not tied to a milestone, so an ordinal would assert a lineage + the plan does not record. + + `ADR-040`'s worked specimen still described `from_yaml_all` as rejecting every + `from_yaml` condition. That is the wording RFC 0013 §5.6 had already been + corrected away from, so the specimen and the artefact it previews had + disagreed since that correction. The ADR calls the specimen non-normative and + tells the reader that RFC 0013's copy is the normative one — but a reader + editing RFC 0013 may well copy from the ADR, and the sentence is the same + *claim*, not a summary of it, so a divergence is a defect in either + direction. It now matches RFC 0013 §5.6 byte for byte, verified by extracting + the bullet from both files. + + The coverage map could reserve one RFC number twice, and no check would + notice. `Map::ownership` permits a repeated helper when both rows carry the + same number, which is exactly what a duplicate reservation produces, so the + one guard with a view of the whole map was blind to the shape. Downstream, + `registries::parse_all` keys on a number over `children.contains`, and both + the status and registry checks match rows with `find`/`any` — so one child + RFC would have silently represented two capability groups. `parse` now tracks + reserved numbers in a `BTreeSet` and rejects a repeat. The guard was proven + live both ways rather than only made to pass: with a row mutated `0015` → + `0014` it fails the run with the duplicate message, and with `map.rs` + reverted to `658b8157` that same mutation passes **all fifteen** checks, the + one-owner test among them. Both probes ran against the live document, and RFC + 0006 was restored to `HEAD` afterwards and confirmed by md5 + (`90c5787133429ec0a8ef66c1d936b265`). + + `Delimiter::opening` accepted any indentation before a fence. The module + claims `CommonMark` in the same doc comment, and `CommonMark` allows at most + three leading spaces; four or more is an indented code block. The cost of the + unbounded form is asymmetric. A line indented deeply enough to be ordinary + code content to every other Markdown tool would open a block here and swallow + every heading and table beneath it — the silent truncation `Fences` exists to + prevent — whereas the bound's own cost is a *loud* one: a fence nested in a + list item sits at the item's content column, four spaces for an ordered + marker, and is legal `CommonMark` this line-level predicate cannot see. The + corpus carries every fence at column zero, so the loud error is the one the + documents avoid today and the silent one is the one they face tomorrow. The + bound is now three spaces and the doc comment states both sides of the trade. + + No gate covered this. `mdtablefix` is lenient through indent 6, and this + repository's `markdownlint` config sets no MD046 key, so MD046 runs in + "consistent" mode and a document whose only code block is indented passes. + Three new unit tests in `mod fence_tests` pin the boundary that no document + exercises: the 0–3 accept / 4–8 reject sweep, that a rejected opener leaves + the structure below it readable, and that an indented *closer* still closes. + + `cargo nextest run --test rfc_stdlib_coverage_tests` → 18 passed, 0 skipped. +- [x] (2026-09-28) **Gate run at `3707fc1b`: two red, both in the prose this + pass had just added, both fixed in the commit carrying this entry.** Seven + targets were commissioned from one runner rather than the four named in + `AGENTS.md`, because the turn-end hook runs `check-fmt`, `lint`, `typecheck`, + `markdownlint` and `nixie` while `AGENTS.md` adds `doc-coverage` and `test`; + the union is the honest set. Five were green on the first pass — `make test` + at 3494 passed / 0 failed / 6 skipped in 127s, `make lint` (all five stages, + both Whitaker invocations), `typecheck`, `nixie`, and `doc-coverage` at + 98.83% — and the two failures were `make check-fmt` and `make markdownlint`, + each on a defect this pass had introduced. + + `mdtablefix` reported five hunks in the plan. Every line in every hunk was ≤ + 80 columns on both sides, so this was **not** an MD013 violation: the tool's + greedy fill packs the same words into a different arrangement than the hand + wrap, and one word's displacement cascades through the paragraph. The correct + fix is therefore to accept the tool's arrangement, not to shorten anything. + Applied with the scoped invocation this plan already records, and confirmed + idempotent — `--check` with the gate's own selector then reports + `169 files left unchanged`, exit 0. The result matches the runner's read-only + `--diff` capture byte for byte, which is the determinism check: the fix + reproduced the tool's own output rather than merely silencing it. + + `markdownlint` failed *before markdownlint ran*. Its `spelling` prerequisite + aborts on `recognise` at `1473:57`, so `mdlint` never executed and the diff + has **no** markdownlint verdict from that run — an unknown, not a green. The + re-run closes that gap explicitly: `spelling` passes and `mdlint` proceeds to + 169 files with 0 errors. The `-ise` to `-ize` fix was applied to both copies + of the sentence, the plan and the Rust doc comment it quotes, because the + branch's own Observation records that rule — *when a correction is applied to + an artefact, grep for its other copies in the same commit* — and applying it + to one copy only would reproduce the ADR/RFC divergence at the start of this + same pass. The `.rs` copy reds no gate: the spelling gate is Markdown-scoped + and the Rust corpus is genuinely split on this word (12 files `-ise`, 9 + `-ize`, one file carrying both), so the local convention does not decide it. + What decided it was that this branch authored exactly one Rust occurrence and + it is the quoted same sentence. + + Two things were deliberately not changed. The twelve pre-existing `.rs` files + spelling `recognise` are outside this delta and outside every gate's scope; + rewriting them would put twelve unrelated files in a prose-fix commit. And + `docs/execplans/…:1072` is 81 columns but is exempt: MD013's `\S*$` rule + means a line whose final token begins within 80 columns is not flagged, the + config sets no `strict`/`stern`, and `git log -S` shows the line was + introduced by `6ed33733` — an earlier, already-published commit, so it is not + a regression this pass introduced. + + `cargo nextest run --test rfc_stdlib_coverage_tests` → 18 passed, 0 skipped. +- [x] (2026-09-28) **Gate run at `68c266e8`: all seven targets green.** The + same seven-target union ran again on the fix commit and passed: `check-fmt` + (169 files, 0 reformatted, 2s), `lint` (all five stages, both Whitaker + invocations, actionlint resolved from `$HOME/go/bin`, 14s), `typecheck` (1s), + `test` (3494 run / 3494 passed / 6 skipped in 147.569s, + `rfc_stdlib_coverage_tests` 18/18, `execplan_status_contract_tests` 9/9, + doctest targets two not three), `markdownlint` (spelling now passes and + `mdlint` proceeds to 169 files with 0 errors, 19s), `nixie` (10s), + `doc-coverage` (98.83%, 32s). The runner also independently reproduced the + two facts this pass had argued from: the `spelling` target's scope is + Markdown-only (controlled A/B on a scratch repo: an `-ise` typo in a `.rs` + file reds no gate) and `make test` *is* sensitive to plan edits, via + `tests/execplan_status_contract_tests.rs:62,133`. + + **This entry is the reason the revision it cites is not the revision that + stands green now.** Writing a Progress entry moves `HEAD`, so the run is + evidence for exactly `68c266e8` and for nothing later; the commit carrying + this paragraph is a *new* revision that the run did not cover. The next gate + run therefore has to cover the commit that contains it, not be assumed green + by inheritance — the same rule the plan's own Observation records about a + suffix in a gate log not being a revision. +- [x] (2026-09-28) **Two `chatgpt-codex-connector` passes on `658b8157` + triaged; four inline comments, plus a non-review from `sourcery-ai[bot]`.** + The plan had recorded neither bot. Sourcery's is a size-limit refusal, not a + review — the diff exceeded the 150,000-character review limit — so it carries + no findings and no verdict to clear. + + Of codex's four, one (`map.rs:123`) is a duplicate of CodeRabbit's F2 already + fixed at `797178c9` and is recorded as such rather than re-dispositioned. The + other three are substantive and are actioned in the commit carrying this + entry; each needed a premise check rather than a straight application, + because in all three the *finding* is sound and the *stated mechanism or + proposed remedy* is not: + + - `map.rs:85` — `claim()` accepts a repeated helper when the repeat carries + the same row number, so a cell reading `` `8.1`; `8.1` `` claims every + helper twice and the `BTreeMap` collapses it. Codex's remedy is "reject any + existing owner". That remedy cannot be applied as written: row `0018`'s + `` `8.7` except `abs` `` resolves through `Survey::sections`, which is built + from `sections_of` *including* the optioned helpers (`section7.rs:260`), so + it already yields `glob`, and the same row's `Optioned` cell names `glob` + again. A blanket reject would red the live document. + + This entry first recorded the remedy as a guard "within one row's own claim + list". That is **also wrong, and wrong in the same way**: `basename` and + `dirname` reach row `0017`'s `claims()` twice — once through `owns`, since + `sections_of` files them in `8.6`, and once through that row's `Optioned` + cell — so the legitimate overlap is *within* a row, not across rows. The + only scope that separates the defect from the design is **per cell**: an + `Owns` cell must name a helper once, an `Optioned` cell must name a helper + once, and the same name may appear in both because the two cells record + different facts (which subsection specifies it; that it gains an option + rather than being introduced). The guard is `ensure_distinct`, applied to + each cell in `parse_row`. Verified against the live document rather than + assumed, and proven live by mutation. + - `RFC 0013:264` — the serializers are specified as enforcing no bound. The + premise that a serializer's input need not be a bounded parser's output is + **correct, and stronger than codex put it**. RFC 0006 §6.8 says + "materialized output rejects unreasonable expansion before allocating", and + RFC 0013 had read "materialized" only in the `from_yaml_all` sense (fully + materialized before return), missing the output sense. The amplification is + not hypothetical and needs no hostile input: MiniJinja values are + `Arc`-shared, so a value built by repeated doubling is a DAG whose *logical* + size is exponential in its construction depth. The mechanism is visible in + the pinned crate: `impl Serialize for Value` recurses through + `ObjectRepr::Seq` with `seq.serialize_element(&item)` for each child, and for + a doubling both children are the same `Arc`, so every level visits the whole + subtree twice. Confirmed by measurement — one recursive macro with `n` + doublings emits `{{ v | tojson }}` as exactly `2^(n+2) - 3` bytes: `n=10` → + 4,093, `n=16` → 262,141, `n=20` → **4,194,301**. A four-line template + therefore drives a materialized output past §6.8's 8 MiB ceiling with no + large input anywhere. **Provenance:** that measurement ran through the + MiniJinja Python binding, which wraps this engine; the figure for the pinned + Rust crate is the one the implementation slice must re-measure, and the + source-level mechanism above is what makes the result binding-independent. + The bound is a ceiling on the *serialized byte count*, reachable only + through `to_yaml` and `to_nice_json`, and clause 6.8 requires the rejection + *before* allocating. That rules out counting bytes as they are written, so + both documents specify a length pass first: walk the value with checked + arithmetic, abandoning the walk when the running total passes the ceiling, + and write only a value that fits. + - `RFC 0013:189` — this is the one finding whose **stated mechanism is + false**, and it is recorded that way rather than applied. Codex argues + `{1: "a"} | to_nice_json | from_json` yields `{"1": "a"}`, "which is not + equal to the input under §6.7's canonical equality". But §6.7 defines + equality as *byte-identical canonical key*, and the canonical form of an + integer key **is** the string form: `serde_json`'s `MapKeySerializer` + renders every integer and boolean arm through + `begin_string`/`write_iNN`/`end_string`, and `serde_json_canonicalizer`'s + `JsonProperty::new` then re-parses those bytes and requires `.as_str()`. So + both mappings canonicalize to `{"1":"a"}` and compare **equal** — the round + trip holds, and codex's "impossible for part of the documented input + domain" does not follow. Its proposed remedy (constrain `to_nice_json` to + string-keyed mappings) is also the wrong split: RFC 0006 §8.1 requires the + stringification outright. + + The real defect is **adjacent, sharper, and unnamed by codex**: one mapping + can hold both the integer `1` and the string `"1"` as keys, and `from_yaml` + accepts exactly that (probe: `yaml.safe_load('1: a\n"1": b\n')` yields + **two** entries, keys `int 1` and `str '1'`). Both render to the same JSON + key, so `{1: "a", "1": "b"} | to_nice_json` emits a document with a + **duplicate key** — which `from_json`, the stated inverse, **rejects** with + `duplicate_key` per §8.1. That is the round-trip guarantee failing on an + accepted input, in the direction codex missed. The collision is rejected at + the serializer, so `to_nice_json` is total on the inputs it accepts, and the + §5.7 guarantee is qualified to say so. + + Both §5 findings land in the section the seven later child RFCs are copied + from, so both were also checked against `ADR-040`'s specimen copy of §5 + (lines 372-463) in the same commit, per the rule this plan already records — + *when a correction is applied to an artefact, grep for its other copies in + the same commit*. The specimen differs from RFC 0013's §5 in several places + already (it is a preview, and shorter), and the two sentences the corrections + touch were found in both copies and corrected in both. + + Two consequences of the §5.8 correction were found only by following it + through, and both are corrections of this entry's own earlier text: + + - The new `output_too_large` condition is a condition of the group, so §5.9's + enumeration needed its row and the "thirteen conditions" claim needed to + become fourteen. The count is asserted in **four** places (both documents' + §5.9 prose and both clause-discharge tables); all four were changed and the + tables were counted mechanically afterwards — 14 rows, 14 codes, in each. + - `RFC 0013`'s first wording for the mechanism was "counts the bytes it writes + and fails past 8 MiB". Reading clause 6.8 rather than paraphrasing it showed + that is not sufficient: the clause opens "Every parser, combinatorial + helper, regular-expression operation, and materialized output rejects + unreasonable expansion **before** allocating." Counting while writing is a + post-hoc check, so it discharges the clause's letter only by accident. Both + documents now specify a length pass first, walking the value with checked + arithmetic and abandoning the walk when the running total passes the + ceiling, so a doubled value stops after 8 MiB of *logical* nodes rather than + expanding. + - The `map.rs` guard's scope was corrected **twice**. This entry first + recorded codex's remedy ("reject any existing owner") as unworkable, then + proposed the narrower "within one row's own claim list". That second + proposal is also wrong, and for the same reason: `basename` and `dirname` + appear twice *within* row `0017`'s claims (once through `owns`, once through + `Optioned`), so the legitimate overlap is inside a row, not across rows. The + only scope separating the defect from the design is **per cell**, which is + what `ensure_distinct` implements. +- [x] (2026-09-28) **BLOCKER: the shared Cargo package cache is deadlocked + machine-wide, so no Rust gate can run.** The commit carrying the three codex + dispositions is `630b8116`, verified by inspection and by everything that + does not need Cargo, but **its local gate run is outstanding**. The commit + recording this entry is plan-only; the code delta under test is exactly + `630b8116`, and a later reader must not fold the two together. A reader must + not treat the absence of a gate result as a pass. + + **Scope correction, added later the same day: this entry overclaims.** It + says no Rust gate can run, and that is true only of the *local* invocation. + CI runs the same gates on GitHub's runners, which do not touch this machine's + package cache, and it has since run them green on a later revision — see the + entry below. The blocker is real but narrow: it costs the local second + reading, not the gates themselves. + + The cycle, read from `/proc` rather than inferred: PID `1832225` + (`cargo test --all-targets --all-features` in the `podbot` worktree, another + agent's job) holds the **write** lock on `~/.cargo/.package-cache-mutate` and + is blocked in `do_wait` on its child test binary `1855438`, which is blocked + in `futex_wait_queue`; that binary spawned a **nested** `cargo` (`1855450`) + which is blocked in `locks_lock_inode_wait` **on the lock its own grandparent + holds**. Two samples of `1855438`'s `/proc/<pid>/stat` twenty seconds apart + showed utime+stime unchanged at 48 ticks, so the loop is not progressing. + Forty-seven processes were queued on that inode with `locks_lock_inode_wait`, + the oldest for 1h46m, and `pgrep -c rustc` was **0** system-wide — every Rust + job on the machine, this branch's included, was stalled behind it. + + This was diagnosed and left alone deliberately. The house rule says not to + kill other agents' processes, and the holder belongs to another session; the + system prompt's remedy for a full or wedged cache is to stop and tell the + user, not to break someone else's lock. The `podbot` job is also + self-inflicted in the sense that matters here — it is a nested-Cargo deadlock + of the kind this repository has hit before (see the nested-Cargo timeout + records), not a cache that merely needs to drain. + + The diagnosis carried no action of its own, so this entry closes as + *diagnosed and recorded* rather than as a task left undone. Its one live debt + — the local gate run it blocks — was paid later the same day: the cache + drained, and all seven targets ran green. The scope correction above is what + makes the checkbox safe to tick; the historical record is unchanged. + + What *was* verified without Cargo: `mdtablefix --check` over the full + selector reports `169 files left unchanged` (exit 0), so every Markdown edit + is canonical and idempotent; `rustfmt --edition 2024 --check` on `map.rs` + exits 0, so the new guard parses and is format-clean; and the two §5.9 tables + were counted mechanically (14 rows, 14 codes each) rather than trusted. A + standalone `rustc` parse of `map.rs` was tried and is **inconclusive** — it + fails only on the unresolvable `anyhow` and `super::` imports, so it cannot + distinguish a syntax error from a missing dependency, and must not be cited + as evidence. + +- [x] (2026-09-28) **Two pieces of work completed while Cargo was blocked, and + the PR description brought back in line with the branch.** Neither needs the + package cache. + + The two commits `630b8116` and `e209ee99` were **pushed**. The remote head + had stood at `68c266e8` while the local head was `e209ee99`, so the earlier + "remote-head discrepancy" is resolved. It was a fast-forward: the remote head + was verified to be an ancestor of local `HEAD` before pushing, so no force + was needed and none was used. + + The PR description had drifted in three separate ways, all corrected in one + edit rather than left for a reviewer to notice. Its `Verification` section + described **four** gates on the long-superseded head `ada8b994` and never + mentioned the seven-target union, the later runs, or the blocker at all. Two + figures were stale: the coverage suite was "15 tests executed", which is + **18** (7 top-level in `tests/rfc_stdlib_coverage_tests.rs` plus 11 module + tests, 6 of them in `markdown.rs`), and RFC 0013 was "429 lines", which is + **452**. Both were re-derived from the working tree rather than adjusted by + arithmetic — `grep -c '#\[test\]'` over the module tree, and `wc -l` on the + committed blob via `git show HEAD:`. + + The rewritten section leads with a per-revision table that states plainly that + `68c266e8` is the last revision to complete the set, and that `630b8116` and + `e209ee99` are **outstanding — blocked, not passed**. It records the deadlock + with the `/proc` evidence, what was verified without Cargo, and — since CI + does not use this machine's package cache — that **CI is the one channel the + deadlock does not block**, which is what can actually settle the current head. + + One figure was **kept** after checking it: "Five seeded-fault controls run + before any second child exists" is not the same list as the + `Verification plan`'s "four seeded faults". The five are the early controls + (a deleted coverage-map table, a corrupted section 7 row, a dangling link, a + deleted roadmap bullet, a bullet moved to the wrong step); the four are the + later RFC 0014/0015 ownership faults. They agree, so neither was changed — an + apparent inconsistency that is only apparent. + + The `ensure_distinct` doc comment's load-bearing claim was **re-verified at + the source level**, because it is the kind of prose a reviewer cannot cheaply + check and a wrong mechanism there would justify the wrong fix. The claim is + that `basename`, `dirname` and `glob` enter the per-section member lists even + though section 7 never accepts them, which is what forces the guard's scope + to be per cell rather than per row. Confirmed: `section7::apply_optioned` + inserts each optioned helper into `read.sections_of` (`section7.rs:260`), and + `survey.rs:130` builds `Survey::sections` by inverting exactly that map. So a + clause reading `` `8.6` except `expandvars` `` resolves to include `basename` + and `dirname`, and what a per-row or per-union guard would need to tolerate + is therefore present in the live document. The comment stands as written. + +- [x] (2026-09-28) **The blocker does not block the gates after all — CI runs + them, and they are green on `c7ff9e4a`.** This entry corrects the scope, and + the correction matters more than the original entry did: it was written on + the assumption that a deadlocked local package cache left the Rust gates with + no runner. That assumption was **never checked, and it is false**. + + The `CI` workflow's `build-test` job ran, on `c7ff9e4a`, to completion and to + success — and its steps are the seven-target union almost exactly: + `make check-fmt` (169 files left unchanged by `mdtablefix --check`), + `make lint` (all five stages, with `actionlint` from the job's own download), + `make typecheck`, `make doc-coverage`, `make spelling`, `make nixie`, + `markdownlint-cli2` over `**/*.md`, and `make test-workflow-contracts`. The + `Windows / lint-windows` job independently carries `Format`, `Lint (Clippy)` + and `Lint (Whitaker)`. + + The test evidence is the strongest of these, and it is not a subset. + `Test and Measure Coverage` invokes the shared `generate-coverage` action with + `all-features: true`, `all-targets: true`, `doctests: true`, + `use-cargo-nextest: true`, and `RUSTFLAGS: -D warnings` — the same flags the + local gate uses — and its log reads ** + `3494 tests run: 3494 passed (1 slow), 6 skipped`**, with every + `rfc_stdlib_coverage_tests` instance `PASS`, including + `totals_and_purity_aggregate_agree`. Doctests ran too, in **two** targets + (`Doc-tests netsuke`, `Doc-tests test_support`), which re-confirms the + two-not-three figure from the other side. `COV-4` printed + `coverage map: 1 of 8 capability groups written; 7 remaining` in that passing + run, exactly as this plan requires. + + So the outstanding work is **narrower than the blocker entry above claimed**. + What remains genuinely unrun is the *local* invocation of the set — which is + not the same claim as "the gates have not run". Two things must be stated + separately and must not be merged: CI's `3494 run / 3494 passed` on + `c7ff9e4a` **is** gate evidence for that revision, and the local `scrutineer` + run is still owed as the second, independent reading. + + Read the run rather than the summary row: `36373283587`, head `c7ff9e4a`, + `workflowName: CI`, `event: pull_request`, all five jobs `success`. The + lessons are the general ones — a summary row is not the log, and an + assumption about what a blocker blocks is itself a claim that needs a check. + + **And the run was repeated on the revision that carries this entry.** Head + `1524a7e6`, run `36375195362`, `CI`, all jobs `success`; `Format` reports + `169 files left unchanged`, and `Test and Measure Coverage` reports ** + `3494 tests run: 3494 passed (1 slow), 6 skipped`** in 240.328s with + `coverage map: 1 of 8 capability groups written; 7 remaining`. So the plan + edits that record the correction are themselves covered by the same evidence, + which is the property the `68c266e8` entry above argues for and this entry + would otherwise have violated. + + **Next action for whoever resumes:** run the seven-target gate set locally + once the cache clears (`pgrep -c rustc` returning non-zero, or the inode free + in `/proc/locks`), then commission the `scrutineer` run — not because the + gates are unrun, but because a local second reading on the runner's own + revision is what this plan owes. The liveness proof for `ensure_distinct` is + also still owed: the guard must be shown to *fire*, by mutating a + coverage-map row to `` `8.1`; `8.1` `` and observing the run fail with the + duplicate message. + + **Re-measured, and still held, at 05:30Z — the blocker is unchanged, not + stale.** A `cargo metadata` probe returned exit 0, which looked like a clear, + but the subsequent build never produced a `target/debug/deps` and no `rustc` + ran. Reading `/proc/locks` properly settled it: the inode carries **exactly + one granted entry** (the un-arrowed line, `1832225`, `FLOCK ADVISORY WRITE`) + and **45 blocked requests** (every `->` line). The holder is still the same + `cargo test --all-targets --all-features` in the `podbot` worktree, at 2h14m, + still in `do_wait` on `1855438`; nested `1855450` is still in + `locks_lock_inode_wait`. `pgrep -c rustc` is still **0** and ~20 `cargo` + processes are queued machine-wide. + + The probe was a false clear because `cargo metadata` is one of the few + commands that does not need the write lock. **Do not read a single exit-0 + probe as the deadlock lifting** — the durable signal is either a non-zero + `pgrep -c rustc` or the granted-lock line disappearing from `/proc/locks`. + The build attempt made while diagnosing was blocked on the lock for its whole + life, not failing, and was stopped rather than left queued: a queued waiter + is itself one more entry in the 45, which makes everyone else's diagnosis + noisier. + + Also corrected: the memory note's holder recipe (`awk '{print $5}'` over + every matching `/proc/locks` line) conflates the holder with its waiters, + because the field layout differs between granted and blocked lines. The + granted line is the one **without** a leading `->`. + + **Unblocked at 06:10Z — the durable signal this entry named has fired.** The + condition it specified is met in the form it specified: + `grep "$INODE" /proc/locks | grep -v -- '->'` returns nothing (no granted + line), the blocked count is **0**, and `pgrep -c rustc` is 0 because nothing + is running rather than because everything is stalled. The holder `1832225` + and its descendants have left the process table. So the local seven-target + run is no longer blocked by the cache, and the second reading this entry + called outstanding is owed as work rather than waiting on an event. Note + which signal carried the verdict: the *inode* going free, exactly as the + entry above insists — a `pgrep`-based reading alone would not have + distinguished a cleared deadlock from a machine whose holders had merely not + yet restarted. + + **Discharged the same day — the local seven-target run is green on + `a5455a1a`, the revision that carries this entry.** `scrutineer` ran the + whole set sequentially, to completion, and every gate exited 0: + + | Gate | Exit | Duration | Diagnostic line, read from the log | + | -------------- | ---- | -------- | -------------------------------------------------------------- | + | `check-fmt` | 0 | 2s | `169 files left unchanged.` | + | `lint` | 0 | 85s | all five stages ran; `All checks passed!`, `rated at 10.00/10` | + | `typecheck` | 0 | 12s | `All checks passed!` (ty), then `Finished 'dev' profile` | + | `test` | 0 | 286s | `3494 tests run: 3494 passed (1 slow), 6 skipped` | + | `markdownlint` | 0 | 30s | spelling ran and passed, then `Summary: 0 error(s)` | + | `nixie` | 0 | 1s | `All diagrams validated successfully!` | + | `doc-coverage` | 0 | 39s | `aggregate 4801/4858 98.83%`, meets the 80.00% threshold | + + Three details are worth more than the verdict, because each is a way a green + could have been hollow. First, `markdownlint` shows *both* a `spelling` pass + (`current: typos.toml`) **and** an mdlint verdict (`Linting: 169 file(s)`, + `Summary: 0 error(s)`) — this gate's spelling prerequisite can abort before + mdlint ever runs, and a spelling-only log would have been an unknown wearing + a pass's clothes. Second, `test` printed `COV-4`'s + `coverage map: 1 of 8 capability groups written; 7 remaining` inside a + *passing* run, which is the whole point of the counter: a half-finished split + passes every other coverage check, so a stall is visible only if this line + still reaches the terminal. Third, every one of the seven logs carries a + verdict line the runner appended itself, of the form + `GATE=… EXIT=<rc> … HEAD=<sha>`; all seven name the same revision, + `a5455a1aae235fb7fd52e5db0ca9fda135329151`. That is what binds the evidence + to a revision rather than to whatever HEAD happened to be when the report was + written, and it is why the exit status was captured through `PIPESTATUS[0]` + rather than read off `tee`. + + **The first green was not the last word, and CI caught what the local run + could not.** CI on `86160a53` reded `build-test` — a *required* check — on + `MD013/line-length`, at this very entry's line 1165, 81 columns against a + budget of 80. The cause is worth recording because nothing local would have + found it: the "verdict line" format was written as one unbreakable code span + of 80 columns, and `mdtablefix` cannot break *inside* a code span, so its + greedy fill emitted the line and MD013 rejected it. The local seven-target + run was green on `a5455a1a` because the offending prose did not exist yet — + it arrived in `86160a53`, the commit that recorded that run. **A gate result + covers the revision it ran on and no other**, which is the recurrence this + plan keeps meeting; the fix is to shorten the frozen token so the wrapper has + somewhere to break. + + The one slow test was + `packaging_smoke_tests::packaged_manifest_retains_build_script_sources`, + which logged `SLOW [>120.000s]` and then **passed** inside its budget, under + a machine load average of 58 from other agents' runs. That is the known + cold-build-cost member and a load observation rather than a correctness + signal; it is recorded because a 120s flag in a log invites the wrong + question otherwise. + + So the two readings this plan wanted now both exist and are kept separate: + CI's `3494 run / 3494 passed` on `c7ff9e4a` and on `1524a7e6`, and this local + run on `a5455a1a`. + + **And the liveness proof is discharged for the `Owns` call site.** The guard + was mutated, not merely re-run: RFC 0006's row `0013` was edited so its + `Owns` cell read `` `8.1`; `8.1` ``, and only the `rfc_stdlib_coverage_tests` + binary was run. It failed — + + ```text + Error: the `Owns` cell at docs/rfcs/0006-ansible-inspired-template-standard-library.md:2068 names from_json twice + Summary [0.035s] 18 tests run: 12 passed, 6 failed, 0 skipped + ``` + + — and on restoring the pristine file the same binary read + `18 tests run: 18 passed`. The message names the mutation's own line, which + is what shows the edit reached the parser rather than being silently dropped + by table parsing. **Six** tests red rather than one, because the map parse is + a shared load step, so a single malformed row fails every test that reads the + map; the guard was the sole error source for all six (the captured log's + distinct `Error:` lines number exactly one). The string is also unique to + this call site: of the tree's `names … twice` producers, only `map.rs:197` + uses the `the {cell} cell at …` shape, so nothing else could have worn its + message. + + That proof covers `map.rs:164` only, so **the sibling call at `map.rs:162`, + which guards the `Optioned` cell, was proved separately** — the same argument + applies to it verbatim, and a per-call-site liveness claim needs a + per-call-site witness. Row `0017`'s `Optioned` cell was mutated to + `` basename ``; `` basename `` and the same binary run: + + ```text + Error: the `Optioned` cell at docs/rfcs/0006-ansible-inspired-template-standard-library.md:2072 names basename twice + Summary [0.038s] 18 tests run: 12 passed, 6 failed, 0 skipped + ``` + + Both call sites now have a witness, and the cell label is what separates + them: each run produced exactly one distinct `Error:` line, and in the + `Optioned` run a count of the `` `Owns` `` label over the log returned **0**, + so the failure is attributable to the call site under test rather than + borrowed from its sibling. The guard at `map.rs:162` also runs *before* the + `Owns` guard at `:164`, so an `Optioned` failure can never mask an `Owns` + one. Note what the pair does and does not establish: neither call site is + unexercised now, but a surviving falsification attempt is *not* proof — each + is falsified-or-not by one mutation, which is exactly the standard this plan + asked for. + + **A second change rides on this head: `uv.lock` is untracked.** The file was + never deliberately tracked. `a94a3006` staged its own verification entry with + `git add -A`, and the sweep carried `uv.lock` along with it, so the branch + *added* the file against `origin/main` — + `git diff --name-status origin/main…HEAD -- uv.lock` read `A`. Commit + `f42202a4` adds the name to `.gitignore` **and** removes it from the index, + because the ignore rule alone would have been inert: git answers from the + index for a tracked path and never consults the ignore rules, so the file + would have stayed tracked while looking ignored. The working-tree file is + deliberately left in place, so `uv` still finds the lockfile it wrote. + + Ignoring it is right for this repository even though `uv`'s own guidance says + a lockfile "should be checked into version control". That guidance presumes + dependencies to lock, and this repository has none by design and in as many + words: `pyproject.toml` declares no `[project]` table and no + `[build-system]`, "so no Python distribution can be built from it and `uv` + never treats the repository as a Python project". The Makefile agrees in + every invocation — `uv tool run …` throughout, and `uv run --no-project …` + for the contract tests and the Python lint gates; the `--locked`/`--frozen` + flags elsewhere in the tree belong to `cargo build`. What would be locked is + a three-line stub naming no package. + + The change is inert by measurement rather than by assumption. The only test + that reads `.gitignore` copies it into a scratch repository and asserts that + each name in `MACHINE_LOCAL_DIRECTORIES` *is* ignored — one-directional, over + fifteen *directory* names that `uv.lock` is not among, so a new file pattern + cannot reach it. The two tests that shell out to `git ls-files` are both + path-scoped (`MUTATIONS_DIR`; `Cargo.toml`/`**/Cargo.toml`). `typos.toml` + already lists `uv.lock` in `extend-exclude`, so the spelling gate expects the + file to exist and is indifferent to who owns it. No test requires it tracked, + and none reads it. + + This is a change outside `EP-M4`–`EP-M11`'s scope and is recorded here for + that reason: the branch carries it, so the plan and the pull request + description must both name it rather than let a reviewer discover it. + +- **The seven bot findings open at this head are already answered; the review + is stale, not unresolved.** Four from `chatgpt-codex-connector` and three + from CodeRabbit's `CHANGES_REQUESTED` pass `5332303125` all anchor at + `658b8157`, and every one names a defect this branch fixed afterwards. + CodeRabbit says so itself: each of its three carries + `✅ Addressed in commits 797178c to 68c266e`. The four codex comments do not + self-annotate, so each was checked against the revision rather than the + anchor: + + | Finding | Subject | State at `6ff02d87` | + | ----------------- | ------------------------------------- | ------------------------------------------------- | + | codex `…609` | an `Owns` cell repeats a section | `ensure_distinct`, `map.rs:162`, `:164` | + | codex `…616` | duplicate child RFC numbers | `ensure!`, `map.rs:132` | + | codex `…627` | non-string JSON mapping keys | section 5.7 rejects non-distinct rendered keys | + | codex `…620` | serializers unbounded | section 5.8's output bound, both serializers | + | CodeRabbit `…304` | ADR-040 specimen contradicts RFC 0013 | specimen states `document_count` is not inherited | + | CodeRabbit `…313` | duplicate child RFC reservations | the `map.rs:132` guard | + | CodeRabbit `…325` | a fence opened by four spaces | `opening` rejects more than three | + + The second codex row is rehearsed by a seeded fault; the rest are guards read + at the revision named in the first column. + + **A caution for anyone following that annotation after the rebase.** The span + `797178c to 68c266e` was correct when CodeRabbit wrote it and is now + misleading in a way that does not announce itself: + `git merge-base --is-ancestor 68c266e8 HEAD` fails, because the 2026-09-28 + rebase rewrote the branch and neither object is an ancestor of any current + branch or of `origin`. They survive only because the rebase preserved the + pre-rebase head, so `68c266e8` is reachable from + `refs/recovery/6-1-1-old-head-20260928-162555` and nowhere else. The content + they introduced did *not* go missing — the guards and prose each row of the + table names were re-read at `b89652b9` and all four are present there — but + the *citation* now points into a history that only the recovery ref holds. + This is the same class as the plan's other rebase-provenance lessons: a + rebase invalidates every SHA cited across it, including one cited by a + reviewer rather than by this plan, and a reader who checks the annotation + finds a missing object rather than a missing fix. + + Two codex findings are worth recording as *not applied as written*, because + their mechanisms were wrong even though the defects were real, and `630b8116` + says so. `…627` claimed the round trip was impossible, but section 6.7's + canonical form of an integer key *is* its string form, so `{1: "a"}` and + `{"1": "a"}` canonicalize identically and the round trip holds; the real + defect was adjacent and unnamed — `from_yaml` accepts a mapping holding both + keys, which would render a duplicate key its own inverse rejects. And + `…609`'s suggested remedy ("reject any existing owner") cannot be applied at + all: row `0018`'s `Owns` clause resolves to `glob` and its `Optioned` cell + names `glob` again, so a blanket reject would red the live document. + + **The lesson is about review state, not about the bots.** A comment anchored + at the current head is not necessarily live. GitHub re-anchors a comment on + push whenever its line survives, so `commit_id` tracks the head while the + comment body still describes an older revision — `line` and `position` are + both non-null for all seven here, which is exactly the shape that reads as + "live". Treating the anchor as the verdict would have sent a reader to re-fix + three defects that were already fixed. Read the body, and check the revision + it describes. + +- **A credential report filed earlier was wrong, and is corrected here.** A + push failed with `could not read Username for 'https://github.com/…'`, and + the first diagnosis blamed the Lody credential helper's `missing_path` + early-return with `credential.useHttpPath` unset. That diagnosis was reported + to Lody as an environment misconfiguration. The retry then succeeded, which + prompted a second look, and the second look contradicts the first. The + harness **injects `credential.useHttpPath=true`** into every command through + the `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/ `GIT_CONFIG_VALUE_n` channel: + `git config --show-origin --get-all credential.useHttpPath` reports + `command line: true`. The helper succeeds end-to-end when a `path=` is + supplied — run directly with its own debug log enabled at + `http://127.0.0.1:41269`, it records `request` to `broker_config` (from an + `env_var`) to `fetch` to a `200` `fetch_response` to `success`, and emits + `username=x-access-token` on stdout with a 40-character password. + + The `missing_path` observation therefore came from a hand-built `printf` + probe, which omitted `path=` and so never reproduced what a real push sends. + It was a true statement about a command that is not the one that failed. An + earlier `git ls-remote` "proof" was void for the same reason in reverse: + `leynos/netsuke` is **public**, so that read needs no credentials at all and + could not have exercised the helper. `git push --dry-run` exits 0, and that + is the ordering which actually exercises write auth. The single intermittent + failure is left **unexplained rather than misattributed**; the filed report + stands as a report of a symptom, and this entry is the correction to its + diagnosis. **A probe that does not reproduce the failing command's inputs + cannot establish the failing command's cause.** + +- [x] (2026-09-28) **The local `coderabbit review --agent` pass was read and + dispositioned; its seven in-scope findings are fixed in the commit carrying + this entry.** The run is `REV=ac314a94`, `CR_STATUS=0`, 617 s, 23 findings. + The count is 23, not 7, and the difference is the whole reason this entry + exists: the local review scans the **working tree**, while the GitHub review + scans the **PR diff**, and the two sets are not the same artefact. Sixteen of + the 23 land in files this branch never touches — `.github/scripts/` + nextest-oracle modules, `tests/kani_scope_wrapper_e2e_tests.rs`, other + branches' execplans — so they belong to the revisions that wrote them and are + recorded as out of scope rather than silently dropped. They are not "skipped + as unimportant"; they are not this PR's to fix. + + All seven in-scope findings were verified against the document text before + any edit, and all seven were correct. Four were stale facts the plan's own + later entries already contradicted: the ToR claim, the still-open blocker + checkbox, the superseded standalone-PR instruction, and the missing + `.gitignore` line. Three were substantive: the `fourteen` test count (the + breakdown was wrong too — 7 + 6 + 5, not 7 + 3 + 4), the `D10`/`:2148` + contradiction, and the first-person passages. The last of these is a real + style-guide rule (`docs/documentation-style-guide.md:39`), though a corpus + probe shows it is widely violated elsewhere in `docs/execplans/`; that makes + the rule no less binding on this file, and the remaining quote at line 835 is + verbatim bot text and correctly left alone. + + One correction went further than the finding asked, because the finding's own + premise was checked rather than trusted. The ToR entry's existing `924cb215` + pin is a commit that **predates the document's existence**; the ToR arrived on + `main` in `96b89ca9` (PR #786). Citing the document at a revision where it + does not exist would have been a fresh instance of exactly the staleness + class this entry is clearing, so the new artefact carries its own pin and + says why it differs from its neighbour. + + **The commit that fixes all seven then reded `check-fmt` in CI, and the + reason is a green from the wrong command.** After editing, the plan's + Markdown was verified with a bare `mdtablefix --check <path>`, which reported + "1 file left unchanged". The gate does not use that invocation. It runs + `mdtablefix --check --git --include-untracked --wrap --renumber + --breaks --ellipsis --fences`, + and `--wrap` is the flag that rewraps prose to the line width; the bare form + is a strictly weaker check that does not rewrap at all. The gate's own + command reported `+45 -45` on the same file, which `build-test` — a + *required* check — caught and reded. The edit was then canonicalized with + `--in-place` under the gate's flags, after which the gate's own command + reports `169 files left unchanged`, exit 0. The rewrap is provably + content-free: the word sequence before and after is identical (34481 words, + compared programmatically rather than by eye). + + This is the plan's recurring theme in a new costume. The earlier instance was + "a gate result covers the revision it ran on and no other"; this one is **a + verifier that is not the gate's verifier is not the gate**, and a pass from + it is not a pass. Both share the same root: an artefact was treated as + evidence for a claim it does not cover. The remedy is the same too — run the + command the gate runs, not a command that resembles it. + + The fix above then produced a third instance, caught by a local gate run on + `feed5192` rather than by CI. The sentence *about* canonicalization was + itself written `canonicalised`, which `make spelling` rejects under the + en-GB-oxendict `-ize` rule, so `spelling` aborted and `markdownlint` reported + **no verdict at all** — an unknown that the outside observer would have read + as green had the abort not been noticed. Two properties of that failure are + worth keeping. First, the word was outside backticks: the plan already + records that a code span is exempt from the rule, and the only reason this + one was not exempt is that it is plain prose. Second, the same file contains + `recognise` twice, and both are correct as they stand — each is a backticked + quotation of a *different* revision's gate output, so the exemption still + applies and "fixing" them would have falsified a historical record. A + repo-wide `-ise` sweep is therefore not a safe repair; the distinction is + prose versus quoted evidence, not one spelling against another. + +- [ ] `EP-M4` RFC 0014, mapping and sequence transforms (step 6.3). +- [ ] `EP-M5` RFC 0015, ordered collection algebra and truth predicates (6.4). +- [ ] `EP-M6` RFC 0016, pattern and version predicates (step 6.5). +- [ ] `EP-M7` RFC 0017, lexical path composition (step 6.6). +- [ ] `EP-M8` RFC 0018, host-state predicates and environment expansion (6.7). +- [ ] `EP-M9` RFC 0019, encoding, identity, and formatting (step 6.8). +- [ ] `EP-M10` RFC 0020, date and time conversion (step 6.9). +- [ ] `EP-M11` Reconcile, retarget roadmap citations, run all gates, mark + roadmap 6.1.1 done. + +- [x] (2026-09-28) **A gate run reded the current head, and the defect was the + branch's own.** `make markdownlint` on `feed5192` exited 2, but not because + `markdownlint` found anything: its `spelling` prerequisite aborts on + `canonicalised` at this file's line 1391, so `markdownlint-cli2` never ran + and emitted no verdict at all. The word was added by `feed5192` itself, in + the very sentence describing canonicalization — a `-ise` form where the + project's en-GB-oxendict rule mandates `-ize`. CI then failed the required + `build-test` check on the same word at the same line and column, so the local + diagnosis and CI agree independently rather than one being inferred from the + other. + + The repair is a one-word substitution of equal length, so no wrap boundary + moves; that was verified with the gate's own command rather than assumed, + since `mdtablefix` reflows prose greedily and a longer or shorter token would + have reded `check-fmt` in exchange. Two neighbouring spellings were + deliberately left alone: this file contains `recognise` twice, and both are + backticked quotations of a *different* revision's gate output, so the + code-span exemption applies and rewriting them would falsify a historical + record. The general lesson is recorded under `Surprises & discoveries` — for + this class the deciding question is prose versus quoted evidence, not one + spelling against another, so a repo-wide sweep is not a safe repair. + + The episode is the third instance of one theme, and the plan now says so in + one place: the earlier two were "a gate result covers the revision it ran on + and no other", and "a verifier that is not the gate's verifier is not the + gate". This one is "a failed prerequisite hides a stage that never ran", and + its tell is the same in all three — an artefact was read as evidence for a + claim it does not cover. A green `check-fmt` and a green `spelling` are now + recorded on `c765659d`, with `mdlint` reaching `169 files, 0 error(s)` for + the first time on this branch, which also demonstrates the prerequisite is + live rather than vacuous. + + Re-verifying the seven stale bot findings at this head turned up one stale + citation of the plan's own. The `chatgpt-codex-connector` finding that the + serializers were unbounded is discharged by RFC 0013 **section 5.8** + ("Resource bounds"), where `to_yaml` and `to_nice_json` each carry "output 8 + MiB; checked before the result is returned" — but the disposition table above + credited "section 5.3's length pass", and 5.3 is "Determinism", which + contains no such pass. The two sections are distinct entries in this plan's + own list of substantive clauses, so the number was simply wrong. Corrected in + place. All seven findings were then confirmed discharged at HEAD, not merely + self-annotated: the `map.rs` duplicate-number guard, `ensure_distinct`'s two + call sites, and the two RFC 0013 obligations were each read at the revision. + +- [x] (2026-09-28) **The branch was rebased onto `main`, 53 commits replayed + one-to-one with no conflict.** `OLD_HEAD` was `c765659d`, `OLD_BASE` + `aa764819` (the branch's exclusive replay boundary), target `7677c388`, and + the result is `93a5d8b6`. `git range-diff` reports `=` for all 53 pairs and + emits no non-summary output, so every replayed patch is byte-identical and + nothing was resolved by hand. The old range and the new range each contain 53 + commits and no merges, `OLD_BASE` is an ancestor of `OLD_HEAD`, and + `OLD_HEAD` is no longer an ancestor of the result — the expected shape after + a replay. + + The reason the replay was mechanical is worth recording, because it is a + property of the *pair* of revisions rather than luck: main's three new commits + (`7677c388`, `027a848e`, `dc4c116a`) touch ten paths and this branch touches + twenty-seven, and **the two sets are disjoint**. There was therefore no file + for a merge driver to arbitrate, which is also why the replay needed no + driver-consent decision — no path was ever selected for a three-way merge. + That was checked rather than assumed: `comm -12` on the two path manifests is + empty, and all ten main-only paths are byte-identical at `93a5d8b6` to their + `7677c388` blobs, which is the audit the merge policy requires of a + target-only path. + + **Main's incoming changes are not pertinent to this branch, and that is + evidenced rather than asserted.** The three commits change workflows, the + `Makefile`'s Pylint invocation, `docs/developers-guide.md`, and add + `tests/workflow_contracts/pylint_tier_test.py`. The branch is documentation + plus Rust source plus one `.gitignore` line, so the overlap in *subject + matter* is nil; more concretely, every `file:line` citation this plan makes + was re-resolved at the new head and no citation names a main-touched path + except the one corrected below. The `Makefile`'s Pylint refactor is the + nearest miss: it changes how the *Python* baseline lint runs, and this branch + touches no Python. So the decision is to adopt none of it and to record why, + rather than to manufacture a merge. + + Two things did need attention. First, `uv.lock`. The incoming commit + `d03a3e31` (a replay of `a94a3006`) had swept `uv.lock` into the repository + with `git add -A`, while the later commit `224dc762` (a replay of `f42202a4`) + untracked it and added `.gitignore:21`. The rebase therefore stopped with + "The following untracked working tree files would be overwritten by merge: + uv.lock". The on-disk copy is a 52-byte file that no configuration in this + repository reads, and it is byte-identical (`md5 8bbc054c…`) to the incoming + blob, so removing the blocker discards nothing. It was preserved to + `/tmp/rebase-6-1-1-untracked/uv.lock`, restored after the rebase, and + verified at the new head to be both present and ignored + (`git check-ignore -v` names `.gitignore:21`) and untracked (`git ls-tree` + finds no entry). The commit that untracks it survives as a non-empty commit, + so its `.gitignore` hunk still applies. + + Second, the rebase exposed a stale citation of this plan's own, in the same + class as the `5.3`→`5.8` correction above. The `doc-coverage` observation + cited `Makefile:206-210` as its evidence. That span is the `RUSTDOC_FLAGS`/ + `VERUS_FLAGS`/`WHITAKER` block, not the `doc-coverage` target, at every + revision examined — including `b23d0535`, the commit that introduced the + citation, where the target sits at line 325. The `206` was correct at an + earlier main state (`5fda1e6e`), so the citation drifted as main moved and + nobody re-resolved it. Main's Pylint hunk made it drift once more, by + deleting one line at 161–168 and shifting everything after 168 up by one. It + now reads `Makefile:325-329`, which is the `doc-coverage` target and its + recipe, and the claim it supports (`scripts/doc-coverage.py` measures library + and binary targets; integration tests are not measured) is carried by the + script's own module docstring at `scripts/doc-coverage.py:4`. + + This is the fourth instance of the plan's recurring theme, and the sharpest: + all three earlier ones were about *running* the wrong check. This one is + about a citation that was never re-resolved after the file under it moved — a + `file:line` is a pointer, and a pointer into a moving file is a claim with an + expiry date. The rebase is precisely the event that invalidates it, which is + why the sweep belongs in the rebase audit rather than in a later review. The + remedy applied here is a bounds-and-identity pass over every citation the + plan makes at the new head, not a spot fix. + + Recovery refs are retained until publication is confirmed: + `refs/recovery/6-1-1-old-head-20260928-162555` (→ `c765659d`), + `-old-base-20260928-162555` (→ `aa764819`), and `-target-20260928-162555` (→ + `7677c388`). The four gates the rebase hook names (`check-fmt`, `typecheck`, + `lint`, `test`) were run in sequence on `93a5d8b6` immediately after the + replay, and all four exited 0. Those results are bound to `93a5d8b6`; this + entry is a later revision and they do not cover it. The seven-target run that + acceptance requires is therefore commissioned against the commit that adds + this entry, because a rebase creates a new candidate and every gate result + bound to `c765659d` is historical. + +- [x] (2026-09-28) **All seven gates pass on `802be5ea`, and `markdownlint` is + a real pass rather than the UNKNOWN the earlier episode produced.** The seven + targets ran sequentially on the post-rebase head: `check-fmt` (4s, + `164 files already formatted`, mdtablefix `169 files left unchanged`), `lint` + (14s, clippy `-D warnings` clean, both pylint runs `10.00/10`, interrogate + `100.0%`, yamllint and actionlint clean), `typecheck` (1s, ty + `All checks passed!`), `test` (180s, nextest + `3494 tests run: 3494 passed (1 slow), 6 skipped`, doctests 87+2+39 passed + with 0 failed), `markdownlint` (14s), `nixie` (1s, + `All diagrams validated successfully!`), and `doc-coverage` (7s, + `aggregate 4801/4858 98.83%`, meets the 80% threshold). + + The `markdownlint` verdict was checked for the *state*, not merely the exit + code, because the earlier episode established that a red prerequisite makes + the target report nothing at all and an outside reader takes silence for + green. Both tells are present and in order — `Linting: 169 file(s)` at line 5 + and `Summary: 0 error(s)` at line 6 — so `markdownlint-cli2` genuinely ran. + This is the first seven-target run on this branch where every gate is green + *and* every verdict is a verdict. + + Two log artefacts were inspected rather than waved through. `lint`'s log + carries `Blocking waiting for file lock on package cache` three times, which + is the shared Cargo cache serializing access as intended, not a defect. And + the candidate for a false reading is the `test` log, which is 669 KB and + contains the strings `error` and `FAIL`: every `error` occurrence is a test + *name* under an `error::tests` module, and `FAIL` does not occur at all. Both + were established by reading the lines, not by the absence of a grep hit. + + Logs are `/tmp/g3-<gate>-6-1-1.out`. These results cover `802be5ea` only, and + the later revision is covered separately below. + +- [x] (2026-09-28) **CI's full gate set and all four required checks pass on + `09e609ab`, the current head; the four Markdown-sensitive targets were also + re-run locally on it.** This entry closes the gap the entry above left open + on purpose — a gate result covers the revision it ran on, so `802be5ea`'s + green did not speak for the commit that recorded it. + + CI is the stronger of the two readings because it is revision-bound and it + ran the whole union rather than a subset. `build-test` on `09e609ab` completed + `success` after running `Format`, `Lint Markdown`, `Lint`, `Typecheck`, + `Doc coverage`, `Spelling`, `Validate Mermaid diagrams`, + `Workflow contract tests`, and `Test and Measure Coverage` — every step + `success`. Its log carries the two verdicts that matter here: + `3494 tests run: 3494 passed (1 slow), 6 skipped`, and `Linting: 169 files` + followed by `Summary: 0 issues in 0 files`. + + The four required checks are `success` on the same revision, and each + *completed after* the commit was created at 14:51:11Z — which is what makes + them evidence about this revision rather than about an ancestor: + + | Check | Conclusion | Completed | Job | + | -------------------- | ---------- | --------- | -------------------- | + | `build-test` | success | 15:08:03Z | `…/job/108985193830` | + | `kani-smoke` | success | 15:06:36Z | `…/job/108985193426` | + | `netsukefile` | success | 14:54:57Z | `…/job/108985192215` | + | `release / metadata` | success | 14:53:38Z | `…/job/108985197560` | + + All four job links point at runs whose `head_sha` is `09e609ab…`, checked + through the runs API rather than read off the check-run row, since a summary + row also lists checks that never ran. + + The local re-run is the weaker reading and is recorded as such. The delta + `802be5ea` → `09e609ab` changes exactly one path — this ExecPlan + (`git diff --name-only --no-ext-diff 802be5ea 09e609ab` prints one line) — so + only the Markdown-sensitive targets can move, and those four were run again: + `check-fmt` (`164 files already formatted`; mdtablefix + `169 files left unchanged`), `markdownlint` (`Linting: 169 file(s)` then + `Summary: 0 error(s)` — both tells present, so `markdownlint-cli2` genuinely + ran), `nixie` (`All diagrams validated successfully!`), and `doc-coverage` + (`aggregate 4801/4858 98.83%`). Logs are `/tmp/g4-<gate>-6-1-1.out`, written + at 14:51:38Z–14:52:16Z. + + Two honest limits on the local half. First, those log files carry **no** + runner verdict line of the `GATE=… EXIT=<rc> … HEAD=<sha>` form that the + `a5455a1a` run appended; the verdicts above are read from each tool's own + terminal success line, which is one step weaker than an exit status captured + through `PIPESTATUS[0]`. Second, the other three targets were not re-run + locally, because they are not reachable by this delta — but that is an + argument from the diff, not a measurement, and CI supplies the measurement on + the same revision. + + That the evidence still describes the current head is itself checked, not + assumptions: `git reflog` shows no commit since `09e609ab` and + `git ls-remote origin` agrees with the local ref. + + The run has since closed out, and the whole of it is green rather than the + required four alone: `36439268002` is `completed/success` with every job + `success` — `build-test`, `kani-smoke`, `netsukefile`, `release / metadata`, + and also `Windows / lint-windows`, `Windows / build-test-windows`, and + `Windows / windows-msi-upgrade`. `lint-windows` is the one worth naming + because it was the last to finish and it is *not* quick: its + `Lint (Whitaker)` step ran 15:01:43Z–15:16:12Z against `Lint (Clippy)`'s + 15:00:58Z, so a reader watching the run mid-flight would have seen a 14-minute + `in_progress` on a step whose local counterpart takes seconds. That is + dylint building its lint library from source on a cold runner, not a stall — + the diagnostic the earlier deadlock entry taught, applied forward: an + unexplained wait gets measured against its own baseline before it is called a + wedge. + +- [x] (2026-09-28) **All seven targets pass on `84e4fa82`, the commit that + lands the entry above, so the full local second reading this plan owes is + discharged on the acceptance revision.** `check-fmt` + (`164 files already formatted`; mdtablefix `169 files left unchanged`), + `lint` (clippy `-D warnings` clean, both pylint runs `10.00/10`, `ambrleaks` + silent, interrogate `100.0%`, yamllint and actionlint clean), `typecheck` (ty + `All checks passed!`, then `cargo check --all-targets --all-features` + finished), `test` (nextest `3494 tests run: 3494 passed (1 slow), 6 skipped`, + then doctests `87 + 2 + 39` passed with 0 failed), `markdownlint` + (`Linting: 169 file(s)`, `Summary: 0 error(s)`), `nixie` + (`All diagrams validated successfully!`), and `doc-coverage` + (`aggregate 4801/4858 98.83%`). Logs are `/tmp/g5-<gate>-6-1-1.out`. + + Four of the seven were logged *before* the commit and three after (the commit + is 15:23:44Z; `markdownlint`, `check-fmt` and `nixie` closed at + 15:21:36Z–15:21:52Z, `doc-coverage` at 15:22:43Z, and `lint`, `test` and + `typecheck` at 15:24:27Z–15:29:01Z). That is not a gap in the evidence, + because the commit only had to capture a working tree the gates had already + read, and `git status --porcelain` was empty both before the commit and after + — the tree did not move between the pre-commit gates and the commit, and + `git rev-parse HEAD` has read `84e4fa82` at every probe since. Recording the + *order* rather than the aggregate is the point: an earlier entry in this plan + had to say that its gate log described the commit that recorded it and + therefore did not cover it, and the remedy that produced `84e4fa82` was to + gate the tree and then commit exactly what was gated. + + The `test` log was read rather than grepped, for the same reason as before: + it carries `coverage map: 1 of 8 capability groups written; 7 remaining` + inside a *passing* run. That line is the whole point of the counter — a + half-finished split passes every other coverage check, so a stall is visible + only if this line still reaches the terminal — and `7 remaining` is the + correct reading at `EP-M3`, with `EP-M4` not yet begun. `FAIL` does not occur + at all (count 0), and the doctest target count is 2, matching the two + `Doc-tests` headers this plan records as the real number rather than the + three a hasty grep suggests. + + **The MD038 fix was then carried forward, and the two affected gates re-run at + `6720c2b9` rather than argued about.** Writing the paragraph above reded + `markdownlint` on a code span with a trailing space — `` `Doc-tests ` `` — + which is MD038, and the repair was to shorten the span to `Doc-tests` rather + than to touch the rule. `check-fmt` and `markdownlint` were re-run on the + fixed tree (both green, the latter again in state 3 with + `Linting: 169 file(s)` before `Summary: 0 error(s)`), and the commit + `6720c2b9` lands exactly that tree. + + `nixie` and `doc-coverage` were re-run too, and the reason is worth stating + because the first instinct was to exempt them as Markdown-irrelevant. They + are not, for `nixie`: a probe of its own log shows it names **this** plan (2 + hits) among 47 `docs/execplans/*.md` files in a 170-file sweep, so a + one-character edit inside a plan is inside `nixie`'s scope even though the + plan carries no mermaid fence of its own. Both re-ran green + (`All diagrams validated successfully!`; `aggregate 4801/4858 98.83%`). + `doc-coverage` reads no Markdown at all (0 hits for `execplans`), so its + re-run was the cheap confirmation of that rather than a necessity. + + The loop this plan has hit before is recording a gate run in a tracked file, + which moves HEAD past the revision the run verified. It is not re-opened here: + `6720c2b9` is the last revision verified, and the delta since `84e4fa82` is + the MD038 repair plus this paragraph — prose, in this one file, whose Rust + content is byte-identical to what the seven-target run passed. Earlier + entries in this plan handled the same situation by *stating the last verified + revision and the delta*, and that is what is done here. CI covers the same + four required contexts on every push and is the authority for any tip beyond + `6720c2b9`. + +## Surprises & discoveries + +- Observation: **two independent safety nets can both report success while + neither is watching.** Evidence: `EP-M3`'s acceptance criterion is "every + gate green", and it was pursued through two channels that both silently + no-op'd. CodeRabbit's commit status on `f5300602` reads `SUCCESS`, while its + only comment on the pull request says "Draft PR not reviewed" — a status that + means "did not fail", not "did review". GitHub Actions reports nothing at + all: PR #697 is `CONFLICTING`/`DIRTY`, and because GitHub cannot construct the + `refs/pull/697/merge` ref for a conflicted pull request, every + `pull_request`-triggered workflow is suppressed outright rather than failing. + Neither condition produces an error, a red check, or a notification. Impact: + the branch's 23 commits have never been CI-validated, and the last CI run on + it is `3f02ee37`, its opening push on 2026-09-08. Lesson: a green status is + only evidence if it was *earned by a run* — check that the run exists before + reading its conclusion, and treat an absent run as a failure, not as silence. + The mitigation is structural, not vigilance: keep the pull request mergeable, + because a conflict disables the entire CI channel. + + *Amendment, added the same day: the mitigation worked, and this Observation + is kept in its original tense because it records a state that no longer + holds.* PR #697 became `MERGEABLE` when the conflict was resolved, and the CI + channel reopened with it: the branch has since been validated on `09e609ab` + (run `36439268002`, `completed/success`, every job green) and again on + `eb21dec1` (run `36445217340`, triggered 2026-09-28T15:39:14Z). The sentence + "the branch's 23 commits have never been CI-validated, and the last CI run on + it is `3f02ee37`" was true when written and is now false; it is left standing + because the lesson depends on it. What the amendment adds is the confirmation + that the *structural* fix — keep the pull request mergeable — is what + restored the channel, rather than attention or luck. +- Observation: **"every gate green" is the wrong acceptance criterion for a + shared, contended host, and the plan wrote it anyway.** Evidence: the same + commit `288526a2` is red locally (`make test`, one nested-Cargo compile test + timed out at 300.008s with load average 74.39 on 24 cores) and green on CI + (3408/3408 passed, no timeout, all 3408 in 415.659s). Both are honest runs of + the same code. Impact: the milestone was gated on an acceptance criterion + that its own environment cannot reliably satisfy, so a correct change could + be held indefinitely by host load, and the natural failure mode is to keep + re-running until it goes green — which is exactly the "flake" reasoning this + plan's own memory rule forbids. Lesson: when a criterion is about the + artefact rather than the machine, say so, and name the authority that + measures the artefact. Here that authority is CI, which runs on a clean guest + with the repository's own budgets and no other tenants; a local gate run's + job is to be *green where it can be* and to have its residual failures + *attributed*, not eliminated. The plan should have read "every deterministic + gate green, and any remaining failure attributed to a named, measured cause". + `EP-M3` is accepted on that reading, with the local red recorded rather than + suppressed. +- Observation: **a delegated causality check can be reported in a form that + does not reproduce, while its conclusion still holds.** Evidence: the ninth + gate run reported that + `git diff --name-only origin/main…HEAD -- crates/ src/ tests/ tests/ui/ + tests/support/` + "returns 0 paths", and drew from that the conclusion that the failing test + is byte-identical to `origin/main`. Re-running it returns **17** paths — this + branch's entire `tests/rfc_stdlib_coverage*` tree. The conclusion is + nevertheless true, and provable a different way: the failing test's own files + are untouched + (`git diff --name-only origin/main…HEAD -- tests/command_env_ui_tests.rs + tests/ui/cli_configuration_pass/` + is empty). Impact: had the conclusion been false and the command cited as + its warrant, an inherited failure could have been waved through. Lesson: + re-run a subagent's headline command before relying on it, and prefer an + attribution that names the specific test's files rather than a coarse + directory prefix — a path-prefix filter is exactly the kind of probe that can + pass for the wrong reason, since `tests/` is both the directory this branch + adds to and the directory the failing test lives in. +- Observation: **a corpus-wide invariant test finds defects in files the branch + does not own, and a rebase can hand it new ones.** Evidence: the dangling + `netsuke-test-framework-technical-design.md` link at `96aefc9c` is on + `origin/main`'s tip and is *live there* — + `git show origin/main:docs/rfcs/ 0007-…md` line 62 carries the bad form while + line 22 of the same file carries the correct `../`. Main's own CI is green on + that commit (`build-test`, `Windows / lint-windows`, `kani-smoke`, + `netsukefile` all `success`), because `links::dangling` and its + `inter_document_links_resolve` caller exist only on this branch: + `git ls-tree -r origin/main tests/` has no `rfc_stdlib_coverage*` entry at + all. Impact: the invariant is this branch's to enforce and no upstream gate + shares it, so the failure could only ever appear here, and it appeared only + because a rebase imported a document neither party was editing in this branch + — the RFC 0006 split never touches RFC 0007. Lesson: when a branch adds a + test that reads the whole corpus rather than its own diff, re-run it after + every rebase and expect it to indict the incoming commits, not the branch. + The repair belongs in the branch (and rides to main with it) rather than in a + separate upstream pull request, because the two are the same edit. +- Observation: **"environmental" can be the right verdict for the wrong + reason.** Evidence: the seventh and eighth gate runs both correctly cleared + this branch's diff of blame for the two `make test` timeouts, and both + reached for host load as the cause. Load was real but not decisive: the two + tests are over budget on this base *by construction*. `origin/main` already + fixed the class, and not by widening a timeout — it moved + `harness_compiles_under_a_split_build_dir` onto recorded parser coverage and + serialized the remaining nested Cargo builds into a `max-threads = 1` group. + Impact: the load explanation implied "re-run when quiet", which would have + burned another 400-second gate run and failed the same way. The decisive + check is ancestry, not load: `git merge-base --is-ancestor e2fc2083 HEAD`. + Lesson: when local gates disagree with CI, check whether the fix already + landed upstream before explaining the discrepancy from the machine. +- Observation: **a review finding can name a real defect and still prescribe the + wrong fix**, and the fix is the part that ships. Evidence: the `CONF-1` + deference finding asked that `as Ansible does` stop being flagged and offered + `such as Ansible` as the false positive to fix instead. The first half is + wrong: `as Ansible does` is this plan's *own recorded seeded fault*, the + transcript at `docs/execplans/…md` showing + `justifies a helper by appealing to Ansible ("as Ansible")`, and it is proven + to fire. Un-flagging it would have turned a green control into a + green-looking empty one — exactly the failure this plan's `CONF-1` + observation already records having been bitten by once, when the obligation + sat as prose for six days with nothing implementing it. The second half is + right: `Unlike Ansible` contains `like Ansible` and means the reverse, and + `such as Ansible` is a compound preposition introducing an example. Impact: + the leading-boundary fix was adopted and clears `Unlike Ansible`; + `such as Ansible` needs a separate exclusion because its `as` is its own + word, so the boundary alone cannot reach it. The reviewer's `as Ansible does` + example was refused, with the refusal recorded in the code's own doc comment + so the next reader does not re-litigate it. Lesson: verify a finding's + *examples* as well as its mechanism — the mechanism here was sound and the + boundaries it proposed were not. + +- Observation: two of this pass's findings asserted a mechanism about the + document that the document does not support, which is the same failure mode + as a rule whose stated reason is false. Evidence: `section7.rs`'s comment + claimed `glob` "also reaches `accepted` as an accept row in its own right"; + every section 7 row naming `basename`, `dirname`, or `glob` is a `Reject` + row, and `glob`'s only row is the `fileglob` reject at RFC 0006:498. + `document.rs`'s comment promised that a heading quoted inside a fence "still + lands on the real one", which `position()` cannot deliver — though no + document in the corpus has such a heading, and each looked-up heading is + unique, so nothing was failing. Impact: both were fixed at the source of the + false claim, and the `document.rs` fix went further than the finding asked by + making the promise true rather than only rewording it. A green suite was no + evidence either way here: neither defect could fail, which is what made them + survive two prior reviews. + +- Observation: a change can pass two CodeRabbit reviews and still fail the + commit gate, because the two read different things. The second pass's seven + Rust files were reviewed twice and cleared both times; `make lint` then + rejected two of them at `4c631e18`, on `too_many_lines` (73/70) and + `iter_skip_next`. Evidence: `/tmp/lint-netsuke-<branch>-4c631e18.out:10,20` + against the same files at `c7c309d6`, where `clippy` was green. Impact: the + gate ran red on a tree CodeRabbit had just approved, which inverts the + expected order. The instruction to make every gate green *before* requesting + a review is not a formality about sequencing — it is what keeps the review + looking for semantics, because a reviewer asked to find style defects finds + some, and they are worse ones than `clippy`'s. + +- Observation: this repository's `clippy.toml` sets + `too-many-lines-threshold = 70`, well below the default 100, so a function + split by responsibility can still be rejected for length alone. Evidence: + `clippy.toml` against `map::parse`, which grew from 68 to 85 lines when the + link-number check was added to it. Impact: adding a check to an existing + function is a length risk even when the addition is small, and the remedy is + to extract the *reasoning* into named functions rather than to compress + statements. + +- Observation: RFC 0006 section 8.1 opens "All six helpers in this group are + pure" but specifies five. Evidence: `docs/rfcs/0006-...md:650` against the + five headings at 654, 671, 694, 707, and 728. Impact: correct to "five" in + `EP-M1`. The group totals do reconcile, so the defect is the word, not the + accepted set. + +- Observation: RFC 0006 section 8.6's group preamble states "Every helper in + this group is **pure and lexical**", but `expandvars` is in that group and + section 8.6 itself calls it the one environment-observing helper in the RFC. + Evidence: `docs/rfcs/0006-...md:1099` against `:1184`. Impact: a second + defect of the same class, sitting exactly on the seam this plan cuts. Correct + in `EP-M1` by scoping the sentence to the lexical helpers. + +- Observation: the naive heading count under section 8 is 58, not 57. + Evidence: three headings are prose rather than helpers (`:953`, `:1084`, + `:1502`); three headings each name two helpers (`:1280`, `:1289`, `:1321`); + `glob` (`:1261`) is an existing helper; and `basename` and `dirname` have + **no heading at all**, appearing only in the prose at `:1095`. Impact: + decisive. Any heading-based parser is wrong before it is written. This is the + strongest single reason the coverage test anchors on tables. See `D4`. + +- Observation: section 7.1 gives `basename`, `dirname`, and `expanduser` the + disposition `Reject`, with a note saying the helper already exists and gains a + `dialect` argument. Evidence: `docs/rfcs/0006-...md:468-470`. Impact: + `Reject` is overloaded three ways — table 11 splits it into 22 "already + provides", 10 "redundant alias", and 18 "on principle". Only the latter two + classes are forbidden. Two of the three helpers table 11 counts as gaining a + behaviour-preserving option are `Reject` rows. The derivation rules in `D5` + must handle this, or the test will forbid the very helpers it requires. + +- Observation: section 7 contains no accept row for `splitdrive` or + `shell_quote`. Both arrive from `Reject` rows — `win_splitdrive` and + `quote` — via the rename note in section 7.8. Evidence: + `docs/rfcs/0006-...md:478` and `:485`, reconciled at `:635-640`. Impact: + "every accepted capability covered once, every rejected covered never" is + unsatisfiable read naively for three helpers. The rejected thing is the + *Ansible spelling*; the *Netsuke capability* is accepted. `D5` records this + as an explicit three-row exception table. + +- Observation: the roadmap already deep-links every phase-6 task to its RFC + 0006 section. Phase 6 contains 51 such references, 39 of them to a section 8 + subsection. Evidence: `docs/roadmap.md:715-1267`; for example task 6.3.1 at + `:863`. Impact: this falsifies the first draft's premise that a contributor + must read 2132 lines. Reaching the `combine` contract today costs about 80 + lines. The plan's stated value had to be rebuilt around what is genuinely + absent — the per-helper contract obligations — rather than around navigation. + +- Observation: `make doc-coverage` runs `cargo rustdoc --show-coverage` over + library and binary targets only; integration tests are not measured. Evidence: + `scripts/doc-coverage.py`; `Makefile:325-329`. Impact: the governing gate on + the new test file is `missing_docs_in_private_items = "deny"` + (`Cargo.toml:252`) under `make lint`, which does apply and covers enum + variants and struct fields. `unwrap_used` and `expect_used` are also denied + (`Cargo.toml:213-214`), so parser helpers must return `anyhow::Result`. + +- Observation: `mdtablefix --wrap` does not wrap headings or table rows. A + 113-character heading sits on green `main` at `docs/rfcs/0006-...md:982`. + Impact: both candidate anchors are safe from reflow, so the choice between + them rests on semantics. Backticked spans in **prose** are not safe, which is + what corrupted the first draft. + +- Observation: no tooling parses `docs/roadmap.md`, and `markdownlint-cli2` is + configured with no cross-file link or anchor validation. Impact: a stale + relative link between documents is caught by nothing. `COV-5` adds that check + to the coverage test, which is already reading every file in `docs/rfcs/`. + +- Observation: Whitaker's `module-max-lines` lint caps every non-root module at + 400 lines, counted over the module's whole span. Evidence: the second gate + run failed at `tests/rfc_stdlib_coverage/mod.rs:30:5` with "Module `survey` + spans 787 lines, exceeding the allowed 400" — a failure the first run never + reached, because `lint-clippy` aborted `make lint` before Whitaker ran. + Impact: the parser was split into five modules by responsibility — `section7` + (the disposition tables), `section8` (the section-reference guard), `totals` + (table 11 and the purity aggregate), `inventory` (the transcribed section 7 + literals), and `assertions` (the cross-checks) — leaving `survey` to hold the + derived result and its orchestration. The largest is now 277 lines. The lint + inspects `mod` items, so a test crate root is not measured: 660-line + `tests/manifest_jinja_tests.rs` is green. Worth knowing before the child RFCs + arrive, since their registry and clause parsers grow the same way. + +- Observation: the section 14.13 coverage map inserted at `EP-M1` was captioned + `_Table 12:_`, colliding with the earlier table 12 (the `to_datetime` + conversion specifiers at `:1536`). The document numbers tables sequentially, + and `_Table 15:_` already existed, so the map is now `_Table 16:_`. Evidence: + `docs/rfcs/0006-...md:2068`. Impact: nothing parses captions, so only review + would have caught it. Worth re-checking in each child RFC, where the registry + table is the one this plan adds. + +- Observation: a `sed -i "START,ENDd"` whose `END` resolves *above* `START` + deletes exactly one line and reports no error. Evidence: the `COV-4` control + first looked its caption up with `grep -n '^_Table 12:' | head -1`, which + matched the earlier table 12, so the range ended above its start; POSIX then + matches only the first address, and the control removed the header row alone + and failed the suite with "the coverage map has 7 rows; expected 8" — a real + failure, but not the one intended. Impact: general, and the reason the + controls print the seeded diff before the verdict. A control that fails is + not yet a control that tested what it meant to, which is exactly the vacuity + `COV-1` to `COV-6` exist to prevent. Two fixes were needed: anchor the + caption search after the header (`awk -v start=...`), and guard both lookups + so an empty address aborts the script rather than reaching `sed`. + +- Observation: the **first written version of this parser was fence-blind**, and + the failure is the silent kind. A fenced `# not a heading` line inside RFC + 0006 section 8.9 read as a depth-1 heading, ended the subsection at the + fence, and left `strftime` and `to_datetime` — specified further down section + 8.10 — looking like helpers with no contract. Six of the seven checks failed, + and the message named the two helpers, not the code block. Evidence: the + fence control transcript in `Verification plan`; the red run is reproducible + by re-running `/tmp/rfc-fence-control.sh` against a checkout of `885edb93`. + Impact: this was the one control that had to be *written* to be believed, + because it was raised as a CodeRabbit review finding marked "trivial" and its + severity is anything but. Every child RFC this plan goes on to write carries + example fences in section 5, so the fault was one document edit away from + being live. Three scans shared the root cause — `Section::tables`, + `Section::subsection`, and `section8::subsection_lines` — and all three now + consult one `Fences` cursor, extracted with the rest of the Markdown lexical + layer into `markdown.rs`. The post-fix run of the same control leaves the + suite at 7 passed. + +- Observation: the plan's own COV-3 scoping note was right and the code did not + implement it. The purity aggregate must range over the 57 **proposed** + helpers; the registries carry all 60 accepted ones. Filtering on purity alone + yields 54/5/1 against section 6.1's 52/4/1 and would have failed a correct + document at `EP-M11`, the first milestone where all eight registries exist. + Evidence: `with_purity` had exactly three call sites, none filtering on + registration kind, and the optioned rows include the filesystem-observing + `glob`. Impact: the fix is at the accessor rather than the call site — + `new_with_purity` carries the registration filter in its contract, so a + future caller cannot get the wrong answer by forgetting it. Also raised by + CodeRabbit and also latent: the check is guarded by `written == rows.len()`, + so it cannot fire before every child exists. + +- Observation: Whitaker's `conditional_max_n_branches` counts a match **guard** + as branches, and the limit is 2. Evidence: the first `Fences` implementation + guarded its closing arm with + `opened == character && run >= opened_run && info.trim().is_empty()` and + failed at `markdown.rs:91` with "Collapse the match guard to 2 branches or + fewer". Clippy passed the same expression, and so did `make test` and + `make typecheck`; the violation is dylint-only, which is why it surfaced at + `make lint` alone. Impact: the guard was not rewritten as a flatter `if`, + which would have lost the "closing fence carries no info string" rule — the + natural cheat is to drop a condition and the branch count falls with it. + Instead the three conditions became two predicates, `Delimiter::closes` and + `Delimiter::is_closing_run`. Eleven hand-run probes over `/tmp` copies + confirm the CommonMark semantics survive: info strings, longer and shorter + closing runs, tilde-versus-backtick nesting, inline code spans, indented + fences, and unterminated blocks. When a child RFC's parser grows a similar + guard, expect this lint, and split a condition into a named predicate rather + than deleting it. + +- Observation: `make fmt` cannot be pointed at one file, and `mdtablefix` has + no check-only mode. Evidence: `check-markdown-format.sh` stages copies and + compares, precisely because the tool always writes; `MD_FILES_FIND` in the + Makefile covers the whole corpus. Impact: after an editorial prose edit the + scoped invocation is + `mdtablefix --in-place --wrap --renumber --breaks --ellipsis --fences <file>…`, + with the flags copied from `mdformat-all` and the checker. Running + `make fmt` instead would reformat unrelated documents and bury the milestone + diff. Both files edited at `EP-M1`'s second review were pure paragraph + rewrapping — zero table-pipe changes — which the checker's failure message + alone does not distinguish. + +- Observation: the worked section `EP-M2` commits is a **fenced** copy, so it + is subject to a width limit the real child RFC is not, and the first draft + was written to the real child's width and failed the gate. Evidence: a + 208-column fenced row failed `MD013` with + `t.md:6:121 error MD013/line-length Line length [Expected: 120; Actual: 208]`, + and `.markdownlint-cli2.jsonc` sets `MD013.code_block_line_length` to 120 + while setting `tables: false`. Impact: a fenced copy must be laid out to 120 + columns even though `tables: false` means no real table in the corpus is ever + measured, so the worked section's tables are narrower than RFC 0013's will be. + `mdtablefix` compounds it by leaving fenced content completely alone — + probed on a file with a misaligned fenced table and an over-wide fenced + paragraph, both of which it reported as "left unchanged" while reflowing the + same paragraph outside the fence — so `make check-fmt` will not repair a + fenced copy either. The consequence for the remaining children is nil, + because a child RFC is real Markdown: its tables are exempt from `MD013` and + are reflowed by `mdtablefix` like every other table in the corpus. The + consequence for this plan is that the specimen is sized for the fence and + says so, rather than being the widest form a child may use. + +- Observation: the first draft of the worked section named a `non_string_key` + error condition for `from_yaml`, and RFC 0006 section 8.1 rejects the + opposite set. Evidence: section 8.1 says "Mapping keys may be strings, + integers, or booleans. Sequence and mapping keys are rejected." An integer or + boolean key is therefore *accepted* and a sequence or mapping key is + rejected, so a condition called `non_string_key` names the wrong predicate in + both directions: it would fire on input the document admits and stay silent + on input it excludes. Impact: renamed to `unsupported_key`, which states what + is rejected without contradicting the accepted kinds. The defect was found by + writing the condition list against section 8.1 rather than from memory of it, + which is the whole reason the worked section was drafted before `EP-M3` + rather than during it. + +- Observation: the skeleton this plan tells `EP-M2` to "copy literally" **does + not satisfy the parser `EP-M1` already shipped**, in two independent places. + Evidence: the skeleton's registry heading read + `### 5.1. Purity and manifest-query registry` against `REGISTRY_HEADING`'s + `### 5.1. Registry` (`registries.rs:20`), and its manifest-query cell + vocabulary was `Available`/`Stub` against `check_manifest_query`'s `yes`/`no` + (`registries.rs:229-237`). Impact: both are hard failures on the first row + read, and both would have fired only at `EP-M3` — after the go/no-go had + already been spent on a document written to the wrong contract. Neither was + caught by `EP-M1` because no child RFC exists yet, so every check that reads + a registry is vacuously green; the template was prose the parser had never + been pointed at. Two readings were available for each — change the template + or change the parser — and the template gave way in both, because in both the + parser already agrees with the parent document. RFC 0006 table 2's own column + is headed "Available in manifest queries" with cells `Yes` and `No`, so + `Available`/`Stub` was a second vocabulary for a fact the parent already + spells; and `### 5.1. Registry` also matches RFC 0006's own section 14.13 + wording, "carries the group's registry". The fix is to the skeleton plus a + note at each site naming the constant that reads it, so the next editor knows + the heading is parsed rather than prose. The general lesson: a template + committed in prose is untested code, and the milestone that first consumes it + is the wrong place to discover that. + +- Observation: the cross-check added to catch a *child's* wrong namespace read + a value the same task had hardcoded wrong, so the guard would have failed a + correct document. Evidence: `section7::apply_optioned` inserted every + optioned helper as `Namespace::Filter`, while RFC 0006 section 3.2 lists + `glob` under Functions; `check_rows_agree_with_survey` compares the child's + namespace cell against that value, so `COV-1` would have failed RFC 0018 — + the group that owns `glob` — for being right. Impact: found only because the + previous pass's fix was re-derived from the document rather than trusted. A + check is a comparison, and a comparison is only as good as its weaker side: + hardening the side that reads the untrusted document is pointless while the + trusted side is a literal nobody re-reads. `Optioned` now carries its + namespace, sourced from section 3.2, and the same reasoning is why the + optioned rows' purity is parsed from the child's own cell rather than + defaulted. + +- Observation: `CONF-1` existed as an obligation in this plan for six days while + the code implemented only its table half. Evidence: the plan's `CONF-1` says + each subsection must be non-empty, name an owned helper or carry the `D6` + escape phrase, and contain no Ansible-deference phrase; `clauses.rs` parsed + the `Clause | Discharge` table and compared its id set, and read no + subsection body at all. Every subsection check the plan names was unchecked, + and every control in `CONF-1`'s non-vacuity list would have passed — because + none of them was implemented either. Impact: this is the plan's own principal + risk, left unguarded by the very obligation written to guard it, and it was + found by review rather than by a failure. The gap was invisible precisely + because the check that *did* exist passed: a green suite one obligation short + of what the plan claims is indistinguishable from a complete one. The general + lesson is that an obligation's prose and its implementation need to be read + against each other once, deliberately, and that the plan's non-vacuity + controls are the cheapest way to do it — a control that cannot fail is a + spec, not a test. + +- Observation: the spec of the `CONF-1` check, written *before* it parsed the + ADR's worked specimen, turned out to reject that specimen. Evidence: + subsection 5.9 of the `EP-M2` worked section named thirteen diagnostic codes + and no helper, so `names_an_owned_helper` was false and the `D6` escape + phrase was absent — a true positive, not a false one. Impact: the specimen + was demonstrating clause 6.9 by enumerating the group's contribution to it, + which is exactly the "specific enough to be worth writing" standard the rule + is meant to enforce, and the rule's own vocabulary was still satisfied by + naming the codes' subject. The fix names all five helpers alongside the codes + rather than weakening the check, because the check is right about what a + reader needs. This is the useful shape of the interaction: a rule written + against a document it has not read yet is the only version that can surprise + its author, and it is worth reading the two against each other before the + rule is relied on rather than after. + +- Observation: two of the three shapes `CONF-1` checks for are already caught + when the class is written *inconsistently*, and only the third needs the new + check. Evidence: `check_manifest_query` (`registries.rs:240`) already rejects + a row whose purity class and manifest-query cell disagree — table 2's own + rule — so a probe setting `from_json` to `Subprocess-observing` with the cell + left at `Yes` fired that check, not the new one. The new aggregate checks + caught it only once the probe was made *consistent* (class + `Subprocess-observing`, cell `No`), which is the case nothing else sees: the + row is internally coherent and still contradicts section 6.1's "no proposed + helper is". Impact: the two checks are complementary rather than redundant, + and a probe that proves one is live must be constructed so the other cannot + answer first. Worth remembering when adding checks to a suite that already + guards adjacent invariants. + +- Observation: the two totals checks were gated on all eight capability groups + being written, so neither could fire until the split was complete. Evidence: + the generator's aggregate was wrapped in `if world.map.unwritten() == 0`, + which is true only when no child exists yet or all eight do; the `EP-M3` + control writes exactly one child, so the milestone it was written for could + not trigger it. Impact: the totals are now asserted partially as well as + exactly — an upper bound on each purity class and on the optioned count runs + at every prefix, and the exact equality runs only when complete. The bound is + sound because section 6.1's three counts are a budget, not a target: a + half-written split can spend too much of it but can never spend too little. + The forbidden classes (clock, network, subprocess) are asserted as hard zeros + at every prefix, because section 6.1 states no proposed helper is any of them + and a zero is not a budget. The general shape is worth reusing: a check + guarded by a completion condition is a check whose subject is the completion, + not the property. + +### `EP-M0` audit results (2026-09-11) + +The audit re-derived every count in this plan mechanically from +`docs/rfcs/0006-...md` with a throwaway script (`/tmp/ep-m0-audit.py`, not +tracked; the derivation it performs is reimplemented in the coverage test at +`EP-M1`). Results, with the plan's claims alongside: + +| Quantity | Plan claims | Derived | Verdict | +| ----------------------------- | ----------- | ------- | ------------ | +| Accept rows in section 7 | 55 | 55 | agree | +| Defer rows in section 7 | 6 | 6 | agree | +| Reject rows in section 7 | 50 | 50 | agree | +| New Netsuke helpers | 57 | 57 | agree | +| Optioned existing helpers | 3 | 3 | agree | +| Accepted set | 60 | 60 | agree | +| Filters / tests / optioned | 41/16/3 | 41/16/3 | agree | +| Purity (pure/fs/env) | 52/4/1 | 52/4/1 | agree | +| Naive section 8 headings | 58 | 58 | agree | +| Forbidden-set members | 34 | 71 | **disagree** | +| Table 11 class counts sum | 50 | 50 | agree | +| Class split, last stated rule | 22/10/18 | 8/24/18 | **disagree** | + +- Observation: the plan's `COV-2` assertion that the forbidden set has "exactly + 34 members" reconciles only as a **row** count — 50 reject rows minus 22 + "already provides" rows, plus 6 deferred rows — and not as a name count under + any derivation. Reject-row name cells expand to 67 names; removing the names + on rows the notes class as "already provides" leaves 38 or 40, and adding the + 6 deferred names gives 44 or 46. Evidence: the audit script's alias-group + expansion, and `docs/rfcs/0006-...md:468-635`. Impact: the plan's number is a + category error. `D10` replaces the size assertion with the complement rule's + 71-name deny set. The membership assertions are nearly all satisfied: + `is_file`, `quote`, `fileglob`, and `lookup` are all forbidden under the + complement, so the four names the first draft's handwritten list omitted are + still caught. One assertion fails: the plan required the deny set to + **exclude** `expanduser`, on the ground that its row is classed "already + provides"; the complement forbids it. That exclusion was the one place the + plan's stated method and its expected members disagreed, and the members had + the better of it — the internal inconsistency, not the number 34, is what + made the class-based method untenable. + +- Observation: the plan's "exactly 34 members" and its `is_file` membership + assertion are mutually inconsistent, which is what actually indicts the + class-based method. Under a faithful class reading (`file` / `is_file` + classed "already provides") `is_file` is **not** forbidden; under the row + count that yields 34 it is not either. The plan could not have both its + number and its membership list, and `EP-M0` was right to stop on it. Evidence: + `docs/rfcs/0006-...md:468-635` and `:600-639`. Impact: the complement rule + satisfies both the intent (catch `is_file`) and the four-name witness, and + gives up only the `expanduser` exclusion, which nothing depended on. + +- Observation: the resolution-note column does **not** discriminate the three + reject classes. `EP-M0`'s first reading said it did, and `EP-M1` falsified + that; the retraction is recorded here rather than only in `D10`, because the + earlier reading is the one a reviewer would otherwise still be working from. + The rule that was believed to reproduce the split was: **alias** if the + resolution cell contains the token "alias" or cites §10.2; otherwise + **exists** if it begins "Exists" or names a provider in backticks; otherwise + **principle**. Re-derived at `EP-M1` it gives **8 alias / 24 exists / 18 + principle**. The *principle* count is right and the (correction at `EP-M1`) + premise holds — every principle row is backtick-free, so `ternary` ("Jinja + conditional expressions") and `mandatory` ("Strict undefined already errors") + land there correctly. The error is the 32-row remainder: table 11 counts 10 + alias and 22 exists, and the two-row difference is exactly `win_splitdrive` + and `fileglob`, the rename rows whose resolution cells name a Netsuke call + form rather than the word "alias". Recovering 22/10/18 would mean + special-casing those two out of *exists* while leaving `now` — a reject row + that also names an existing helper in backticked call form — inside it, with + nothing in the document to distinguish them. Evidence: + `docs/rfcs/0006-...md:468-635`, `:1617-1690`, and `:600-639`. Impact: the + split is **not derivable** and is not asserted; `D5` rule 3's prescribed + remedy — adding a discriminating column to section 7 — is needed only if the + split is ever wanted as a contract, and no normative edit to RFC 0006 is made + now. `COV-2` asserts the parseable totals and that table 11's three class + counts sum to the reject-row count, so a broken table still fails loudly. + +- Observation: the control schedule below is off by one milestone, and this was + found by writing `EP-M2`'s probe rather than by reading. It says the `COV-2`, + `COV-3`, and `CONF-1` controls "need something to corrupt and run at `EP-M4` + and `EP-M5`, the first milestones with a registry row and a clause body". The + first milestone with a registry row and a clause body is `EP-M3`: it delivers + RFC 0013, which owns five registry rows and discharges all eleven clauses. + `EP-M4` is merely the *second*. Evidence: `EP-M2`'s liveness probe placed the + ADR's worked specimen at `docs/rfcs/0013-…md` and flipped RFC 0006's map row + to `written`, and `every_child_discharges_every_clause`, `COV-1`, `COV-3`, + and the link check all ran non-vacuously against it. Impact: the four + controls are runnable at `EP-M3` and are discharged there, not deferred; the + schedule was corrected in place rather than left to contradict the milestone + it sits above. + +- Observation: `COV-3`'s purity aggregate is satisfiable only over rows whose + `Registration` is `New`. The registries carry all 60 accepted helpers, and + the three optioned rows include `glob`, which is filesystem-observing; + all-row aggregation therefore yields 54 pure / 5 filesystem / 1 environment, + not the 52/4/1 that section 6.1 states. Evidence: + `docs/rfcs/0006-...md:222-260` counts only the 57 proposed helpers. Impact: + minor; the fix is to scope the aggregate to `New` rows and say so in `COV-3`. + +- Observation: RFC numbers 0013 to 0020 are free. `origin/main` carries RFCs + 0001 to 0012 only, and every active remote branch checked (`3-14-8-…`, + `4-3-1-…`, `4-4-1-…`, `7-1-1-…`, `adopt-rstest-bdd-v0-5-0`, + `docs/rfc-0001-implementation-roadmap`, `document-the-timeout-tiers`, + `property-testing-rfc`) stops at 0012. Impact: no reservation is required + before `EP-M3`, but the allocation is only a convention and `EP-M1` must + still backfill the number-allocation table in RFC 0006 and re-enumerate + before each child commit. + +- Observation: the partition's per-group registry contents all reconcile + against the accepted set. Group sizes are 5, 6, 8+7, 4+4, 6+1(+2), 1+4(+1), + 9, and 2, summing to 41 filters, 16 tests, and 3 optioned helpers, exactly + the accepted set of 60. Impact: `EP-M0`'s partition is confirmed; no helper + needs to move between children, and the ambiguity tolerance is not triggered. + +- Observation: three defects in the coverage parsers were found only by + running the checks against the real documents, and all three would have + misreported rather than crashed. First, `Section::tables` pushed the table's + own accumulator and then appended rows to a different allocation, so every + table parsed as empty. Second, `Section::subsection` excluded its own heading + line, so a table placed directly under a subsection heading was attributed to + no heading at all and named `""`; `map::parse`, `registries::parse`, and + `clauses::discharged` all filter on that heading text, so all three would + have failed on the first child RFC as well. Third, `roadmap`'s `FIRST_STEP` + and `LAST_STEP` carried a `###` prefix while being compared against a heading + already stripped of it, so no capability step was ever in range and `COV-6` + reported the roadmap as empty. Evidence: the diagnostics that located each — + `coverage map subsection contains no table; 39 lines, 1 tables [("", 8)]` for + the second, `docs/roadmap.md has no capability steps starting ### 6.2.` for + the third. Impact: the narrow parser contract in the module docs was right, + but three parsers were never exercised against their subject before these + runs, which is the argument for landing the checks while their subjects are + still unwritten. + +- Observation: `COV-4`'s required line cannot be printed the obvious way. The + workspace denies `clippy::print_stdout` and `clippy::print_stderr` + (`Cargo.toml:209-210`), and `cargo nextest run` captures a passing test's + output by default, so a `println!` would be both a lint error and invisible. + Evidence: `make test` runs `cargo nextest run`, whose + `profile.default.overrides` already raise `success-output` for two tests. + Impact: the check carries one + `#[expect(clippy::print_stdout, reason = ...)]`, and `.config/nextest.toml` + gains an override for `coverage_map_status_is_reported` with + `success-output = "immediate"`. Verified: a green run prints + `coverage map: 0 of 8 capability groups written; 8 remaining`. + +- Observation: the 400-line cap is measured on *code*, not on file length, and + the difference defeated the first attempt to verify the fix. Liveness-probing + `module_max_lines` by appending 120 `// pad` lines to a 303-line module left + Whitaker green, which reads as "the lint is not looking at this file". It is + looking; it does not count bare comment lines. Re-probing with 110 + doc-comment lines plus a function took the same file to 415 and produced + `error: Module progress spans 415 lines, exceeding the allowed 400.` Impact: + the fix at `7605c884` is confirmed by a live oracle rather than by an absence + of output, and every module is now at most 303 lines, so none is near the + boundary under either counting rule. This is the second time in this task + that a probe's own defect would have been read as a pass had the probe not + been run against a case it was expected to fail. + +- Observation: the cap's scope is *nested modules*, and the corpus confirms it + rather than merely permitting it. Every non-crate-root Rust file in the tree + is at most 400 lines, with four (`src/ninja_gen/mod.rs`, + `src/manifest/mod.rs`, `src/manifest/render_tests.rs`, + `src/manifest/expand_test_cases/condition_cases.rs`) sitting exactly at 400 — + a wall, not a coincidence. Eight `tests/*.rs` files exceed it (411 to 660), + all of them crate roots, and `dylint.toml` treats `tests/*.rs` targets as a + separate compilation unit. Impact: the two files this milestone added were + the only nested modules over the cap, so the violation was this task's to fix + and not an inherited condition; had the cap applied to crate roots, the same + split would have been required of eight pre-existing files and would have + been out of scope. + +- Observation: "just a module move" is exactly the change for which a + formatting gate gets skipped, and exactly the change most likely to need one. + The split at `7605c884` was verified with three gates — clippy, nextest, and + Whitaker — and committed without `make check-fmt`, which then failed on two + mechanical diffs in the two files the split had just created by hand. The + first stage of the documented gateway set was the one omitted. Impact: the + cost was a fifth gate run and a second scrutineer invocation, both of which + the first-stage gate would have prevented for the price of one command. Every + commit from here runs all five `make` targets as a single command, however + mechanical the change looks. + +- Observation: `docs/contents.md` is the one index in the corpus that nothing + checks, and this task was carrying the only defect it had. Of 39 ADR files, + 38 were indexed and the missing one was exactly this branch's. Impact: the + omission survived nine gate runs, and it was found only by reading the file, + not by a gate. No test in the tree mentions `contents.md` at all. The rule + this plan already fixed for RFC numbers therefore has a second, unguarded + instance: `D2` allocates RFC numbers lazily *because* they collide, and the + index that records them has no equivalent guard. Not fixed here — a check on + `docs/contents.md` is outside this plan's declared interface set, and adding + one would be a new obligation rather than a discharge of an existing one. It + is recorded so the omission is not rediscovered later. + +- Observation: "run `make fmt`" is not a remedy for MD013, and the two look + alike from the gate output. The three over-long lines this task added were + prose, and `make fmt` rewrapped neither: `mdtablefix` has no wrap rule that + reaches an unwrapped prose line, and `markdownlint --fix` does not implement + MD013 at all. Impact: the plan's summary of `make fmt` as the fix for + formatting findings was too broad. MD013 is a hand fix, and treating the + formatter as its remedy would have left the gate red for a second run. The + converse also held and was checked rather than assumed: after the hand fix, + `make fmt` ran again and touched none of the three, and + `mdtablefix --renumber` did not eat the `- [x] (2026-09-25)` progress + entries, which is the failure mode that tool is known for. + +- Observation: a non-normative copy of a normative section is still a copy, and + nothing checks the two against each other. `ADR-040` carries RFC 0013's + section 5 as a worked specimen and says in terms that RFC 0013's copy is the + normative one. The specimen's `from_yaml_all` bullet nevertheless still read + "rejects every `from_yaml` condition" — the precise wording RFC 0013 §5.6 was + corrected away from when RFC 0006 §8.1 was read properly, since a + zero-document stream is an empty sequence rather than an error. So the + divergence was introduced by the correction itself: it was applied to the + artefact and not to the specimen that previews it, and the two then disagreed + for eleven days across three gate runs and three review passes. Evidence: + CodeRabbit's fourth pass flagged the ADR line; extracting the bullet from + both files showed the ADR's copy to be the pre-correction text. Impact: this + is not a stale comment. The specimen is the only worked example a `EP-M4` to + `EP-M10` author has, and it is a full section rather than a summary, so the + ADR is what gets copied. A second copy of a normative text needs either a + check or an explicit pointer to the artefact as the single source — the ADR + already has the pointer and it was not enough, so the rule this records is + narrower: **when a correction is applied to an artefact, grep for its other + copies in the same commit.** Neither `make check-fmt` nor `make lint` + compares two documents' prose, and no review pass before this one had both + copies in view. + +- Observation: the ownership check could not see the one collision its own + subject matter is named for. The coverage map must contain exactly one row + per capability group, `D2` allocates RFC numbers lazily *because* they + collide, and the plan re-enumerates remote heads before each child commit for + exactly that reason — yet `parse` had no duplicate-number guard, and nothing + else noticed one either. Evidence: `Map::ownership` inserts every claimed + helper into one map keyed by name and errors only when the *same helper* is + claimed by two *different* numbers, so two rows sharing a number are not a + conflict to it; downstream, `registries::parse_all` filters on + `children.contains`, and both the status and registry checks match rows with + `find`/`any`. A mutation of row `0015` to `0014` therefore passed all fifteen + checks, one child silently representing two capability groups. Impact: three + separate guards each had a partial view and each assumed another had the + whole one. The duplicate-number guard now sits in `parse`, the only place + both rows are visible before they are separated into `Map::rows`. The general + lesson is the converse of the ADR one: a check keyed on the *aggregate* + (helpers → owner) cannot enforce a property of the *members* (one number per + row), and the subject matter's own history of collisions is a hint about + which property deserves a direct guard rather than an emergent one. + +- Observation: three of this repository's Markdown tools disagree about where a + fence may begin, and the disagreement runs in the direction that hides a + defect. `CommonMark` allows up to three leading spaces before an opening + fence; `Delimiter::opening` accepted any amount; `mdtablefix`, the gating + tool, is lenient through indent 6 by way of `trim_start()`; and this + repository's `markdownlint` config sets no MD046 key, so MD046 runs in + "consistent" mode. Evidence: `Delimiter::opening` read `line.trim_start()`; + an indentation sweep showed `mdtablefix` leaving a table untouched at every + indent 0–6, and `markdownlint` reporting zero errors for a 4-space-indented + fence. Impact: a document whose only code block is indented passes every + gate, so the leniency was invisible by construction rather than by accident — + and the failure it permits is the silent one, an indented run of backticks + swallowing every heading and table beneath it. The fix aligns the predicate + with `CommonMark` and the module's own doc-comment claim, which is also what + the shipped sibling reader in `tests/documentation_examples/mod.rs` does + (column zero only, stricter than `CommonMark`). The cost is named in the doc + comment rather than hidden: a fence nested in a list item is at the item's + content column and this line-level predicate will not recognize it. That + error is loud — the body is handed to the heading and table scans and + misparses — and the corpus has **zero** indented fences across all 23 scanned + files (the 22 under `docs/rfcs/` plus `docs/roadmap.md`, 206 fence lines, + none indented), so no document needs the nested form today. Three unit tests + in `mod fence_tests` pin the boundary, because a corpus with no instance of a + shape cannot test the predicate for it. + +## Decision log + +- Decision `D1`: eight child RFCs, one per roadmap phase-6 capability step 6.2 + to 6.9. Step 6.1 gets no child RFC. Rationale: the roadmap's steps are a + complete, disjoint cover of RFC 0006 sections 8.1 to 8.10, and they cut on + the **purity seam**, which section 14's delivery slices do not. Slice 5 + bundles pure lexical path helpers with filesystem-observing predicates; the + roadmap instead puts the lexical helpers and the pure `abs` test in step 6.6 + and the observing helpers in step 6.7. Since section 5.1 of each child is a + purity registry, a purity-aligned partition makes seven of eight children + uniformly pure and one uniformly non-pure, and makes every clause about + capability, determinism, and manifest-query availability uniform within a + child. Slice alignment would produce a child straddling two roadmap steps and + mixing purity classes. Step 6.1 gets no child because its subject is the + shared machinery of sections 6 and 14.1, which is not a capability in section + 7's sense; such a child would own no helper and restate what RFC 0006 already + specifies. Date/Author: 2026-09-08, planning agent. + +- Decision `D2`: allocate RFC numbers 0013 to 0020 **lazily**, one per child at + the commit that creates it, after landing reservation rows in RFC 0006's + number-allocation table as part of `EP-M1`. Rationale: numbers become + irreversible on merge, and this repository has a measured collision base rate + — `adr-003`, `adr-004`, and `adr-014` are all duplicated on disk. A + reservation that lives only on this branch reserves nothing. The partition is + stated by slug, so nothing depends on contiguity. `EP-M1` also backfills 0007 + to 0012 into that table and deletes its false claim that no RFC has been + merged to `main`. Date/Author: 2026-09-08, planning agent. + +- Decision `D3`: **RFC 0006 section 8 stays intact and normative.** Child RFCs + are additive. This reverses the first draft, which migrated the per-helper + contracts out. Rationale: five lines of evidence. The repository's + established convention is additive — RFCs 0009, 0010, and 0011 each declare an + `Amends` preamble bullet naming RFC 0001 and instruct that the amendment be + folded back into the parent before promotion, keeping the parent normative + throughout. Migration would leave 39 roadmap task bullets citing a stub, with + no link gate to notice. Two capability groups split across two milestones, + and the shared `dialect` block that section 8.6 defines is consumed by + helpers in both, so the intermediate states would be genuinely incoherent + rather than merely incomplete. Migration accounts for only 935 of roughly + 3500 lines of first-draft deliverable, so it buys little and risks most. And + the measured contributor read path gets worse, not better. What is lost: a + child RFC is not self-contained; a reader consults RFC 0006 section 8.N for + the contract. That is accepted, and the Purpose section no longer claims + otherwise. Date/Author: 2026-09-08, planning agent. + +- Decision `D4`: coverage is anchored on **tables, not headings**. Each child + RFC's section 5.1 is a five-column registry — helper, namespace, + registration, purity class, manifest query — with one row per helper the RFC + owns. That registry is the ownership signal. Rationale: heading anchoring is + unworkable, provably. `basename` and `dirname` have no heading, so two of the + three optioned helpers would be permanently unowned. Three headings name two + helpers each. Three headings under section 8 are prose. And RFC 0006's own + sections 10.3, 11.1, 11.4, 11.5, 11.7, 11.8, and 15.3 are headings containing + backticked helper names in non-normative contexts, so a heading scan produces + false duplicate-ownership and false forbidden-name hits against the parent + document. A table cell cannot be mentioned in passing. The registry is also + the artefact section 6.1 requires and which exists nowhere today, so it is + worth writing regardless of the test. Date/Author: 2026-09-08, planning agent. + +- Decision `D5`: the accepted and forbidden sets are **derived from RFC 0006 + section 7's disposition tables and section 7.8's totals**, not hardcoded. + Rationale: section 7 states its own purpose as the index — every surveyed + name with an explicit disposition and, for accepted names, the owning section + 8 subsection. Hardcoding would create a third copy of the inventory in a + different language, drifting independently of the two it mirrors, which is + the exact failure this task exists to prevent. The evidence that hand + maintenance fails is that the first draft's list omitted sixteen names. Three + derivation rules, which the test must state: + 1. **Alias groups.** Section 7.8 notes that rows cover alias groups as single + entries. Split the name cell on its separator. + 2. **Renames.** Three accepted capabilities are registered under a Netsuke + name rather than the surveyed one: Ansible's `hash` becomes `text_hash`, + its `quote` becomes `shell_quote`, and its `win_splitdrive` becomes + `splitdrive` with a windows dialect. The rule is read off the disposition + cell, which for `hash` reads "Accept as `text_hash`" — **not** `Reject`, as + the first draft of this rule said — and `Reject` with a rename note for + the other two. A disposition beginning "Accept as" therefore names the + registered helper directly, and the renaming is not a special case the + parser needs to know about. Section 7.8 states the three renames in prose + and the test asserts there are exactly three. + 3. **Reject is overloaded.** The disposition cells, counted by `EP-M0`, are + `Accept` (54), "Accept as `text_hash`" (1), `Defer` (6), `Reject` (49), + and `Reject as a new name` (1). All 50 reject-dispositioned rows count as + rejected whatever their class, so the deny set is the complement of the + accepted set: 67 reject names plus 6 deferred names, less the 2 that are + themselves accepted Netsuke names. The class distinction is **not + derivable** from the document and is **not asserted**; `D10` records how + that was established and why it does not matter. No check reads a row's + class. + Date/Author: 2026-09-08, planning agent. + +- Decision `D6`: section 5 has five mandatory substantive clauses; the other + six may be a single declarative line. Rationale: seven of the eight children + own only pure helpers, so clauses 6.2 to 6.5 discharge to the same four + sentences in each — roughly 336 lines of literal restatement across the set, + with 6.5, 6.10, and 6.11 adding more. Mandating eleven prose subsections per + child is a vacuity generator. Substantive and per-helper: **5.1** the + registry, **5.6** type and error contract, **5.7** canonical value equality, + **5.8** resource bounds, and **5.9** diagnostics, which must give a code of + the form `netsuke::jinja::<module>::<reason>` per error condition. Permitted + to be one line: 5.2, 5.3, 5.4, 5.5, 5.10, and 5.11. **Anti-vacuity rule**, + applied by a reviewer in one pass: every subsection states either a + group-specific consequence — a bound, a registry row, a diagnostic code, a + purity assignment, a named error condition — or the exact words "No + additional obligation beyond RFC 0006 section 6.N." A bare restatement of the + clause is a review reject. Date/Author: 2026-09-08, planning agent. + +- Decision `D7`: roadmap steps 6.10 and 6.11 get no child RFC. + Rationale: 6.10 covers section 9's deferred candidates, which the success + criterion requires be covered by none. 6.11 is outside RFC 0006's + Ansible-derived set and already has + `docs/git-change-detection-helpers-design.md` and + [ADR-015](../adr-015-use-bounded-git-cli-for-change-detection.md). + Date/Author: 2026-09-08, planning agent. + +- Decision `D8`: roadmap task 6.1.1 has been rewritten from "child issues" to + "child RFCs and accompanying roadmap tasks", and the same wording is to be + updated at the seven places inside RFC 0006 that say "child issue". **Done + for the roadmap**, on reviewer direction, in the commit that carries this + revision; RFC 0006's seven phrases remain `EP-M1` work. Rationale: leaving + either document saying "issues" while the tree contains eight child RFCs + would leave both describing work nobody did. RFC 0006 section 6 opens by + saying a child issue that does not satisfy every clause is not complete, + which after this change is the definition of a child RFC's section 5. The + reviewer also directed that work be tracked in committed documentation where + possible, which settles the open question about the lost burn-down: the + roadmap checkboxes in steps 6.2 to 6.9 are the tracker, and no child RFC gets + a GitHub issue. The amended task records that explicitly so a later reader + does not reintroduce one. The amended success criterion says "exactly one + child RFC and at least one accompanying roadmap task" rather than "exactly + one" of each, because `product` is already named by tasks 6.4.2 and 6.4.5. + Making the roadmap half "exactly one" would have required splitting existing + tasks for no benefit. Date/Author: 2026-09-08, planning agent; roadmap + wording only under `D8`, `EP-M1` and `EP-M11` doing the rest. + +- Decision `D11`: this branch's ADR is renumbered from 021 to **040**, the + lowest free number above the corpus ceiling, and `docs/contents.md` gains the + index entry it never had. Rationale: `main` published an ADR 021 of its own + on 2026-09-09 (`3348cc0a`), one day after `EP-M0` recorded "the highest + existing ADR is 020 … re-check before committing", and the branch's ADR is + dated 2026-09-11 and committed at `9730a880` with main's already in its + history. Two ADRs cannot both be 021 in one corpus. The plan's tolerance rule + is unambiguous about the disposition — "If a number is taken, stop and + escalate" — and the escalation is raised rather than assumed away. Of the two + admissible remedies, renumbering the unmerged branch is the one the corpus + already prescribes: main's number is cited by three inbound links including a + link-reference definition, so moving it would churn a published document to + fix an unpublished one. The ceiling is re-swept rather than remembered, + because the number that was free when this plan was written is the number + this defect is made of: 039 on `jm5/kani-change-scoped-gate` is the current + highest anywhere, so 040 is free, and `origin/main`'s own highest is 038. + + Scope is eight edits, all mechanical, and the guard is what makes the last + two safe. The 17 `ADR-021` mentions in this plan were checked for `fetch`, + `trust`, `quarantin`, `network`, and `policy` before any replacement: none + matched, so every mention means this branch's ADR and a scoped global replace + cannot corrupt a reference to main's. Main's file and all three of its + citations are left untouched, and the result is verified by set-comparison + rather than by count, because 39 files against 39 entries is also what a swap + looks like. Date/Author: 2026-09-25, implementation agent. wording confirmed + by the reviewer. + +- Decision `D9`: record the convention in + `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md`, scoped narrowly. + Rationale: allocating eight numbers under a particular partition is hard to + reverse. But the ADR must not claim to generalize. It applies to **survey + RFCs** — documents that enumerate a large candidate set with a per-candidate + disposition — and explicitly does **not** replace the `Amends` convention + that RFCs 0009 to 0011 use for normative amendments to RFC 0001. The first + draft claimed the convention would extend to RFC 0001; that would have + contradicted those three amendments' own fold-back instruction. The ADR must + also carry the **amendment procedure**: the ordered touchpoints to edit when + a helper is added, removed, or renamed after the split — the section 7 row, + the section 8 subsection, the section 14 coverage map, the owning child's + registry, and the roadmap task — so the coverage test is a guard rail rather + than a ratchet. The highest existing ADR is 020; three earlier numbers + collided, so re-check before committing. Date/Author: 2026-09-08, planning + agent. + +- Decision `D10`: the forbidden set is the **complement of the accepted set** + — every section 7 reject name and every section 9 deferred name, less the + registered Netsuke names of accepted helpers — which is **71 names**, not the + 34 this plan first asserted. The reject-class split of table 11 is neither + derived nor asserted; the deny set does not need it. Rationale: `EP-M0` found + that the plan's 34 reconciles only as a **row** count — 28 class-based + forbidden rows plus 6 deferred rows — and not as a name count under any + derivation; that its assertion that the deny set contains `is_file` cannot + hold under that same class reading, because the `file` / `is_file` row is + classed "already provides", so the plan's number and its membership list were + mutually inconsistent; and that the resolution-note prose does not + discriminate the three reject classes under any rule the document states. Two + independent attempts at a note-parsing rule produced 24/10/16 and 25/6/18 + against table 11's 22/10/18; a third rule reproduced 22/10/18 exactly, but it + leans on the literal token "alias" and on a citation to §10.2, and neither is + a contract the parent document offers. The complement rule needs no prose + parsing at all, is strictly safer than any class-based reading because it + forbids a superset of what both readings forbid, and requires no normative + edit to RFC + 0006. Two consequences are accepted: `basename` and `dirname` are the only + excluded names, so `expanduser` — which the first draft would have permitted + — is forbidden; and the deny set does not shrink when section 7 gains a + reject row of the "already provides" class. Neither affects a child RFC, + because a registry row must name an accepted helper, and the accepted set is + checked independently by `COV-1`. The class split is **not** retained as a + witness. `EP-M1` re-derived it a third time against the rule recorded here + and got 8 alias / 24 exists / 18 principle, not 22/10/18, so the rule the + earlier draft believed reproduced the split does not do so. Recovering + 22/10/18 needs a rule that special-cases the two rename rows + (`win_splitdrive` and `fileglob`) out of *exists* while leaving `now` — also + a reject row naming an existing helper in backticked call form — inside it, + and that is curve-fitting, not derivation. RFC 0006 states no rule assigning + a reject row to a class, and section 10 groups only some of them. Accordingly + `COV-2` asserts the totals that *are* directly parseable — 55 accept rows, 6 + defer rows, 50 reject rows, 111 surveyed entries — and asserts only that + table 11's three class counts **sum** to the derived reject-row count. That + is a real consistency check on the table without asserting an undecipherable + split. Date/Author: 2026-09-11, implementation agent, chosen by the reviewer + from three options; the class-split retraction was added by the same author + the same day, after `EP-M1` falsified the rule. + +## Alternatives considered + +The first draft had no such section. Two alternatives are live at the approval +gate. + +**Stop after `EP-M1`.** The coverage test, `ADR-040`, the RFC 0006 defect +corrections, and the roadmap rewrite together solve the mechanical half of the +problem — the bijection nobody can check by reading — for roughly a tenth of +the cost and none of the irreversibility. No RFC number is spent. The +per-helper obligations would remain underived, which is the other half of the +value, but they could later be added to RFC 0006 section 8 in place as a +registry table per group, at roughly 140 lines rather than roughly 1800. If the +reviewer judges eight child RFCs disproportionate, this is the fallback, and +`EP-M0` and `EP-M3` are positioned so it can still be taken. + +**Six children rather than eight.** RFC 0020 owns two helpers and RFC 0018 owns +one filter plus four tests and an option. Merging date and time into encoding +and formatting, and the version predicate into collection algebra, would give +six children with no group under about 120 lines of section 8 content. The cost +is two broken one-to-one step mappings, which weakens the success criterion's +phrasing. Cheaper, but not recommended. + +Rejected: aligning to section 14's ten delivery slices, for the purity-seam +reason in `D1`. Rejected: unnumbered per-group design documents on the pattern +of roadmap step 6.11 — genuinely cheaper and fully reversible, but the +commissioned task asks for RFCs, and a normative contract discharge belongs in +the RFC review process. + +## Outcomes & retrospective + +To be completed at `EP-M11`. Before marking `COMPLETE`, reconcile every +discovery against RFC 0006, `docs/roadmap.md`, and the style guide, and confirm +no disposition changed. + +## Context and orientation + +Netsuke reads a YAML manifest called a `Netsukefile`, expands Jinja templates +in it with MiniJinja, and generates a Ninja build file. The "template standard +library" is the set of filters, tests, and functions Netsuke registers with +MiniJinja. + +Terms, defined once: + +- **Filter**: invoked as `value | name(args)`. **Test**: invoked as + `value is name(args)`. **Function**: invoked as `name(args)`. Jinja keeps the + three in separate namespaces. +- **Manifest query**: the read-only environment serving `netsuke help targets`. + A helper that reads the clock, environment, filesystem, network, or a + subprocess is registered there only as a failing stub. +- **Purity class**: RFC 0006 section 6.1's label — pure, clock-observing, + environment-observing, filesystem-observing, network-observing, or + subprocess-observing. +- **Canonical key**: the RFC 8785 canonical JSON form of a value, giving a + deterministic equality relation so no helper's output order comes from a hash + table. RFC 0006 section 6.7. +- **Survey RFC**: an RFC that enumerates a large candidate set with a recorded + disposition per candidate. RFC 0006 is the only one. +- **Registry**: the five-column table at section 5.1 of each child RFC. + +Files that matter: + +- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` — 2132 lines, + status `Proposed`. Section 6 is the eleven-clause contract; section 7 is the + disposition matrix and the derivation source; section 8 is the per-helper + contracts, which stay put; section 14 becomes the coverage map. +- `docs/roadmap.md` — phase 6 spans lines 715 to 1267. Step 6.1 is shared + machinery; 6.2 to 6.9 are the capability groups; 6.10 is deferred; 6.11 is + Git change detection. +- `docs/contents.md` — the `## Requests for comments` section at lines 35 to 82 + is the only RFC index. Note how RFCs 0009 to 0011 are described there, as a + normative amendment adding something to RFC 0001; child RFCs need an + analogous phrasing. +- `docs/documentation-style-guide.md:222-360` — RFC naming, required and + conditional sections, formatting guidance, and a literal template. +- `tests/documentation_installation_tests.rs` — the precedent for splitting a + test binary across a sibling module. + `tests/integration_test_wiring_tests.rs` — the contract governing how the new + binary must be wired; note at lines 101 to 146 that a sibling module tree + must be explicitly declared or it is flagged orphaned, and that the + path-attribute form requires the module name to match the directory name. + +For wider architecture see `docs/netsuke-design.md`. For the testing idioms the +child RFCs impose on their implementers, see +`docs/rust-testing-with-rstest-fixtures.md`, `docs/rstest-bdd-users-guide.md`, +`docs/reliable-testing-in-rust-via-dependency-injection.md`, +`docs/rust-doctest-dry-guide.md`, and +`docs/snapshot-testing-in-netsuke-using-insta.md`. When writing RFC 0018, load +the `hexagonal-architecture` skill: `expandvars` and the filesystem predicates +are this plan's only ports, and the boundary to preserve is between pure domain +policy — path lexing, purity classification — and the injected adapters, the +`cap_std` workspace handle and the environment reader, that RFC 0006 section +6.4 and [ADR-008](../adr-008-environment-seam-taxonomy.md) mandate. Route Rust +questions for the coverage test through `rust-router`, which points at +`rust-unit-testing`. Use `scrutineer` for gate runs and `scribe` for the +`docs/contents.md` entries and the roadmap citation retargeting; do not +delegate any section 5, which is new normative content. + +### The partition + +Every later milestone is bookkeeping against this table. The ownership +qualifiers are exact: the first draft wrote unqualified group numbers and +thereby double-assigned seven helpers. + +| Child RFC | Title | Owns | Step | +| --------- | ----------------------------------------------- | ------------------------------------ | ---- | +| 0013 | Structured data interchange helpers | §8.1 | 6.2 | +| 0014 | Mapping and sequence transform helpers | §8.2 | 6.3 | +| 0015 | Ordered collection algebra and truth predicates | §8.3, §8.8 | 6.4 | +| 0016 | Pattern and version predicates | §8.4, §8.5 | 6.5 | +| 0017 | Lexical path composition | §8.6 except `expandvars`; §8.7 `abs` | 6.6 | +| 0018 | Host-state predicates and environment expansion | §8.7 except `abs`; §8.6 `expandvars` | 6.7 | +| 0019 | Encoding, identity, and formatting helpers | §8.9 | 6.8 | +| 0020 | Date and time conversion helpers | §8.10 | 6.9 | + +*Table 1: Child RFC allocation against RFC 0006 groups and roadmap steps.* + +The two divisions are the roadmap's own and are affirmatively supported by RFC +0006. Section 8.7 calls `abs` pure and lexical and says that because it is +pure, it is available during manifest queries unlike the other four tests in +its group; roadmap task 6.6.4 places it in step 6.6 for that reason. Section +14.7 separates `expandvars` from the lexical helpers because it is the only +environment-observing helper in the RFC and therefore the only one needing an +injected reader, a manifest-query stub, and its own capability review. Together +the cuts make RFC 0017 uniformly pure and RFC 0018 the only child with non-pure +helpers. + +The registry contents, which are the coverage test's expected ownership: + +- RFC 0013, five filters: `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`, + `to_nice_json`. +- RFC 0014, six filters: `combine`, `dict2items`, `items2dict`, `extract`, + `subelements`, `rekey_on_member`. +- RFC 0015, eight filters — `union`, `intersect`, `difference`, + `symmetric_difference`, `product`, `combinations`, `permutations`, + `zip_longest` — and seven tests: `any`, `all`, `subset`, `superset`, + `contains`, `truthy`, `falsy`. +- RFC 0016, four filters — `regex_replace`, `regex_search`, `regex_findall`, + `regex_escape` — and four tests: `match`, `search`, `regex`, `version`. +- RFC 0017, six filters — `path_join`, `normpath`, `splitext`, `commonpath`, + `relpath`, `splitdrive` — one test, `abs`, and the optioned existing filters + `basename` and `dirname`. +- RFC 0018, one filter, `expandvars`; four tests — `exists`, `link_exists`, + `same_file`, `mount` — and the optioned existing function `glob`. +- RFC 0019, nine filters: `b64encode`, `b64decode`, `urldecode`, `to_uuid`, + `shell_quote`, `comment`, `human_readable`, `human_to_bytes`, `text_hash`. +- RFC 0020, two filters: `to_datetime`, `strftime`. + +That is 41 new filters, 16 new tests, and 3 optioned existing helpers, matching +table 11. Aggregating the purity columns must yield 52 pure, 4 +filesystem-observing, and 1 environment-observing, matching section 6.1 — an +independent cross-check the coverage test performs as `COV-3`. + +RFC 0006's section 16 open questions distribute as follows. Question 1 on +`to_nice_yaml` and question 4 on alias bounding go to RFC 0013; question 3 on a +version prefix goes to RFC 0016; question 2 on the `abs` test name goes to RFC +0017; question 6 on a truncating hash sibling goes to RFC 0019. Questions 5 and +7 belong to no child: question 5 concerns the shared bounds of step 6.1, and +question 7, on an injected clock, is owned by roadmap task 7.1.1, "Add the +clock provider seam to the stdlib time module", on a separate reserved branch. + +**Six of the seven remain open, and question 7 does not.** This split does not +close any of them — that is the point of carrying each into its owning child, +unresolved — but question 7 was resolved independently of this task, by the +merge of task 7.1.1 in `96aefc9c`, which is this branch's base. RFC 0006 +section 16 item 7 already reads "Resolved", recording that `now()` reads +through a `ClockProvider` held by `StdlibConfig` and classified in the +[ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11, and +that RFC 0020 neither needs the seam nor depends on 7.1.1. Reading the section +16 list as seven open questions would therefore contradict the document itself, +so it is recorded here as six. `EP-M1` still annotates question 7 so a phase-6 +implementer does not adopt it by accident; the annotation is now redundant with +the section's own text rather than the only pointer to it. + +### The child RFC template + +The template is committed to +[ADR-040](../adr-040-focused-child-rfcs-for-survey-rfcs.md), under "The child +RFC template", together with the registry row shape and each column's accepted +vocabulary. It is not restated here: two copies of a parsed artefact drift, and +the copy a child is written from must be the copy the test reads. `EP-M2` moved +it there from this plan for exactly that reason. The template retains +`## Current state` and `## Alternatives considered` as conditional-but-expected +for RFCs 0017 and 0018, which must contrast `relpath` against the existing +`relative_to` and `splitext` against `with_suffix`, and must carry RFC 0006 +section 15.5's analysis of the rejected Windows-specific filter family. + +## Conformance basis + +`docs/terms-of-reference.md` exists, and two of its parts bear on this split +directly. Goal **G4** ("Deterministic plans") and goal **G6** ("Visible +impurity") are the parent documents' statement of what RFC 0006 section 6 turns +into a per-helper contract, and hard constraint 8.1's "project configuration +cannot grant itself authority the operator has not granted" (ADR-021, ADR-026) +is the rule clause 6.4 discharges as a capability boundary. No goal or +constraint contradicts the split; the split's purpose is to make those +obligations dischargeable per helper rather than in one survey document. + +Upstream artefacts: + +- `docs/terms-of-reference.md` at `origin/main` commit `96b89ca9`, goals G4 and + G6, and hard constraint 8.1. The pin differs from the one below because the + document postdates `924cb215`: it arrived on `main` in `96b89ca9` (PR #786), + and is unchanged on this branch. + +- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` at + `origin/main` commit `924cb215`, status `Proposed`. Sections 6, 7, 7.8, 8, 9, + 10, 13.4, 14, and 16 are load-bearing. +- `docs/roadmap.md` at the same commit, phase 6, task 6.1.1 and steps 6.2 + to 6.9. +- `docs/documentation-style-guide.md:222-360`. +- [ADR-008](../adr-008-environment-seam-taxonomy.md) governs the `expandvars` + seam RFC 0018 discharges; + [ADR-010](../adr-010-scope-glob-capability-to-literal-prefix.md) governs the + `glob` option; and + [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md) governs the YAML + stack RFC 0013 discharges. +- `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md`, created at `EP-M1`. + +Trace links, one per obligation. `EP-M1` is the milestone that lands the check; +the tests are named without their `netsuke-build::rfc_stdlib_coverage_tests::` +prefix, which is the same for all of them and which would push every line past +120 columns: + +```text +RFC0006-S7 -> ROADMAP-6.1.1 -> EP-M1 -> COV-1 -> every_accepted_helper_has_exactly_one_owner +RFC0006-S9 -> ROADMAP-6.1.1 -> EP-M1 -> COV-2 -> no_forbidden_helper_is_registered +RFC0006-S6.1 -> ROADMAP-6.1.1 -> EP-M1 -> COV-3 -> totals_and_purity_aggregate_agree +RFC0006-S14 -> ROADMAP-6.1.1 -> EP-M1 -> COV-4 -> coverage_map_status_is_reported +ROADMAP-6.2..6.9 -> ROADMAP-6.1.1 -> EP-M1 -> COV-6 -> every_capability_has_a_roadmap_task +RFC0006-S6 -> ROADMAP-6.1.1 -> EP-M3..EP-M10 -> CONF-1 -> every_child_discharges_every_clause +ADR-040 -> EP-M1 -> docs/adr-040-focused-child-rfcs-for-survey-rfcs.md +``` + +## Verification plan + +### Review remediation local verification, 2026-10-01 + +The successful integrated candidate had starting HEAD `1c35b1d0` and a complete +tracked-and-untracked source fingerprint of +`7058af8061276b28875ba959e1e335c8626ebc2f37c73fec2d05378637e9510d`. One +scrutineer ran the gates sequentially, with logs under `/tmp`. Conflicting +`FORCE_COLOR` was removed from the runner environment, retaining `NO_COLOR`, to +avoid the tools' warning about both settings being present. + +| Command | Result | +| ------------------------------- | ---------------------------------------------------------------------- | +| `make fmt` | Passed. | +| `make test-rfc-stdlib-coverage` | 272 passed. | +| `make test-workflow-contracts` | 986 passed, 3 skipped. | +| `make check-fmt` | Passed. | +| `make lint` | Rustdoc, Clippy, Whitaker, Python and Actions checks passed. | +| `make typecheck` | Rust and Python checks passed. | +| `make markdownlint` | Spelling passed; 170 files, zero Markdown errors. | +| `make doc-coverage` | 98.83%, above the 80% threshold. | +| `make nixie` | Passed. | +| `make test` | 3748 Nextest tests passed, 6 skipped; 128 doctests passed, 32 ignored. | + +The full Rust run printed +`coverage map: 1 of 8 capability groups written; 7 remaining`. The coverage +binary's 272 passes include its seven repository checks and direct fixtures and +properties. This evidence does not complete the seven unwritten child RFCs or +establish semantic adequacy of their prose. The final edit recording these +results receives its own formatting, Markdown, focused coverage, and +ExecPlan-status checks before commit. CI and reviewer confirmation are separate +publication evidence. + +### Post-commit complexity refactor verification, 2026-10-01 + +The separate refactor candidate is based on `e88d0d1a`; its complete source +fingerprint after formatting is +`6ebddad1369efe49bfc1eed8fde67414b5d303bf3cd7f30ebcbafff4cf8d46a6`. The two +targeted `cs review` checks analysed the working files, not the immutable base +commit. Both `links_tests.rs` and `nextest_success_output.py` scored 10.0 with +an empty findings list. No rule was suppressed. + +One scrutineer repeated the preceding table's complete gate set sequentially. +The focused suite passed 272 tests, workflow contracts passed 986 with 3 +skipped, and the full Rust suite passed 3748 tests with 6 skipped and 128 +doctests with 32 ignored. Doc-comment coverage remained 98.83%. The final +ExecPlan evidence edit receives the same focused documentation checks before +commit. Hosted CodeScene and CI results remain separate evidence for the +published head; CodeRabbit confirmation at the base does not substitute for +confirmation of these new fixes. + +### Review validation branch-to-test inventory, 2026-10-01 + +The direct tests live beside the private implementation through test-only +sibling modules. Each invalid fixture changes one condition and checks the +existing diagnostic, including file and absolute line when the reader supplies +that context. The seven repository checks retain their public names. + +| Implementation | Direct tests and guarded branches | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `document.rs` | `document_tests.rs`: section boundaries, fence opacity, absolute lines, table/header/separator recognition, missing cells. | +| `markdown.rs` | `markdown_property_tests.rs`: independent opening and closing predicates; delimiter, run, information, indentation, tabs, and later structure. Existing heading examples remain. | +| `totals.rs` | `totals_tests.rs`: complete counts, missing/duplicate/ambiguous/non-numeric tally, missing count cell, purity evidence and wrapping. | +| `section7.rs`, `inventory.rs` | `section7_tests.rs`: disposition and citation errors, narrow cells, namespace conflicts, rename/option witnesses, every table descriptor, ignored tables. | +| `section8.rs` | `section8_tests.rs`: missing contract or citation, whole-token matching, exact section number, fenced boundaries. | +| `survey.rs` | `survey_tests.rs`: isolated derivation, missing file/section, propagated failures, deny complement and namespace totals. | +| `map.rs` | `map_tests.rs`: numbers, status and link disagreement, duplicate reservations/claims, ownership grammar and unknown references; independent set-operation properties. | +| `registries.rs` | `registries_tests.rs`: missing data, filename and cell vocabularies, duplicate names, purity/query disagreement, reserved corpus selection and counts. | +| `clauses.rs`, `deference.rs` | `clauses_tests.rs` and adjacent deference cases: malformed/duplicate IDs, empty bodies/cells, owned-helper evidence, explicit escape, Ansible deference and word boundaries. | +| `roadmap.rs` | `roadmap_tests.rs`: missing/invalid steps, scheduled helpers, task headings, fence opacity and complete step order. | +| `links.rs` | `links_tests.rs`: relative targets and source lines, missing files, above-root traversal; independent bounded traversal model, fragments and dot/empty segments. | +| `partition.rs` | `partition_tests.rs`: missing/extra ownership, registry number/name/namespace/registration mismatches, denied registration. | +| `assertions.rs` | `assertions_tests.rs`: every aggregate mismatch, deny count and required/forbidden witnesses. | +| `progress.rs` | `progress_tests.rs`: partial upper bounds, complete helper/purity/option equalities, forbidden purity, status, schedule and discharge consistency. | +| `mod.rs` | `repo_tests.rs`: isolated capability reads/listing, missing/non-UTF-8 documents, fixture open context, token and alias readers. | +| Nextest contracts | `nextest_success_output_test.py`: exact override and declared name, valid fixture, ten single-condition mutations and mutation completeness. | + +Not every defensive return is input-reachable. Immutable offsets found in the +same section cannot subsequently exceed its bounds; a tally lookup cannot +vanish after its single-hit check; and ownership `only`/`except` token access +cannot fail after the two-token arity check. These branches remain intact. The +tests exercise the reachable rejection that precedes each defensive return +rather than changing visibility or inventing an impossible fixture. Parsed +Markdown rows always have a first cell, so the generic missing-cell accessor is +tested directly while later missing columns are tested through readers. No +claim of semantic proof or exhaustive random-input coverage is made: bounded +properties supplement the explicit diagnostic cases. + +This change adds no runtime behaviour, so there is no invariant over program +state. It introduces a combinatorial invariant over documents — a bijection +between the accepted helper set and its owning child RFCs — which is statable +and mechanically checkable, and a semantic obligation about the adequacy of the +prose discharges, which is not. + +The plan is explicit about that asymmetry, because the first draft was not. The +mechanical obligations cover the coverage bijection. The substantive product of +this task is section 5's prose, and no test can establish that it says +anything. Three controls bound the gap: `D6` cuts the clause count and states +an anti-vacuity rule a reviewer applies in one pass; `CONF-1` mechanically +rejects the three commonest vacuity shapes; and `EP-M3` is a hard go/no-go on +the first completed child. The pull request must not claim more. + +Method selection: table-driven Rust tests enumerate the fixed document +inventory and explicit rejection cases. Bounded property tests independently +model the general fence, path, and ownership parsers, whose input domains are +not that finite inventory. Both forms run in the same focused test binary. + +**Parser contract, common to all obligations.** Helper inventories come from +Markdown table rows rather than helper headings. Structural headings delimit +the named sections: RFC 0006's section 7 subtables and its section 14 coverage +map, and each child's section 5.1 registry. It splits on the cell separator, +trims, and strips one pair of backticks. It matches names by whole-token +equality, never substring — the vocabulary contains `abs` against `is_abs`, +`quote` against `shell_quote`, `hash` against `text_hash`, and `subset` against +`issubset`, and substring matching would be wrong on all four. It records file +and line for every parsed row so failures name a location. + +### Obligation `COV-1`: exactly one designated owner + +- Obligation: every helper in the accepted set derived from RFC 0006 section 7 + appears in the section 5.1 registry of exactly one child RFC, and that RFC is + the one the section 14 coverage map designates. +- Method: set comparison across three independently sourced signals — the + accepted set from section 7, the expected owner from the coverage map, and + the actual owner from the child registries. +- Rationale: three signals from two documents, none hardcoded. A count-only + check would pass when a helper is assigned to the wrong child, leaving the + plan's most contestable decisions unguarded. +- Domain: 57 new helpers plus 3 optioned existing helpers, against RFC 0006 and + RFCs 0013 to 0020. +- Artefact: `tests/rfc_stdlib_coverage_tests.rs` and + `tests/rfc_stdlib_coverage/`. +- Evidence: `cargo nextest run --test rfc_stdlib_coverage_tests`. Green at + `EP-M1` with every coverage-map row marked not yet written, and green at + every plateau thereafter as rows flip to a written child. +- Non-vacuity: four seeded faults, each applied to a scratch copy and reverted, + with transcripts recorded. Delete the `combine` row from RFC 0014's registry + and expect a zero-owner failure naming `combine` and the file. Add `combine` + to RFC 0015's registry as well and expect a two-owner failure naming both. + **Move `combine` wholesale from RFC 0014 to RFC 0015** and expect a + wrong-owner failure — the control the first draft lacked, without which the + partition is untested. Corrupt one section 7 accept row and expect the + derived set to shrink and the failure to name the row. The test also asserts + the derived accepted set has exactly 60 members, so a parser returning + nothing cannot pass vacuously. Discharged at `EP-M1` for the row-corruption + control, and only for it: the three registry controls run at `EP-M4` and + `EP-M5`. Transcripts are in `Control transcripts`. + +### Obligation `COV-2`: no forbidden candidate is registered + +- Obligation: every name in any child RFC's registry is a member of the derived + accepted set. Equivalently, no name that RFC 0006 section 7 rejects under any + disposition, or that section 9 defers, appears in any child RFC's registry. +- Method: derive the accepted set from section 7's accept rows, then the deny + set as the **complement** — every surveyed reject or defer name that is not + the registered Netsuke name of an accepted helper. See `D10`. +- Rationale: a hand-maintained list is not a sound contract when the source of + truth is a tracked file in the same repository. Deriving means the set + tightens automatically when section 7 gains a row. Stating the check as a + complement rather than as a union of reject classes removes the need to parse + the resolution-note prose, which `D10` records as too fragile to be a + contract; it also makes the deny set a superset of any class-based reading, + so no name either reading forbids can slip through. +- Domain: the derived forbidden set — 71 names, from 67 reject names and 6 + deferred names, less `basename` and `dirname`, which are `Reject` rows whose + surveyed name is the registered Netsuke name of an accepted helper. +- Artefact: as above. +- Evidence: same command. Green from `EP-M1`, which is exactly why the controls + below are compulsory rather than optional. +- Non-vacuity: this obligation is green before any child exists, so a pass + proves nothing on its own. Add a registry row for `shuffle` to a scratch copy + of RFC 0015 and expect a failure naming `shuffle` and the file. Add `is_dir` + and expect a failure naming the rejected alias. Assert the derived deny set + has exactly 71 members and contains `is_file`, `is_dir`, `is_link`, `quote`, + `fileglob`, `lookup`, `win_dirname`, and `expanduser`. The first four are the + names the first draft's handwritten list omitted; `is_dir`, `is_link`, and + `win_dirname` are aliases the plan's own risk names; and `expanduser` is the + name the first draft expected the deny set to **exclude**, so asserting it is + present is the direct test of `D10`'s complement rule against the class-based + reading it replaced. Assert it does **not** contain `basename`, `dirname`, + `abs`, `glob`, `shell_quote`, or `splitdrive`, which are accepted helpers; + without this assertion the complement's exclusion step is untested, and a bug + that denied the very helpers the registries must carry would pass. +- Note: the reject-class split of table 11 — 22 already-provides, 10 redundant + alias, 18 on principle — is **not** what this obligation checks, because the + complement does not need it. Nor does `COV-2` assert the split itself: RFC + 0006 states no rule assigning a reject row to one of the three classes, and + `EP-M1` falsified the rule the earlier draft believed recovered 22/10/18 (it + yields 8/24/18). `COV-2` therefore asserts only that table 11's three class + counts **sum** to the derived reject-row count, which catches an edit that + breaks the table's arithmetic without claiming a derivation it does not have. + The totals that are directly parseable — 55 accept rows, 6 defer rows, 50 + reject rows — are asserted exactly. + +### Obligation `COV-3`: totals and purity aggregate agree + +- Obligation: the derived accepted set contains 41 filters, 16 tests, and 3 + optioned helpers, matching the totals parsed from table 11; and the + registries' purity columns, taken over rows whose registration is `New`, + aggregate to 52 pure, 4 filesystem-observing, and 1 environment-observing, + matching section 6.1. +- Method: parsed count against parsed count. +- Rationale: genuinely independent, unlike the first draft's version, which + compared a hardcoded inventory against a hardcoded literal transcribed from + the same table in the same sitting. Here the counts come from the child + registries and the expectations from RFC 0006, so neither can be adjusted to + match the other without editing a normative document. The purity aggregate is + the only check reconciling section 6.1 against the registries; without it a + wrong purity class rots silently. +- Scoping note: the aggregate is taken over `New` rows only, because section + 6.1's 52/4/1 counts the 57 **proposed** helpers. The registries carry all 60 + accepted helpers, and the three optioned rows include `glob`, which is + filesystem-observing; aggregating every row would yield 54 pure, 5 + filesystem-observing, and 1 environment-observing and fail against a correct + document. `EP-M0` found this; see `Surprises & discoveries`. +- Domain: three totals and three purity counts. +- Artefact: as above. +- Evidence: same command. The purity half is necessarily partial until every + child exists, and it is now partial in two forms rather than being deferred + whole. An upper bound on each purity class and on the optioned count runs at + every prefix of the split, and the exact equality runs only when the coverage + map has no unwritten row. The bound is sound because section 6.1's three + counts are a budget: a half-written split can spend too much of it but never + too little. The classes section 6.1 forbids outright — clock-observing, + network-observing, and subprocess-observing — are asserted as hard zeros at + every prefix, because "no proposed helper is" is not a budget. +- Non-vacuity: change one registry row's purity class from pure to + filesystem-observing and expect a failure reporting 5 filesystem-observing + where section 6.1 states 4 in total; observed at `EP-M2`'s second review, + firing with one of eight groups written. Change a helper's namespace and + expect the filter and test totals to fail. Introduce a purity class not among + table 2's six values and expect a vocabulary failure. Set one row to a + forbidden class *consistently* — the class and its manifest-query cell + changed together — because changing the class alone is caught first by + `check_manifest_query`, which is a different and narrower rule; the new + aggregate check is only reachable on a row that is internally coherent. + +### Obligation `COV-4`: the coverage map reports progress honestly + +- Obligation: the section 14 coverage map has one row per capability group with + an explicit owner and status, and the test reports in its passing output how + many groups remain unwritten. +- Method: parse and report. +- Rationale: a split that stalls half-finished satisfies every other obligation + — the abandoned state and the finished state are indistinguishable to a + bijection check. This makes a stall announce itself on every test run rather + than waiting for someone to notice. +- Artefact: as above. +- Evidence: passing output includes a line naming the unwritten count. `EP-M11` + asserts it is zero. +- Non-vacuity: at `EP-M1` the reported count must be 8, not 0. A count of 0 + before any child exists means the map is not being read. Observed at `EP-M1`: + `coverage map: 0 of 8 capability groups written; 8 remaining`, printed on a + passing run because `.config/nextest.toml` raises this test's + `success-output` to `immediate`. Discharged further at `EP-M1` by removing + the map table itself, which fails six of the seven checks with "the coverage + map subsection contains no table"; both transcripts are in + `Control transcripts`. + +### Obligation `COV-5`: inter-document links resolve + +- Obligation: every relative Markdown link from a file in `docs/rfcs/` to + another repository path resolves to an existing file. +- Method: path existence check over parsed links. +- Rationale: this plan adds eight documents, eight coverage-map links, and + eight `docs/contents.md` entries, and retargets 39 roadmap citations. + `markdownlint-cli2` is configured with no link validation and there is no + link checker in the repository, so a stale link is caught by nothing. The + parser already reads every file in `docs/rfcs/`, so this is nearly free. +- Artefact: as above. +- Evidence: same command. +- Non-vacuity: point a scratch copy's link at a non-existent file and expect a + failure naming the source line and the missing target. Discharged at `EP-M1`; + the observed message is in `Control transcripts`. + +### Obligation `COV-6`: every capability has an accompanying roadmap task + +- Obligation: every helper in the derived accepted set is named, in a backticked + span, by at least one task bullet under the roadmap phase-6 step that owns + its child RFC. +- Method: parse phase-6 step and task bullets from `docs/roadmap.md`, then check + membership per helper against its owning step. +- Rationale: this is the second half of the amended success criterion, and + without it only the RFC half is checked. It also guards the thing the + reviewer asked for — that work be tracked in committed documentation — + because it fails if a capability is given an RFC but no roadmap task to + deliver it. Verified before writing this obligation: all 60 helpers are + already named under their owning step, so `COV-6` is green from `EP-M1` on + the current roadmap and stays green unless a task is deleted or a helper is + reassigned across steps. +- Domain: 57 new helpers plus 3 optioned existing helpers, against roadmap steps + 6.2 to 6.9. +- Artefact: `tests/rfc_stdlib_coverage_tests.rs` and + `tests/rfc_stdlib_coverage/`. +- Evidence: same command as the other obligations. +- Non-vacuity: green from the start, so a pass proves nothing on its own and a + control is compulsory. Delete the `zip_longest` task bullet from a scratch + copy of step 6.4 and expect a failure naming `zip_longest` and step 6.4. Move + the `expandvars` bullet from step 6.7 to step 6.6 and expect a wrong-step + failure, since RFC 0018 owns it. Assert the parsed task-bullet corpus is + non-empty and that phase 6 yields exactly eight owning steps, so a parser + that matches nothing cannot pass. Both roadmap controls are discharged at + `EP-M1`, with transcripts in `Control transcripts`; the deleted-task message + now names the owning step as well as the helper. +- Note the asymmetry with `COV-1`, and that it is deliberate: `COV-1` requires + exactly one owning RFC, whereas `COV-6` requires at least one task, because + `product` is legitimately named by both 6.4.2 and 6.4.5. + +### Obligation `CONF-1`: every child discharges every clause + +- Obligation: each child RFC contains one section 5 subsection per clause of + RFC 0006 section 6; each subsection's body is non-empty and either names at + least one helper that RFC owns or contains the exact escape phrase from `D6`; + and no subsection contains an Ansible-deference phrase. +- Method: structural and lexical checks, plus reviewer judgement for the rest. +- Rationale: the mechanical half catches the three commonest vacuity shapes — + the empty stub, the generic discharge naming no helper, and the appeal to + Ansible. The last is literally this plan's own acceptance criterion and is a + substring search, so leaving it to review would be indefensible. The clause + list is derived by parsing section 6's subsection headings rather than + hardcoding eleven, so a future section 6.12 does not break eight documents. + Subsections are located by position under section 5 and by title match, not + by literal numerals, because two independent formatters in this repository + are empowered to renumber. +- Domain: RFCs 0013 to 0020 against section 6's clause list. +- Artefact: as above. +- Evidence: same command, scoped to children that exist. Also checked: the two + id sets must be equal, so a section 5 subsection with no discharge-table row + — or a row with no subsection — fails even though each is well-formed alone. +- **Amendment (2026-09-24): the mechanical half was implemented at `EP-M2`, six + days after it was specified, and until then only the table half existed.** + The obligation above was written as prose and the code compared id sets, so + the empty stub, the generic discharge, and the deference appeal were + unenforced; every non-vacuity control below would have passed, because none + was implemented either. See `Surprises & discoveries` for how it was found + and why a green suite one obligation short of its plan is indistinguishable + from a complete one. +- Non-vacuity: all four controls were run at `EP-M2`'s second review, against a + probe child RFC mounted from the `ADR-040` worked specimen with the coverage + map's row `0013` flipped to written. Deleting subsection 5.8's body failed + with "is subsection 5.8. Resource bounds of section 5 with an empty body". + Replacing 5.7's body with "This group meets the clause by construction" + failed as a generic discharge, naming the subsection and quoting the `D6` + escape phrase it did not carry. Inserting "as Ansible does" failed with + "justifies a helper by appealing to Ansible". A duplicated 5.7 heading and a + duplicated `6.7` table row each failed as a second subsection or row for that + clause. The probe is the `EP-M3` rehearsal as well: with the specimen mounted + and the map row flipped, all ten tests passed, which is the state `EP-M3` + must reach. +- **The specimen itself failed this check, and the check is right.** Subsection + 5.9 of the `ADR-040` worked section named thirteen diagnostic codes and no + helper. It was corrected by naming all five helpers alongside the codes, not + by weakening the check; a rule written before the document it grades is the + only version that can surprise its author, which is why the two were read + against each other here rather than at `EP-M3`. +- **Residual gap.** Whether section 5.7 genuinely discharges canonical equality + for `subset`, as opposed to restating clause 6.7, is a judgement no test + makes. This is the substantive product of the task and it rests on review, + bounded by `D6`'s anti-vacuity rule and by the `EP-M3` go/no-go. Do not claim + otherwise in the pull request. +- **Amendment (2026-09-19).** The discharge is checked as a two-column + `Clause | Discharge` table under a `### Clause discharge` subsection, which + closes section 5 after the eleven clause subsections. It was first specified + as `### 5.6. Clause discharge`, which CodeRabbit showed collides with the + template's own fourth bullet: 5.6 is "Type and error contract", the title RFC + 0006 clause 6.6 carries, so a table row reading `6.6` sat under a heading + reading "Type and error contract" and implied the two were the same thing. + Two readings were available — keep the template's 5.6 title and move the + table elsewhere, or keep the table and duplicate the id — and the first is + smaller and contradicts nothing already written, so the subsection lost its + number rather than gaining a rival. `CLAUSES_HEADING` and the new + `TABLE_HEADING` follow it, matching on the heading directly above the table + rather than on the subsection's first table, so a table placed under one of + the eleven clause subsections cannot be mistaken for the discharge. The + `EP-M2` worked example fixes the exact placement before any child RFC is + written from it, which is why this is settled now: eight documents would + otherwise inherit the ambiguity. Restating the eleven clause titles under + section 5 is the third option, and it is rejected — `D6` already argues that + literal restatement across eight documents is a vacuity generator, and the + ids, not the titles, are what the table matches on. + +### Control transcripts (2026-09-11) + +`/tmp/rfc-coverage-controls.sh` (scratch, not tracked) seeds one fault at a +time into the working tree, runs `cargo test --test rfc_stdlib_coverage_tests`, +prints the seeded diff and the failure, and restores both documents from a +backup taken before the first control. It deliberately does not use +`git checkout --`, which would discard the uncommitted milestone under test. It +fingerprints the two documents before and after and aborts on a partial +restore; the run finished with the same two hashes it started with, and the +baseline after the last revert was 7 passed. + +Six controls are runnable at `EP-M1`, before any child RFC exists. Each was +run, and each failed for its own reason. The quoted messages are wrapped for +width; nextest wraps them the same way at a terminal. + +- `COV-4`, red state. The section 14.13 coverage map table is deleted, leaving + its prose and caption behind. This is the red half of the red-green evidence + the Validation and acceptance section asks for, and it is what shows the + reported count is read from the table rather than defaulted to zero: + + ```text + Error: the coverage map subsection contains no table + ``` + + All six dependents fail on it. The line is also why `map::parse` insists on + eight rows rather than accepting an empty table: a map that parses to nothing + would otherwise satisfy "no unwritten group remains" vacuously. +- `COV-1`, corrupted accept row. RFC 0006:469, the `from_json` row, loses its + `§8.1` citation, leaving the resolution cell reading "see section 8". All six + dependent checks fail, naming the file and the line: + + ```text + Error: accept row at docs/rfcs/0006-ansible-inspired-template-standard-library.md:469 + cites no section 8 subsection + ``` + + The three registry controls this obligation also specifies — delete, + duplicate, and move the `combine` row — need a registry to corrupt and are + deferred to `EP-M4` and `EP-M5`. +- `COV-5`, dangling link. The `ADR-040` link in section 14.13 is repointed at a + file that does not exist: + + ```text + Error: dangling inter-document links: + ["docs/rfcs/0006-ansible-inspired-template-standard-library.md:2055 links to + ../adr-021-no-such-file.md which resolves to + docs/adr-021-no-such-file.md, and no such file exists"] + ``` + +- `COV-6`, deleted task. Roadmap line 935, the `zip_longest` bullet under step + 6.4.3, is deleted: + + ```text + Error: accepted helpers ["zip_longest (RFC 0015, step 6.4)"] are named in no + roadmap capability step at all, so nothing schedules them + ``` + + The owning step in that message was added at `EP-M1` in response to this + control: naming only the helper left the reader to work out where it belonged. +- `COV-6`, wrong step. The `expandvars` bullet moves from step 6.7 to 6.6: + + ```text + Error: the coverage map gives RFC 0018 roadmap step 6.7, but that step names + none of ["expandvars"] + ``` + +- Fence blindness, run separately by `/tmp/rfc-fence-control.sh`. A fenced + `text` block carrying a `# not a heading` line and a `{{ a | b }}` example is + inserted directly after the section 8.9 heading in RFC 0006. Before the fix + this failed **six of the seven checks**, and the diagnostic misattributed the + cause: the fenced `#` line read as a depth-1 heading, truncated section 8.9 + at the fence, and left its helpers looking unspecified: + + ```text + test every_child_discharges_every_clause ... FAILED + test every_capability_has_a_roadmap_task ... FAILED + test coverage_map_status_is_reported ... FAILED + test every_accepted_helper_has_exactly_one_owner ... FAILED + test totals_and_purity_aggregate_agree ... FAILED + test no_forbidden_helper_is_registered ... FAILED + Error: accepted helpers ["strftime", "to_datetime"] name no RFC 0006 section 8 + subsection, so they have no contract to implement + ``` + + The two named helpers are exactly those section 8.10 specifies; nothing in + the message points at the code block that actually broke the parse. This is + the most dangerous control in the set, because the fault is one a child RFC + will legitimately contain: every child's section 5 carries example fences. + After the fix the same seeded fault leaves the suite at 7 passed. The control + restores the document from a backup and verifies its sha256 before and after. + +`COV-2`, `COV-3`, and `CONF-1` are green before any child exists, so their +controls need something to corrupt — a registry row and a clause body. The +first milestone that supplies both is `EP-M3`, not `EP-M4` as this section +first said: `EP-M3` delivers RFC 0013, which owns five registry rows and +discharges all eleven clauses. + +`EP-M2`'s second review ran them ahead of `EP-M3`, by mounting the `ADR-040` +worked specimen as a probe child RFC and flipping coverage map row `0013` to +written. All four `CONF-1` controls fired, as did the `COV-3` partial-purity +bound and both new link-number checks; the transcripts are in +`Control transcripts (2026-09-24)`. The probe is also the rehearsal for +`EP-M3`: with the specimen mounted, all ten tests passed, which is the state +`EP-M3` has to reach with a real child. + +### Control transcripts (2026-09-24) + +The `EP-M2` review's controls were run through a scratch harness +(`/tmp/probe.sh`, not tracked) that restores both the survey and the probe +child from pristine copies, applies one Python mutation, runs +`cargo nextest run --test rfc_stdlib_coverage_tests`, and prints the distinct +error lines. Each ran against the probe child, and each failed for its own +reason. Quoted messages are wrapped for width. + +- `CONF-1`, empty body. The body of subsection 5.8 is deleted, leaving the + heading: + + ```text + Error: docs/rfcs/0013-structured-data-interchange-helpers.md:134 is + subsection 5.8. Resource bounds of section 5 with an empty body + ``` + +- `CONF-1`, generic discharge. Subsection 5.7's body is replaced with "This + group meets the clause by construction", which names no owned helper and does + not carry the `D6` escape phrase: + + ```text + Error: docs/rfcs/0013-structured-data-interchange-helpers.md:112 is + subsection 5.7. Canonical value equality, whose body names none of the + helpers this RFC owns and does not read "No additional obligation beyond RFC + 0006 section 6.". A reviewer cannot tell it apart from a restatement of the + clause it discharges + ``` + +- `CONF-1`, deference. "as Ansible does" is inserted into subsection 5.8: + + ```text + Error: docs/rfcs/0013-structured-data-interchange-helpers.md:134 is + subsection 5.8. Resource bounds, which justifies a helper by appealing to + Ansible ("as Ansible"). RFC 0006 surveys Ansible; it does not adopt its + choices + ``` + +- `CONF-1`, duplicate clause. A second `### 5.7.` subsection is inserted before + the discharge table, and separately a second `|`6.7`|` row is added to it. + The two are distinct checks and each fires alone: + + ```text + Error: docs/rfcs/0013-structured-data-interchange-helpers.md:190 is a second + section 5 subsection for clause 6.7 + + Error: docs/rfcs/0013-structured-data-interchange-helpers.md:233 discharges + clause 6.7 a second time + ``` + +- `COV-3`, partial purity bound. All five registry rows are set to + filesystem-observing with their manifest-query cells changed to `No`, at one + of eight groups written. Changing the class *alone* fires + `check_manifest_query` instead, so the probe must keep the row internally + coherent to reach the new check: + + ```text + Error: the registries already declare 0 pure / 5 filesystem / 0 environment; + RFC 0006 section 6.1 states only 52/4/1 in total + ``` + +- `COV-3`, forbidden class as a hard zero. One row is set to + subprocess-observing with its cell at `No`: + + ```text + Error: the registries declare 1 helper(s) `subprocess-observing`; RFC 0006 + section 6.1 states no proposed helper is + ``` + +- Link-target number. Row `0013`'s link is retargeted to `0014-….md`, and + separately its link *text* is changed to `0014` while the target is + unchanged. Both fail, naming the disagreement: + + ```text + Error: coverage map row for RFC 0013 at + docs/rfcs/0006-ansible-inspired-template-standard-library.md:2070 links to + 0014-mapping-and-sequence-transform-helpers.md, whose number is 0014 + + Error: coverage map row for RFC 0014 at + docs/rfcs/0006-ansible-inspired-template-standard-library.md:2070 links to + 0013-structured-data-interchange-helpers.md, whose number is 0013 + ``` + + Restoring the link but reverting the cell to a bare `` `0013` `` while the + status stays `written` fails as "is marked written but its child RFC cell is + not a link". + +- Green baseline. With the probe child mounted and the map row flipped, and no + fault seeded, all ten tests pass and the reported count reads + `coverage map: 1 of 8 capability groups written; 7 remaining`. This is the + state `EP-M3` must reach. + +The harness restored both documents from their pristine copies after every +probe, and the working tree carried only the nine intended files afterwards; +`git diff` on the survey showed the two prose edits and nothing else. + +### Axioms + +- RFC 0006's dispositions are correct. This task partitions them. +- Table 11's totals of 41, 16, and 3, and section 6.1's aggregate of 52, 4, and + 1, are correct. `EP-M0` re-derives both and must agree. +- `markdownlint-cli2`, `typos`, and `mdtablefix` behave as configured. +- Section 7's note column **does not** reliably discriminate the three reject + classes. This axiom is stated in the negative because `EP-M0` and `EP-M1` + falsified the positive form: no rule fitted to the document recovers table + 11's 22/10/18, and the best attempt yields 8/24/18. `D10` therefore takes the + deny set from the complement rule and `COV-2` does not assert the split. `D5` + rule 3 states the remedy — a discriminating column — if the split is ever + wanted as a contract. +- Jinja namespaces are separate, so a filter and a test may share a name. No + name in the accepted set does; `abs` collides only with a pre-existing + MiniJinja built-in that is not in the inventory. The registry's namespace + column is carried for failure-message quality and forward insurance, not + because it disambiguates anything today. + +## Milestones and plateaus + +Because section 8 is not moved, every plateau is coherent by construction: RFC +0006 is unchanged as a specification throughout, and each child adds a +conformance record. An abandoned split leaves a repository that is incomplete, +never one that is inconsistent. This is a materially weaker claim than the +first draft made, and it is true. + +### `EP-M0` — audit and derivation rules. Go/no-go + +- Outcome: the partition and the `D5` derivation rules are confirmed against + the document, with two corrections adopted (`D10`). +- Acceptance evidence: recorded in `Surprises & discoveries` — the heading + recount with its four reconciliation adjustments stated explicitly, since the + naive count is 58 and never 57; the finding that section 7's note column does + **not** discriminate the three reject classes, because no rule fitted to the + document recovers table 11's 22/10/18, so under `D10` the split is not + derived and is not asserted; the derived accepted set at 60 and forbidden set + at **71** rather than the 34 first written; the purity aggregate at 52, 4, + and 1 once scoped to `New` rows; and confirmation that RFC numbers 0013 + upward are free on `origin/main` and every active remote branch. +- Conformance check: no tracked file modified except this plan. +- Recovery: nothing to revert. +- Corrections adopted: `D10` (deny set is the complement of the accepted set, + not a union of reject classes), the `D5` rule 2 rewording for `hash`, the + `COV-3` purity scoping, and the `COV-2` member assertions — `is_file` is + forbidden, but by the complement rule rather than by its reject class. +- **Go/no-go.** Stop if any derived count disagrees, or if the reviewer prefers + a fallback from `Alternatives considered`. The note column not discriminating + is **not** a stop condition: `EP-M0` and `EP-M1` established that it does + not, and `D10` answers it with the complement rule rather than with prose + parsing. + +### `EP-M1` — coverage test, ADR, corrections, roadmap rewrite + +Land as commits in PR #697, not as a self-contained pull request. The earlier +draft asked for a separate pull request on the grounds that the milestone is +independently valuable and is the fallback if nothing else proceeds; the +reviewer's instruction names one pull request, and PR #697 already carries the +task title, so the milestones stack there and each lands as its own commit. The +same correction was applied to this milestone's own Progress entry when it +shipped (see the `EP-M1` entry in `Progress`), and this paragraph had been left +stating the superseded model. + +- Outcome: `tests/rfc_stdlib_coverage_tests.rs` green with all eight groups + unwritten and `COV-4` reporting 8. `ADR-040` records the convention, its + narrow scope, and the amendment procedure. RFC 0006 gains the section 14 + coverage map and reservation rows for 0013 to 0020, has its number-allocation + table backfilled and its false claim that no RFC has been merged removed, has + the section 8.1 and section 8.6 defects corrected, has its seven "child + issue" phrases updated, and has section 16 question 7 annotated with a + pointer to roadmap task 7.1.1. Roadmap 6.1.1 was already rewritten per `D8`, + so `EP-M1` only has to confirm it still matches the delivered artefacts. +- Acceptance evidence: `COV-1` through `COV-6` green; all seeded-fault + transcripts recorded; every gate green. +- Conformance check: no disposition changed; no `src/` file touched; no new + dependency. +- Recovery: revert; nothing depends on it. +- Compatibility decision: none. RFC 0006 is a pre-1.0 internal document. + +### `EP-M2` — the template and one worked section 5 + +- Outcome: the literal skeleton is committed into `ADR-040` — chosen over the + developers' guide because the template is part of the convention the ADR + already states in four parts, and the ADR is where a child's author is + already sent — and one complete worked section 5 exists for review, for RFC + 0013, the group `EP-M3` delivers. 0013 is the *first* group, not the + smallest: it owns 5 registry rows and 94 lines of section 8 against RFC + 0020's 2 rows and 80 lines, so "smallest" was wrong in this plan's own risk + entry as well. The choice of 0013 stands regardless, because the worked + example earns its keep by being the document the go/no-go is spent on, not by + being cheap to write. +- Where it landed: the worked section sits in `ADR-040` under **The worked + section**, immediately after the template it fills in, and is a fenced + specimen rather than a ninth RFC. It could not be a file under `docs/rfcs/`: + `registries::parse_all` scans that directory and takes every file whose + number the coverage map reserves, so a specimen named `0013-…` would be + parsed as RFC 0013 itself and `every_accepted_helper_has_exactly_one_owner` + would accept a document that is not the child. The ADR is the safe home, and + it is also the natural one, since the template this completes already lives + there and neither can now be edited without the other in view. +- Mechanical evidence, because the specimen is a copy-source and a wrong one + costs eight documents: the registry heading matches `REGISTRY_HEADING` + literally, the five rows parse to `new`/`pure`/`yes` under `registries.rs`'s + cell vocabularies, and the discharge table yields exactly `6.1` through + `6.11` in document order under `clauses.rs`. Checked by extracting the fence + and running both parsers over it, not by reading it. +- Acceptance evidence: a reviewer reads the worked section 5 and can state one + thing it told them that RFC 0006 section 6 did not. +- Recovery: revert. + +### `EP-M3` — RFC 0013, structured data interchange. Hard go/no-go + +- Outcome: `docs/rfcs/0013-structured-data-interchange-helpers.md` exists, + complete, listed in `docs/contents.md`, named in the coverage map, and + referenced from roadmap step 6.2 and its three tasks. +- Acceptance evidence: `COV-1` shows five helpers owned by RFC 0013; `CONF-1` + green for it; `COV-4` reports 7 unwritten; every gate green. +- Conformance check: no disposition changed; open questions 1 and 4 carried + unresolved; the anti-vacuity rule satisfied for all eleven subsections. +- **Go/no-go.** This is the most important checkpoint in the plan. Everything + before it is reversible and spends no number that matters. Everything after + commits seven more numbers to a pattern nobody has yet seen finished. If + section 5 for five simple pure filters does not tell a reviewer something + they did not already know, the pattern does not work and the remaining seven + must not be written. Fall back to `Alternatives considered`. +- Recovery: revert the commit; the coverage-map row returns to unwritten. + +### `EP-M4` to `EP-M10` — one capability RFC each + +Identical in shape, so stated once. For child `00NN` owning the groups in +`Table 1` for roadmap step `6.S`: + +- Outcome: `docs/rfcs/00NN-<slug>.md` exists per the template in `ADR-040`; the + coverage map names it; `docs/contents.md` lists it; roadmap step `6.S` and + each of its tasks cite it. +- Acceptance evidence: `COV-1` shows exactly that RFC's registry helpers owned + by it; `CONF-1` green; `COV-4`'s unwritten count decremented; gates green. +- Conformance check: no disposition changed; `COV-2` proves mechanically that + no forbidden name was introduced; open questions match the assignment. +- Recovery: revert the single commit. Because section 8 is untouched, the prior + plateau is fully coherent. +- Compatibility decision: none. + +Order: `EP-M4` RFC 0014, `EP-M5` RFC 0015, `EP-M6` RFC 0016, `EP-M7` RFC 0017, +`EP-M8` RFC 0018, `EP-M9` RFC 0019, `EP-M10` RFC 0020. RFC 0017 precedes RFC +0018 because `expandvars` and `abs` both depend on the dialect mechanism that +section 8.6 defines and RFC 0017 discharges. Ship `EP-M4` to `EP-M7` as one +pull request and `EP-M8` to `EP-M11` as another, so no reviewer faces the whole +set at once. + +### `EP-M11` — reconcile and close + +- Outcome: all 39 roadmap task-level section 8 citations additionally cite + their child RFC; `COV-4` reports 0 unwritten; task 6.1.1 marked done; this + plan `COMPLETE`. +- Acceptance evidence: `make check-fmt`, `make lint`, `make doc-coverage`, + `make test`, `make markdownlint`, and `make nixie` green on the merge commit, + captured under `/tmp`. `COV-5` green over every new link. +- Conformance check: reconcile every `Surprises & discoveries` entry. Both RFC + 0006 defect corrections present. No disposition changed. Re-enumerate remote + heads a final time to confirm no allocated number collided. +- Recovery: not applicable. +- Remaining gaps: none. Implementing the helpers is steps 6.2 to 6.9. + +## Plan of work + +### Stage A — understand and propose + +Read RFC 0006 sections 6, 7, 7.8, 8, 9, 10, 13.4, 14, and 16. Perform the +`EP-M0` derivations. Confirm numbers are free: + +```bash +git ls-remote --heads origin | grep -oP 'refs/heads/\K.*' | sort +git ls-tree --name-only origin/main docs/rfcs/ +``` + +Ends at the go/no-go. + +### Stage B — red + +Write the coverage test before any child RFC. Wire it per +`tests/integration_test_wiring_tests.rs`, following the plain module +declaration precedent in `tests/documentation_installation_tests.rs`, not the +path-attribute precedent in `tests/dependabot_config_tests.rs`, which sidesteps +the orphan check rather than satisfying it. + +Split across `tests/rfc_stdlib_coverage_tests.rs` and +`tests/rfc_stdlib_coverage/` **from the start**, not if it grows. The +repository caps code files at 400 lines and both comparable precedents already +exceed it at 411 and 419. Because `D5` derives the inventory from RFC 0006 +there is no large data table to hold, so the split is a parser module plus an +assertions module. + +Run it and confirm it fails for the expected reason — the coverage map does not +exist. Add the map, confirm green. Then apply every seeded fault from the +`Verification plan` in turn, confirm the expected failure, revert, and capture +each transcript. + +Lint constraints that shape the code: `unwrap_used` and `expect_used` are +denied outside test bodies, so every parser helper returns `anyhow::Result`; +`panic_in_result_fn` is denied; and `missing_docs_in_private_items` is denied, +covering struct fields and enum variants, not merely types. + +### Stage C — implementation + +`EP-M1` as commits in PR #697, then `EP-M2`, then one commit per child. For +each child: + +1. Create `docs/rfcs/00NN-<slug>.md` from the literal template in `ADR-040`. +2. Write section 4 as a list of the group's helpers with one-line purposes and + links into RFC 0006 section 8.N. Do not restate a contract. +3. Write section 5.1's registry, then 5.6, 5.7, 5.8, and 5.9. Apply the + anti-vacuity rule to 5.2 through 5.5, 5.10, and 5.11. Close section 5 with + the clause-discharge table, and complete it rather than copying it forward: + it is the one place a reader sees all eleven clauses resolved. +4. Flip the child's row in the coverage map from unwritten to the new number. +5. Add the `docs/contents.md` entry, phrased like the RFC 0009 to 0011 entries. +6. Add a `See RFC 00NN` sub-bullet to roadmap step `6.S`, and the same to each + of the step's tasks alongside the existing section 8 citation. +7. Re-enumerate remote heads, run `make fmt`, run the gates, commit. + +### Stage D — reconcile + +`EP-M11`. Sweep the remaining citations, confirm `COV-4` reports zero, run the +full gate set sequentially. + +## Concrete steps + +Run everything from the worktree root. Gate commands, sequential and tee'd: + +```bash +make fmt +make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-$(git branch --show-current).out +make lint 2>&1 | tee /tmp/lint-netsuke-$(git branch --show-current).out +make test 2>&1 | tee /tmp/test-netsuke-$(git branch --show-current).out +make markdownlint 2>&1 | tee /tmp/markdownlint-netsuke-$(git branch --show-current).out +make nixie 2>&1 | tee /tmp/nixie-netsuke-$(git branch --show-current).out +``` + +Focused test during Stage B: + +```bash +cargo nextest run --test rfc_stdlib_coverage_tests 2>&1 \ + | tee /tmp/covtest-netsuke-$(git branch --show-current).out +``` + +Observed red transcript, the `COV-4` control state with the coverage map table +removed. The order varies between runs, and `inter_document_links_resolve` +alone stays green because nothing else it does touches the map: + +```plaintext +PASS [ 0.007s] (1/7) netsuke-build::rfc_stdlib_coverage_tests inter_document_links_resolve +FAIL [ 0.019s] (2/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner + Error: the coverage map subsection contains no table +FAIL [ 0.019s] (3/7) netsuke-build::rfc_stdlib_coverage_tests coverage_map_status_is_reported + Error: the coverage map subsection contains no table + … four more FAIL lines, each with the same error … +error: test run failed +``` + +Observed green transcript at `EP-M1`: + +```plaintext +PASS [ 0.006s] (1/7) netsuke-build::rfc_stdlib_coverage_tests inter_document_links_resolve +PASS [ 0.020s] (2/7) netsuke-build::rfc_stdlib_coverage_tests coverage_map_status_is_reported + coverage map: 0 of 8 capability groups written; 8 remaining +PASS [ 0.020s] (3/7) netsuke-build::rfc_stdlib_coverage_tests every_capability_has_a_roadmap_task +PASS [ 0.021s] (4/7) netsuke-build::rfc_stdlib_coverage_tests every_child_discharges_every_clause +PASS [ 0.021s] (5/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner +PASS [ 0.022s] (6/7) netsuke-build::rfc_stdlib_coverage_tests totals_and_purity_aggregate_agree +PASS [ 0.022s] (7/7) netsuke-build::rfc_stdlib_coverage_tests no_forbidden_helper_is_registered +Summary [ 0.022s] 7 tests run: 7 passed, 0 skipped +``` + +Predicted wrong-owner seeded-fault transcript, the control the first draft +lacked. No registry exists yet, so this one has no observed counterpart: + +```plaintext +FAIL [ 0.012s] (1/7) netsuke-build::rfc_stdlib_coverage_tests every_accepted_helper_has_exactly_one_owner + Error: helper combine (filter): coverage map designates RFC 0014, registry found in + RFC 0015 at docs/rfcs/0015-ordered-collection-algebra-and-truth-predicates.md:73 +``` + +Commit messages use `git commit -F`. The per-child template: + +```plaintext +Add RFC 00NN for the <group> helpers (6.1.1) + +Records the RFC 0006 section 6 contract obligations for the <n> helpers in +section 8.N, with a purity and manifest-query registry, and links the group +to roadmap step 6.S. + +RFC 0006 section 8 is unchanged; this RFC is additive. + +Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> +``` + +Pull request titles carry the roadmap number in parentheses, as the repository +convention requires: for example `Add the RFC 0006 coverage contract (6.1.1)`. + +Delegate gate runs to `scrutineer` and the `docs/contents.md` and roadmap +citation work to `scribe`. Do not delegate any section 5. + +## Validation and acceptance + +Acceptance is behavioural. + +1. Open any child RFC's section 5.1 and read a table giving every helper in the + group its purity class and manifest-query availability. Confirm no such + table exists anywhere in the repository today — that is the artefact this + task creates. +2. Read that child's sections 5.6 through 5.9 and find, for each, a + group-specific consequence: a named bound from RFC 0006 table 3, a + diagnostic code, a named error condition. Nowhere does a justification + reduce to matching Ansible. +3. Run `make test` and observe `rfc_stdlib_coverage_tests` pass with eighteen + tests — seven obligation checks, six `markdown.rs` heading and fence tests, + five `deference.rs` appeal tests — and a line reporting how many capability + groups remain unwritten. Delete one row from RFC 0013's section 5.1 + registry, re-run, and observe a failure naming that helper and reporting + zero owners. Restore the row. +4. Move a registry row from one child to another, re-run, and observe a + wrong-owner failure naming both the designated and the actual RFC. +5. Search the registries in `docs/rfcs/` for `shuffle`, `is_dir`, `is_file`, + `quote`, and `fileglob`, and find none of them. Each remains discussed in + RFC 0006 sections 9, 10, and 11, and each may be named as a non-goal in a + child's section 3 — the check is scoped to registry tables, which is what + `COV-2` parses. +6. Open `docs/roadmap.md` at step 6.5 and find both the existing section 8.4 + citation and a new RFC 0016 citation on each task. +7. Confirm `docs/contents.md` lists all eight new RFCs. + +Red-Green-Refactor evidence: + +- Red: the focused test fails because RFC 0006 section 14 has no coverage map. +- Green: it passes once the map lands, with all eight groups unwritten. +- Refactor: it still passes at `EP-M11` with zero groups unwritten, and the + reported remaining count has gone 8, 7, and so on to 0 across the milestones. + +No behaviour-driven scenarios apply: this changes no observable tool behaviour, +adds no command-line surface, and touches no persistence or network boundary. +`docs/users-guide.md` is therefore not updated; RFC status is not user-facing +behaviour. Recorded so the omission is a decision, not an oversight. +`ortho_config` is likewise not used: it governs layered configuration surfaces +and this change adds none. If a later milestone unexpectedly introduces one — +for instance by resolving RFC 0006 open question 5 to expose the section 6.8 +bounds through `StdlibConfig` — that is a tolerance breach requiring escalation. + +Quality criteria: + +- Tests: `make test` green, including the seven coverage tests. +- Verification: `COV-1` through `COV-6` and `CONF-1` discharged, with every + seeded-fault transcript recorded. `CONF-1`'s semantic residual gap stated + plainly and not overclaimed. +- Lint: `make lint`, `make markdownlint`, `make check-fmt`, and `make nixie` + green. Note `make doc-coverage` does not measure integration tests; the + governing gate is `missing_docs_in_private_items` under `make lint`. +- Performance and security: not applicable; no capability, dependency, or trust + boundary changes. + +## Idempotence and recovery + +Every step is re-runnable; `make fmt` is idempotent. Each milestone is one +commit. + +Rollback is genuinely simple here, and only because of `D3`. Since RFC 0006 +section 8 is never modified, reverting any child-RFC commit removes a +conformance record and returns a coverage-map row to unwritten. No contract is +lost and no citation breaks. Reverts conflict only on the coverage map and +`docs/contents.md`, both single-line edits. + +The one irreversible act is allocating an RFC number, and only on merge. `D2` +allocates lazily, so an abandoned split spends only the numbers it used. Before +merge, renumbering is a rename plus a link sweep; `COV-5` catches a stale link +that `make markdownlint` cannot. + +Do not use bare `git stash`; the stash stack is shared across worktrees. Use a +temporary work-in-progress commit. + +## Artefacts and notes + +To be filled during implementation. Record at minimum: the `EP-M0` derivation +results with the heading-count reconciliation stated; every seeded-fault +transcript; the `git diff --stat` per child commit; and the final gate log +paths under `/tmp`. + +## Interfaces and dependencies + +Files created: + +- `docs/rfcs/0013-structured-data-interchange-helpers.md` +- `docs/rfcs/0014-mapping-and-sequence-transform-helpers.md` +- `docs/rfcs/0015-ordered-collection-algebra-and-truth-predicates.md` +- `docs/rfcs/0016-pattern-and-version-predicates.md` +- `docs/rfcs/0017-lexical-path-composition.md` +- `docs/rfcs/0018-host-state-predicates-and-environment-expansion.md` +- `docs/rfcs/0019-encoding-identity-and-formatting-helpers.md` +- `docs/rfcs/0020-date-and-time-conversion-helpers.md` +- `docs/adr-040-focused-child-rfcs-for-survey-rfcs.md` +- `tests/rfc_stdlib_coverage_tests.rs` +- `tests/rfc_stdlib_coverage/mod.rs` and its submodules + +Files modified: + +- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` +- `docs/roadmap.md` — task 6.1.1 already amended; steps 6.2 to 6.9 gain a + child-RFC citation per child +- `docs/contents.md` +- this ExecPlan +- `.gitignore` — adds `uv.lock`, paired with `git rm --cached uv.lock`. The + lockfile is never tracked on `origin/main`; it entered this branch by + accident at `a94a3006`, whose `git add -A` swept its own verification entry + and the working-tree file in together, and it left again at `f42202a4`. The + net diff against `origin/main` for that path is therefore **empty**, and the + `.gitignore` line is the only surviving trace. It is named here because a + reader who runs `git diff origin/main...HEAD -- uv.lock` sees nothing and + would otherwise not know the change exists. + + **Authorized separately from this split, and not part of its scope.** The + `.gitignore` line and the `git rm --cached` were requested explicitly for + this branch rather than arrived at by the execplan, so a reviewer finding + them outside `EP-M4`–`EP-M11` is looking at a deliberate, separately-approved + change and not at scope creep. The reasoning that makes the change correct — + a lockfile that locks nothing, an ignore rule that would be inert while the + path stays tracked, and the measurements showing no test reads the file — is + recorded in the Progress entry dated 2026-09-28 that begins "A second change + rides on this head". Its placement in this list is descriptive: the file + changes on this branch, so the interface list names it. + +No other file changes. No `src/` change. No `Cargo.toml` change: `googletest` +0.14.3, `pretty_assertions` 1.4.1, and `regex` 1.12.2 are dev-dependencies, and +`anyhow` is a normal dependency, which integration tests link against. The +`.gitignore` and `uv.lock` change above is the one exception to "no other file +changes", and it is confined to repository hygiene — no build input. + +The test's private shape, in `tests/rfc_stdlib_coverage/mod.rs`. Every field +and variant needs a documentation comment, since +`missing_docs_in_private_items` is denied. + +```rust +/// Jinja namespace a helper occupies. +enum Namespace { + /// Invoked as a filter on a piped value. + Filter, + /// Invoked as a test after `is`. + Test, + /// Invoked as a bare function call. + Function, +} + +/// Whether a registry row introduces a helper or adds an option to one. +enum Registration { + /// One of the 57 new helpers. + New, + /// One of the 3 existing helpers gaining an option. + OptionAdded, +} + +/// A surveyed name's disposition, derived from RFC 0006 section 7. +enum Disposition { + /// Accepted, with the owning section 8 subsection. + Accept, + /// Deferred by section 9. + Defer, + /// Rejected by section 10, for any of the three reasons table 11 counts. + Reject, +} + +/// One accepted helper, as the document that established it records it. +struct Row { + /// Registered helper name, backticks stripped. + name: String, + /// Namespace the helper occupies. + namespace: Namespace, +} +``` + +## Revision note + +Revised 2026-09-08 after a six-lens design review. The review verified the +partition — all 57 helpers, none lost, duplicated, or misassigned — and +rejected almost everything around it. + +The plan no longer migrates RFC 0006 section 8 into the children (`D3`): the +repository's convention is additive, migration would strand 39 roadmap +citations with no link gate to notice, two groups split across two milestones +would leave genuinely incoherent intermediate states, and the measured +contributor read path would have got worse rather than better. The Purpose +section was rewritten because its original premise, that a contributor must +read 2132 lines, was false; the roadmap already deep-links every task. The +coverage test is now anchored on tables and derived from RFC 0006's own section +7 rather than on headings and hardcoded constants (`D4`, `D5`): heading +anchoring cannot see `basename` or `dirname`, miscounts three compound +headings, and false-positives on the parent's own sections 10, 11, and 15, +while the handwritten forbidden list had already omitted sixteen names. A ninth +child RFC for roadmap step 6.1 was dropped as empty. Section 5 was cut from +eleven mandatory prose clauses to five substantive ones with an anti-vacuity +rule (`D6`), because seven of eight children own only pure helpers and the +original structure was a vacuity generator. Numbers are now allocated lazily +(`D2`) against a measured in-repository collision base rate of three. A hard +go/no-go was added after the first completed child, an +`Alternatives considered` section was added with an `EP-M1`-only fallback, and +five tolerances were added covering aggregate volume, effort, gate false +positives, number collision, and vacuity. Two further RFC 0006 defects were +recorded, and several factual errors corrected: issues #596 and #594 are +closed, the naive section 8 heading count is 58 rather than 57, +`make doc-coverage` does not measure integration tests, and `basename` and +`dirname` carry a reject disposition the derivation rules must not confuse with +the forbidden set. + +Nothing is implemented; the plan awaits approval. + +Revised again 2026-09-08 on reviewer direction. Roadmap task 6.1.1 has been +rewritten in place to say "child RFCs and accompanying roadmap tasks", and to +record that delivery is tracked through the roadmap checkboxes in steps 6.2 to +6.9 rather than through separate issues, so progress stays in committed +documentation. The plan's `Scope divergence` section is therefore retired and +replaced by `Scope: settled`, which keeps the reasoning on the record without +presenting it as an open question. `D8` is updated accordingly, and the lost +burn-down it worried about is resolved rather than merely noted. + +Two precision fixes came out of amending the roadmap. The success criterion now +reads "exactly one child RFC and by at least one accompanying roadmap task", +because `product` is already named by both task 6.4.2 and task 6.4.5, so +requiring exactly one task would have meant splitting existing tasks for no +benefit. And a new obligation `COV-6` checks the roadmap half of the criterion +mechanically, which the plan previously left unchecked: every accepted helper +must be named by at least one task under the step that owns its child RFC. That +was verified before the obligation was written — all 60 helpers already are — so +`COV-6` is green on the current roadmap and fails only if a task is deleted or +a helper is reassigned across steps. + +Nothing is implemented; the plan awaits approval. + +Revised again 2026-09-11 after `EP-M0`. The plan is approved and in progress: +the implementation agent was asked to proceed, and `EP-M0` ran as the first +task, as the plan requires. The audit confirmed every derived count except one, +and the exception was instructive. The plan's 34-member forbidden set was a row +count wearing a name count's clothes, and it could not coexist with the plan's +own `is_file` and `expanduser` assertions — under a faithful class reading +`is_file` is not forbidden, and under the row count that yields 34 neither is +it. `D10` resolves the inconsistency by stating the deny set as the +**complement of the accepted set** — 71 names — which keeps the four membership +witnesses the first draft wanted, gives up only the `expanduser` exclusion, and +needs no normative edit to RFC 0006. Everything else held: the partition, the +accepted set at 60, the 41/16/3 totals, the purity aggregate, the naive heading +count of 58 with its four reconciliation adjustments, and the availability of +RFC numbers 0013 to 0020 on `origin/main` and every active remote branch. + +Three smaller corrections followed. `D5` rule 2 asserted that all three rename +rows say `Reject`; `hash`'s row actually reads "Accept as `text_hash`", so the +rule is read off the disposition cell and the rename is not a parser special +case. `COV-3`'s purity aggregate has to be scoped to `New` rows, because the +registries carry all 60 accepted helpers including the filesystem-observing +`glob` and would otherwise aggregate to 54/5/1 against section 6.1's 52/4/1. And +`COV-2`'s member list was restated against the complement, with `is_dir`, +`is_link`, `win_dirname`, and `expanduser` added to its non-vacuity assertions +and the `expanduser` exclusion dropped. + +One `D5` rule 3 remedy is now **in scope, but deferred by choice**. The plan +provided that if section 7's note column did not discriminate the three reject +classes, `EP-M1` would add a discriminating column. `EP-M0` believed it did +discriminate — a reject row being *alias* if its resolution cell contains +"alias" or cites §10.2, *exists* if it begins "Exists" or names a provider in +backticks, and *principle* otherwise, which was recorded as reproducing table +11's 22/10/18. `EP-M1` re-derived that rule and got 8 alias / 24 exists / 18 +principle. The *principle* third is correct and every principle row is +backtick-free, but the remaining 32 rows split 8/24 where table 11 says 10/22; +the difference is exactly the two rename rows `win_splitdrive` and `fileglob`, +which §7.8 says are "registered under a Netsuke name rather than the surveyed +one" and which table 11 therefore counts as aliases while the rule counts them +as exists. Recovering 22/10/18 needs those two special-cased out of *exists* +while `now` — also a reject row naming an existing helper in backticked call +form — stays inside, and the document offers nothing to distinguish them. The +split is therefore **not derivable from the tables as they stand**, and `COV-2` +does not assert it: it asserts the directly parseable totals, plus that table +11's three class counts sum to the derived reject-row count. Adding the +discriminating column remains the remedy if the split is ever wanted as a +contract; `D10` keeps it off the deny set's critical path either way, so no +normative edit to RFC 0006 is made now. + +- [x] (2026-09-28) **The `2db228aa` review triaged: seven local CodeRabbit + findings, five in scope, plus the verification that the scrutineer's two + "posted-but-unaddressed" comments were already fixed.** The seven local + targets were reviewed against this branch's actual diff, which is 60 commits + over 27 files. Note the local `--agent` pass diffed `6499fc48` — a commit + that is not an ancestor of HEAD, is not on `origin/main`, and is reached by + no ref. Nine of its fourteen findings land outside the PR diff entirely (kani + tests, `.github/workflows/ci.yml`, `Makefile`, `pylint_tier_test.py`, two + foreign ExecPlans, `resolver_telemetry_boundary_tests.rs`) and are not this + branch's to fix. The five in scope are applied in the commit carrying this + entry. + + Two of the five are corrections of *this plan's* text and one of those + contradicts a number the plan states eight other times: + + - `bijection over fifty-seven names` is wrong twice over. The bijection is + over the accepted set, which is **sixty** — 57 new helpers plus the 3 + existing ones gaining an option — and the plan's own totals table already + reads `| Accepted set | 60 | 60 | agree |`. Both occurrences were corrected, + with the arithmetic shown so the 57 is not simply erased: it is the + *new-helper* figure, not the accepted-set figure. + - Three first-person pronouns were removed from the plan, per + `documentation-style-guide.md` line 39. The sweep found **three**, not the + two the finding named: the phrase `My own build attempt`, in the entry + about the package-cache deadlock, was outside the finding's citation and + would have been missed by applying it literally. + A corpus probe bounds the rule honestly — `docs/` carries 149 first-person + occurrences, so the rule is stated rather than universally observed, and + this plan is now among the compliant files rather than the rest. + - The `.gitignore` / `uv.lock` paragraph now records that the change was + authorized separately from the split and is not part of its scope, which is + what the finding asks for. The change itself is untouched: it was requested + for this branch in its own right, and is correct for reasons recorded in the + earlier entry beginning "A second change rides on this head". What was + missing was the *record of authorization*, not the change. + + The remaining two are normative edits that reach beyond this plan, and each + was mirrored into its other copy in the same commit, per the rule this plan + already records — *when a correction is applied to an artefact, grep for its + other copies in the same commit*: + + - **RFC 0013 section 5.6 omitted `output_too_large` from both serializer + bullets.** The code is real, is in section 5.9's table, and is *enforced* + by section 5.8's length pass — but section 5.6 is the per-helper + enumeration a reviewer reads to learn what each helper rejects, and it + listed four conditions for `to_yaml` and repeated "the same four" for + `to_nice_json`. Both bullets now name five. The count was checked against + the table afterwards rather than asserted: `to_yaml` now names + `undefined_input`, `indent_out_of_range`, `unsupported_key`, + `unsupported_kind`, and `output_too_large`, and section 5.9 carries a row + for each. **This changes no code and no count elsewhere** — fourteen codes + was already correct. + - **Section 5.7's rendered-key collision named no code.** It said + `to_nice_json` "rejects a mapping whose rendered keys are not distinct" + while the adjacent sentence named `duplicate_key` for `from_json`'s + rejection of the same collision. The serializer reuses that code — the same + key problem detected at the other end of the round trip — so naming it keeps + the code set at fourteen and leaves both discharge rows and section 5.9's + table untouched. Introducing a fifteenth code was the alternative and was + rejected as the larger change for no gain in precision. + + **The scrutineer's "Next Action" was wrong and was not acted on.** It + reported two "posted-but-unaddressed findings" — + `tests/rfc_stdlib_coverage/map.rs:173` (duplicate child RFC reservations) and + `tests/rfc_stdlib_coverage/markdown.rs:154` (fence indentation) — and told + the next agent to review them first. Both guards are present and correct: + `map.rs` rejects a repeated reservation at lines 120-139, and `markdown.rs` + bounds fence indentation at 136-154. The posted comments carry + `commit_id = 2db228aa` only because GitHub re-anchors a review comment's line + and position on push; the bodies describe defects fixed in commits merged + before it. **A comment's `commit_id` is not evidence that the code it points + at is still broken.** This is a second instance of the same re-anchoring + illusion already recorded for this PR, and the verification cost three file + reads — cheaper than the alternative, which was to re-fix two live guards. + + One further false premise was rejected before it reached the tree. The + scrutineer's report also read the `coverage map: 1 of 8 …` line as not being + a progress report at all. It is correct that COV-4 captures the *test's own + stdout* rather than a live count, but the plan has consistently read it as + the reported state of the map and that reading is the one the test asserts + on; the observation changes no disposition. + + **A `make fmt` run was required and is recorded rather than assumed.** Gate + one (`make check-fmt`) reded on the first attempt of this change: + hand-wrapped prose is not `mdtablefix`'s canonical wrap, and four of the six + edited files were flagged. That is the canonicalization trap this plan has + already met once, and it is a formatting failure rather than a content one — + the formatter's own `--fix` was the remedy, and the resulting reflow was + verified not to have dropped content by comparing word counts against HEAD + (all six files grew, none shrank) and by re-reading every edited passage in + the reflowed text. Two `grep` probes returned empty during that verification + for phrases that were in fact present, because `mdtablefix` had moved them + across a line break: **a phrase-level `grep` is not evidence of absence in a + wrapped document** — read the paragraph. + + **The gate run then reded a second time, and again the defect was this + branch's own prose.** `make markdownlint` aborted in its `spelling` + prerequisite on `canonicalisation` — a bare `-ise` form written into the very + paragraph describing the canonicalization trap, which is the *same* defect + class this plan already records at the entry about `feed5192`, three hundred + lines above the new text. The lesson repeats rather than extending: the + recurrence is not evidence that the earlier entry was wrong, it is evidence + that the trap is live in new prose and that writing *about* a spelling rule + in an unbackticked word is itself the hazard. `markdownlint-cli2` never ran, + so mdlint had no verdict — that state was reported as UNKNOWN rather than as + a pass, per the distinction already recorded above. + + Two side effects of the failed gate are worth recording, because each would + otherwise have entered the commit unexamined: + + - **`typos.toml` is rewritten by the gate, and the rewrite was reverted.** + `make spelling` regenerates the file from the pinned shared dictionary + (`typos-config-builder` `v0.1.1`) on every run, and the run moved one + `extend-ignore-re` entry — narrowing `\bvar\.iamge_id\b` to a longer + backticked phrase and re-sorting it. The file is tool output, not hand + prose, so the question is not whether the new text is nicer but whether a + stale file fails anything. **It does not.** The probe is decisive: with + HEAD's version restored, `make spelling` exits **0**, prints `current: + typos.toml`, and silently rewrites the file to the same 44-entry text on + each run, byte-identical across two consecutive runs. So the modification is + a gate side effect that CI reproduces on its own, and committing it would + widen this branch's diff by a file the ExecPlan does not list for the sake + of text that is regenerated rather than authored. It was reverted, and the + tree is back to the six files this change touches. The one thing that does + bite is a *stale committed* file being invisible in review: nothing fails, + so nobody learns the dictionary moved. + - **A `grep` for `-ise` forms on added lines found no others.** Reporting the + negative matters here because the spelling gate stops at the first error, so + a single fix is not evidence that the rest of the new prose is clean. The + check was run against the added lines specifically — `canonicalis`, + `normalis`, `serialis`, `initialis`, `organis`, `recognis`, `analys`, and + their kin — and the only surviving matches were correct English words + (`collision`, `diagnosis`, `premise`, `consistently`), none of them a `-ise` + variant a reviewer would need to weigh. + + Concern counts after triage: **high 0, medium 0, low 2** — the two CodeRabbit + findings in `tests/rfc_stdlib_coverage/markdown.rs` and `map.rs` that this + session verified as already-fixed, and which are therefore closed rather than + carried. diff --git a/docs/rfcs/0006-ansible-inspired-template-standard-library.md b/docs/rfcs/0006-ansible-inspired-template-standard-library.md index 96a636504..7b8bd663e 100644 --- a/docs/rfcs/0006-ansible-inspired-template-standard-library.md +++ b/docs/rfcs/0006-ansible-inspired-template-standard-library.md @@ -13,20 +13,32 @@ ### Number allocation -No RFC has been merged to `main` yet, so the `docs/rfcs/` sequence is defined -entirely by in-flight branches. This RFC takes `0006` and treats the numbers -below it as reserved, per the "gaps are acceptable when numbers are reserved, -drafted on another branch, or intentionally skipped" rule in +Numbers `0001` to `0012` are merged to `main` and are therefore taken. This RFC +takes `0006`, and reserves `0013` to `0020` for the capability child RFCs +section 14.13 allocates, per the "gaps are acceptable when numbers are +reserved, drafted on another branch, or intentionally skipped" rule in [the documentation style guide](../documentation-style-guide.md). -| Numbers | Reserved for | Current state | -| ------------ | ----------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| 0001 | Structured command blocks, plus the two amendments | Drafted in [#573](https://github.com/leynos/netsuke/pull/573) and superseded by [#600](https://github.com/leynos/netsuke/pull/600) | -| 0002 to 0004 | Manifest composition: repository-relative includes, versioned local bundles, digest-pinned external bundles | Drafted in [#600](https://github.com/leynos/netsuke/pull/600) | -| 0005 | Release integrity and admission | Proposed in [#556](https://github.com/leynos/netsuke/pull/556) | -| 0006 | This RFC | Proposed | - -_Table 1: RFC sequence reservations across in-flight branches._ +| Numbers | Reserved for | Current state | +| ------------ | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| 0001 | Structured command blocks | Merged in [#573](https://github.com/leynos/netsuke/pull/573) | +| 0002 to 0004 | Manifest composition: repository-relative includes, versioned local bundles, digest-pinned external bundles | Merged in [#600](https://github.com/leynos/netsuke/pull/600) | +| 0005 | Release integrity and admission | Merged in [#556](https://github.com/leynos/netsuke/pull/556) | +| 0006 | This RFC | Merged in [#602](https://github.com/leynos/netsuke/pull/602) | +| 0007 | Netsukefile testing framework | Merged in [#566](https://github.com/leynos/netsuke/pull/566) | +| 0008 | Repository-wide code-health contracts and fuzzing | Merged in [#556](https://github.com/leynos/netsuke/pull/556) | +| 0009 to 0010 | Structured-command amendments: per-command working directories, and runtime bindings and secure tempdirs | Merged in [#600](https://github.com/leynos/netsuke/pull/600) | +| 0011 | Allow-listed structured command shells | Merged in [#653](https://github.com/leynos/netsuke/pull/653) | +| 0012 | Netsukefile property testing | Merged in [#654](https://github.com/leynos/netsuke/pull/654) | +| 0013 to 0020 | Capability child RFCs this RFC's accepted set is split into | Reserved here; allocated in section 14.13 | + +_Table 1: RFC number allocation and merge state, and the numbers reserved here._ + +Every row of table 1 records a merge state, and `Proposed` is not one: it is the +`Status` this RFC's own preamble carries, and every merged RFC in the corpus +carries it too — a merged RFC's status stays `Proposed` until its capability +has shipped. `0006` is therefore merged and `Proposed`, and the row states the +merge because that is the fact the column is about. ## 1. Summary @@ -48,7 +60,7 @@ existing Netsuke surface, and a delivery sequence of ten focused slices. This RFC specifies behaviour only. It contains no implementation, and it is not itself a request to merge one large standard-library change. Accepted groups -become focused child issues after v0.1.0 final. +become focused child RFCs after v0.1.0 final. ## 2. Problem @@ -194,12 +206,13 @@ the third has since been closed. - A generic plugin or lookup dispatcher. See section 10.5. - Any change to the v0.1.0 release scope. - Any change to the existing `hash` contract. See section 11.1. - - Implementation. This RFC specifies behaviour; child issues implement it. + - Implementation. This RFC specifies behaviour; child RFCs carry it, and the + roadmap tasks in steps 6.2 to 6.9 are what schedule the work. ## 5. Licensing and provenance boundary Ansible is licensed GPL-3.0-or-later. Netsuke is licensed ISC. The following -rules are normative for every child issue arising from this RFC. +rules are normative for every child RFC arising from this RFC. 1. Names, concepts, documented signatures, and independently verified observable behaviour may be borrowed. @@ -224,8 +237,8 @@ and ## 6. Cross-cutting contract -This section is normative. A child issue that does not satisfy every clause -below for every helper it adds is not complete. +This section is normative. A child RFC that does not satisfy every clause below +for every helper it adds is not complete. ### 6.1. Purity classes @@ -365,6 +378,12 @@ values through a hash set. order-preserving map keyed on the canonical key; they must not expose the map's iteration order. +The canonical-JSON domain is the set of values for which RFC 8785 produces a +canonical JSON representation, with string keys at every mapping level and no +value excluded above. Round-trip claims in section 8.1 are limited to that +domain. A serializer may accept additional native values, but converting them +does not establish canonical equality with the original value. + ### 6.8. Resource bounds Every parser, combinatorial helper, regular-expression operation, and @@ -424,8 +443,11 @@ Netsuke registers exactly **one** name per capability. ### 6.11. Documentation and testing obligations -Each accepted helper requires all of the following before its child issue -closes. +Each accepted helper requires all of the following before its roadmap task in +the owning child RFC's step is complete. The obligations attach to the helper, +not to the child RFC document: a child RFC states each helper's contract and +enumerates these obligations, while the checklist that tracks them is the +roadmap task the helper is scheduled under. 1. An entry in `docs/stdlib-yaml-and-jinja-guide.md` giving its signature, purity label, prose contract, edge cases, and an example, in the format the @@ -650,7 +672,7 @@ are optional keyword arguments; arguments shown without one are required. ### 8.1. Structured data interchange -All six helpers in this group are pure. They exist so that manifests can +All five helpers in this group are pure. They exist so that manifests can consume compiler metadata, package manifests, and generated configuration fragments without a subprocess. @@ -723,9 +745,9 @@ Deterministically serializes a native value as block-style YAML. timestamp, or an empty value, and whenever it has leading or trailing whitespace. This explicitly covers the YAML 1.1 spellings `yes`, `no`, `on`, `off`, `y`, and `n`, so the Norway problem cannot reach a generated file. -- Round trip: `value | to_yaml | from_yaml` returns a value equal to `value` - under section 6.7 canonical equality, for every value expressible in YAML. - This is a property test. +- Round trip: for values in the section 6.7 canonical-JSON domain that are + expressible in YAML, `value | to_yaml | from_yaml` returns a value equal to + `value` under section 6.7 canonical equality. This is a property test. - Undefined input is an error. #### `value | to_nice_json(indent=2, sort_keys=false)` @@ -739,8 +761,19 @@ Pretty-prints JSON. MiniJinja's `tojson` remains the compact serializer; no - Output uses LF line endings and does **not** end with a trailing newline, so the result composes inside a larger document. - Integer and boolean mapping keys are rendered in their canonical string - form. Other key kinds are rejected rather than coerced. -- Round trip: `value | to_nice_json | from_json` returns an equal value. + form. Other key kinds are rejected rather than coerced. This conversion is + lossy: `from_json` reads the rendered key as a string, so the original key + type is not preserved. +- Distinct source keys that render to the same string are rejected with + `duplicate_key`. For example, a mapping containing integer key `1` and string + key `"1"`, or boolean key `true` and string key `"true"`, is rejected. +- Round trip: for values in the section 6.7 canonical-JSON domain, + `value | to_nice_json | from_json` returns a value equal to `value`. Inputs + with converted integer or boolean keys are outside this guarantee, including + such mappings nested in sequences. + +The normative amendment and its acceptance cases are recorded in +[RFC 0013](0013-structured-data-interchange-helpers.md). ### 8.2. Mapping and sequence transforms @@ -1099,10 +1132,12 @@ the existing `basename` and `dirname` filters, which keep their present host-native behaviour when it is omitted, so no shipped manifest changes meaning. -Every helper in this group is **pure and lexical**. None touches the -filesystem, none resolves symbolic links, and none grants authority. Normalizing -`../../etc/passwd` produces text, not access; containment remains the -capability layer's responsibility, per section 6.4. +Every helper in this group except `expandvars` is **pure and lexical**. None +touches the filesystem, none resolves symbolic links, and none grants +authority. Normalizing `../../etc/passwd` produces text, not access; +containment remains the capability layer's responsibility, per section 6.4. +`expandvars` is the one environment-observing helper in this RFC, and section +14.7 separates it from the lexical helpers for that reason. #### `parts | path_join(dialect='host')` @@ -1883,7 +1918,7 @@ re-verifies rather than assuming. ## 14. Delivery slices and sequencing This RFC is deliberately not one implementation change. Each slice below -becomes a focused child issue that can ship, be reviewed, and be reverted +becomes a focused child RFC that can ship, be reviewed, and be reverted independently, and each carries the full cross-cutting contract from section 6 rather than saying only "match Ansible". @@ -2034,6 +2069,51 @@ Every slice must satisfy all of the following before it merges: - green `make check-fmt`, `make lint`, `make doc-coverage`, `make test`, `make markdownlint`, and `make nixie` on the merge commit. +### 14.13. Coverage map + +Each row below allocates one or more capability groups to one focused child +RFC, which carries the full cross-cutting contract from section 6 for the +helpers it owns. The map's boundaries are the roadmap's rather than section +8's, and the two do not coincide in either direction: rows `0015` and `0016` +each own a pair of whole groups, because one roadmap step delivers both, while +rows `0017` and `0018` divide two groups between them, because section 8.6 and +section 8.7 sit on opposite sides of the pure/observing boundary. The rows +partition the accepted set: every helper section 8 specifies — including +`basename`, `dirname`, and `glob`, which gain an option rather than being +introduced — appears in exactly one row, and no row claims a helper section 8 +does not specify. Delivery is tracked by the roadmap step each row names, per +roadmap task 6.1.1. + +The `Owns` column is a grammar rather than a list, so the allocation cannot +drift from section 8 as it is edited: + +- `` `8.6` `` claims every helper section 8.6 specifies; +- `` `8.6` except `expandvars` `` claims all of them but the named one; +- `` `8.7` only `abs` `` claims the named one alone; and +- clauses separated by `;` are unioned. + +The `Status` column reads `unwritten` while the child RFC does not exist and +`written` once it does, and the `Child RFC` cell — not `Status` — carries the +relative link to it. A repository test asserts that the two agree, that the +rows partition the accepted set, that no rejected or deferred name reaches a +child registry, and that no helper in this map is missing from the roadmap step +that owns it. The convention and its amendment procedure are recorded in +[ADR-040](../adr-040-focused-child-rfcs-for-survey-rfcs.md). + +| Child RFC | Title | Owns | Optioned | Roadmap step | Status | +| --------------------------------------------------- | ----------------------------------------------- | ------------------------------------------- | --------------------- | ------------ | --------- | +| [0013](0013-structured-data-interchange-helpers.md) | Structured data interchange helpers | `8.1` | — | 6.2 | written | +| `0014` | Mapping and sequence transform helpers | `8.2` | — | 6.3 | unwritten | +| `0015` | Ordered collection algebra and truth predicates | `8.3`; `8.8` | — | 6.4 | unwritten | +| `0016` | Pattern and version predicates | `8.4`; `8.5` | — | 6.5 | unwritten | +| `0017` | Lexical path composition | `8.6` except `expandvars`; `8.7` only `abs` | `basename`; `dirname` | 6.6 | unwritten | +| `0018` | Host-state predicates and environment expansion | `8.7` except `abs`; `8.6` only `expandvars` | `glob` | 6.7 | unwritten | +| `0019` | Encoding, identity, and formatting helpers | `8.9` | — | 6.8 | unwritten | +| `0020` | Date and time conversion helpers | `8.10` | — | 6.9 | unwritten | + +_Table 16: Allocation of the accepted set to focused child RFCs and roadmap +steps._ + ## 15. Alternatives considered ### 15.1. Adopt the Ansible surface wholesale, names and all @@ -2117,12 +2197,14 @@ Windows host needs when generating paths for a Unix target. `strftime` are pure. Roadmap item 7.1.1 supplied it: `now()` reads through a `ClockProvider` held by `StdlibConfig`, classified in the [ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11. + The seam is answered there rather than here, and RFC 0020 — which owns + `to_datetime` and `strftime` — neither needs it nor depends on 7.1.1. ## 17. Recommendation Adopt this RFC as the specification for Netsuke's Ansible-inspired standard-library expansion, and schedule the slices in section 14 as focused -child issues after v0.1.0 final. +child RFCs after v0.1.0 final. The case for adopting rather than deferring is that the gaps in section 2 are not stylistic. Each one currently resolves to `shell()`, and each such diff --git a/docs/rfcs/0007-netsukefile-testing-framework.md b/docs/rfcs/0007-netsukefile-testing-framework.md index b095edf7c..39957e1e8 100644 --- a/docs/rfcs/0007-netsukefile-testing-framework.md +++ b/docs/rfcs/0007-netsukefile-testing-framework.md @@ -59,10 +59,11 @@ parallel one; the technical design records the consequences. What is missing: a mechanism to substitute manifest macros, any test dialect, discovery, mock engine, fixture lifecycle, or `test` subcommand. (The clock seam for `now()` that this section originally listed was supplied by roadmap -item 7.1.1; the [technical design](netsuke-test-framework-technical-design.md) -§5.2 records the implemented shape.) The manifest schema rejects unknown -top-level keys, so the proposed `tests` configuration block is a schema -addition with compatibility consequences (see below). +item 7.1.1; the +[technical design](../netsuke-test-framework-technical-design.md) §5.2 records +the implemented shape.) The manifest schema rejects unknown top-level keys, so +the proposed `tests` configuration block is a schema addition with +compatibility consequences (see below). ## Goals and non-goals diff --git a/docs/rfcs/0013-structured-data-interchange-helpers.md b/docs/rfcs/0013-structured-data-interchange-helpers.md new file mode 100644 index 000000000..a75cb0325 --- /dev/null +++ b/docs/rfcs/0013-structured-data-interchange-helpers.md @@ -0,0 +1,470 @@ +# RFC 0013: Structured data interchange helpers + +## Preamble + +- **RFC number:** 0013 +- **Amends:** RFC 0006, sections 6.7 and 8.1 +- **Status:** Proposed +- **Created:** 2026-09-24 +- **Parent RFC:** [RFC 0006, Ansible-inspired template standard-library + expansion](0006-ansible-inspired-template-standard-library.md) +- **Roadmap step:** 6.2 +- **Originating issue:** [#596](https://github.com/leynos/netsuke/issues/596) + (closed) +- **Release target:** v0.1.x or later; must not widen the v0.1.0 hardening + release defined by [#594](https://github.com/leynos/netsuke/issues/594) + +## 1. Summary + +This RFC specifies the structured data interchange group: `from_json`, +`from_yaml`, `from_yaml_all`, `to_yaml`, and `to_nice_json`. Together they let +a manifest read the metadata `cargo metadata` emits, a compiler's JSON output, +a YAML package manifest, or a generated configuration fragment, and write a +fragment back, without invoking `jq`, `yq`, or a scripting runtime. All five +are pure, so all five are available to manifest queries as well as to target +recipes, and none needs a capability handle. RFC 0006 section 14.2 defines this +group as slice 1, and section 14.11's recommended first wave places slice 0 +ahead of it, because the remaining groups read their inputs in the forms it +produces. + +## 2. Problem + +A manifest that needs one field from a compiler's JSON metadata has, today, one +route: `shell()` out to `jq`, `yq`, Python, or Ruby and capture the output. RFC +0006 section 2 records the cost. Each such call converts a pure, cacheable, +capability-free planning expression into a subprocess with ambient authority, +imports a host dependency that the build description never declared, and adds +an escaping surface between the tool's output and the template that consumes +it. The same applies in reverse: a manifest that generates a configuration +fragment has no way to emit it but to interpolate text with no guarantee about +quoting, so a value reading `no` can silently become a YAML boolean. + +The contortion is not incidental to build manifests; it is the ordinary case. +Compiler metadata is JSON, package manifests and generated configuration are +YAML in most of the ecosystems Netsuke targets, and Rust's own `cargo metadata` +is JSON. RFC 0006 section 15.2 considers and rejects adding nothing here, on +the grounds that it leaves `netsuke help targets` unable to answer questions it +should be able to answer purely. + +This group addresses the JSON and YAML halves. A TOML package manifest is +outside it: RFC 0006 accepts no TOML parser, and Cargo metadata is available as +JSON without one, so a manifest that needs it reads +`cargo metadata --format-version 1` through `fetch` rather than parsing +`Cargo.toml` directly. + +## 3. Goals and non-goals + +- Goals: + - Read one JSON document into native MiniJinja values, preserving object + order, and reject a duplicate key rather than let the last value win. + - Read one YAML document, or a multi-document stream materialized in full, + over the `serde-saphyr` stack [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md) + already adopts. + - Serialize deterministically to block-style YAML and to pretty-printed JSON, + with string scalars quoted so they cannot be read back as another type. + - Test both round trips under RFC 0006 section 6.7 canonical equality for + values in its canonical-JSON domain, rather than relying on examples. +- Non-goals: + - `to_json`: rejected in RFC 0006 section 7 as redundant with MiniJinja's + `tojson`, which remains the compact serializer. + - `to_nice_yaml`: rejected in RFC 0006 section 10.2 as differing from + `to_yaml` only in a default indent. Whether that rejection is expressed as + outright absence or as a diagnostic-raising registration is carried as open + question 1. + - `from_ini`, `from_csv`, and every other parser for a format no consumer has + named. RFC 0006 section 9 defers by evidence bar, and none of these has one. + - Ansible's loader behaviour: unsafe strings, vault tags, and the order- + dependent duplicate-key tolerance RFC 0006 section 13.3 lists. + - Merge keys (`<<`), which are ambiguous with `combine`'s explicit list policy + and recursion flag, and are rejected rather than merged. + - A lazy iterator over a YAML stream. `from_yaml_all` materializes. + +## 4. Capability set + +Five helpers, all filters, all pure, all newly registered. + +- `from_json` — parse one JSON document into native values. Contract in + [RFC 0006 section 8.1](0006-ansible-inspired-template-standard-library.md#81-structured-data-interchange). +- `from_yaml` — parse exactly one YAML document into native values. Contract in + the same subsection. +- `from_yaml_all` — parse a multi-document YAML stream into a materialized + sequence. Contract in the same subsection. +- `to_yaml` — serialize a value as deterministic block-style YAML. Contract in + the same subsection. +- `to_nice_json` — pretty-print a value as JSON. Contract in the same + subsection. + +This section does not restate any contract. Each helper's kinds, options, +rejection conditions, and bounds are specified in RFC 0006 section 8.1, and +what follows in section 5 is how this group meets the cross-cutting clauses +rather than what the helpers do. + +## 5. Cross-cutting contract conformance + +This section discharges RFC 0006 section 6 for the five helpers section 4 +lists. Where a clause's consequence follows from RFC 0006 alone and is the same +for every group, the subsection says so in one line; where this group forces a +decision, the subsection states the decision. + +### 5.1. Registry + +| Helper | Namespace | Registration | Purity class | Manifest query | +| --------------- | --------- | ------------ | ------------ | -------------- | +| `from_json` | Filter | New | Pure | Yes | +| `from_yaml` | Filter | New | Pure | Yes | +| `from_yaml_all` | Filter | New | Pure | Yes | +| `to_yaml` | Filter | New | Pure | Yes | +| `to_nice_json` | Filter | New | Pure | Yes | + +All five rows are `New`: this group introduces five helpers and extends none. +All five are pure, so all five fall inside RFC 0006 section 6.1's 52, and this +RFC accounts for 5 of them. + +### 5.2. Manifest-query availability + +All five are pure, so `from_json`, `from_yaml`, `from_yaml_all`, `to_yaml`, and +`to_nice_json` all register in `register_query_helpers`, and none registers in +`register_disabled_query_helpers` as a stub. The consequence is that +`netsuke help targets` gains five working helpers and no new always-failing +stub. One caveat, carried rather than resolved here: if RFC 0006 section 16 +question 1 is answered by registering `to_nice_yaml` solely to raise a +diagnostic naming `to_yaml(indent=4)`, that registration introduces no accepted +helper and so is not a section 5.1 row; it is decided in section 8. + +### 5.3. Determinism + +Two group-specific obligations follow from clause 6.3, and both are testable +without reading prose. + +- **Key order is an output, not a side effect.** `from_json` preserves object + order because `serde_json` is built with `preserve_order`; `from_yaml` + preserves mapping order; `to_yaml` and `to_nice_json` emit insertion order + unless `sort_keys=true`. A round trip therefore returns a mapping in the + order it went in, and the tests assert order, not merely equality. +- **Trailing-newline behaviour is a contract, not a convention.** `to_yaml` + ends with exactly one trailing newline and `to_nice_json` with none, so one + composes as a whole document and the other composes inside a larger one. The + asymmetry is deliberate and is pinned by a test at each end. + +### 5.4. Capability boundary + +No additional obligation beyond RFC 0006 section 6.4. The clause's substantive +rules all address helpers that reach outside their arguments, and no helper in +this group does: none takes a `cap_std` handle, none takes an injected reader, +and none can violate the clause's trapdoor rule about a filesystem predicate +reporting `false` for an out-of-scope path. + +### 5.5. Platform contract + +No additional obligation beyond RFC 0006 section 6.5. The clause's obligations +attach to helpers whose behaviour varies by platform or that parse another +platform's syntax: no helper here takes a `dialect` argument, none can fail +with a platform diagnostic, and all five emit LF on every platform. + +### 5.6. Type and error contract + +RFC 0006 section 8.1 specifies the kinds each helper accepts. What follows is +the conditions under which a kind, a key, or an option value is rejected, each +carrying a code from section 5.9. Per helper, because the three parsing helpers +and the two serializers do not share a rejection set. + +- `from_json` accepts a string. It rejects `wrong_kind`, `syntax`, + `duplicate_key`, `depth_exceeded`, and `length_exceeded`. +- `from_yaml` accepts a string. It rejects every `from_json` condition, and adds + `unsupported_key` for a sequence or mapping key, `special_tag`, `merge_key`, + `alias_budget`, and `document_count` for a stream that is not exactly one + document. +- `from_yaml_all` accepts a string and applies every per-document condition from + `from_yaml` to each document. `document_count` is the one condition it does + **not** inherit: RFC 0006 section 8.1 makes a stream of zero documents an + empty sequence, not an error, and multi-document input is the whole point of + the helper. The input-length and node budgets apply to the whole stream + rather than to each document. +- `to_yaml` accepts any value except undefined. It rejects `undefined_input` + and `indent_out_of_range` outright, plus `unsupported_key` when + `sort_keys=true` meets a mapping key with no canonical JSON form, + `unsupported_kind` for a value that has none, and `output_too_large` when the + rendered output would exceed the ceiling section 5.8 enforces. +- `to_nice_json` accepts any value except undefined, and rejects the same five + conditions as `to_yaml`, with the difference that section 8.1 states its key + rule directly: integer and boolean keys are rendered in canonical string form + and every other key kind is rejected rather than coerced. It also rejects + distinct source keys that render to one JSON key, with `duplicate_key`. + +Three decisions this group adds: + +- **`none` is accepted; undefined is not.** Clause 6.6 makes undefined an error + and makes `none` a value. Every helper here follows that split, and the line + between them is where a manifest author is most likely to be surprised, so + each of the five documents it explicitly rather than leaving the clause to + imply it. +- **A duplicate key is rejected with a position.** `from_json` and `from_yaml` + name the duplicated key and the offset of its second occurrence. This is the + one place the group adds detection the underlying parser does not perform, + and the reason is that last-key-wins is a silent data-loss trapdoor in a + build manifest rather than a tolerable convenience. +- **The two `indent` ranges differ on purpose.** `to_yaml` accepts 1 to 8 and + `to_nice_json` accepts 0 to 8. Zero is meaningful for JSON, where it means + compact output, and meaningless for block YAML, where it would mean no + indentation at all. Both reject an unknown value with an error enumerating + the accepted range, satisfying clause 6.6's enumerated-option rule and + roadmap task 3.15.5. + +### 5.7. Canonical value equality + +Clause 6.7 governs serializer key sorting and canonical round-trip claims. The +obligation here is to say which of the clause's exclusions this group can meet, +and to define the domain of its round-trip guarantees. This RFC amends RFC 0006 +sections 6.7 and 8.1 to make that domain explicit. + +- `sort_keys=true` sorts mapping keys by their RFC 8785 canonical key, so two + mappings that differ only in insertion order serialize identically. That is + clause 6.7's relation applied to a key-ordering decision, and it is why the + option can promise deterministic output at all. +- The clause excludes undefined, callables, and the `now()` timestamp object, + because none has a canonical JSON form. Undefined is already rejected on + input by clause 6.6; the other two are rejected on output as + `unsupported_kind`, because a manifest can hold a callable or the result of + `now()` and pass it to a serializer, and clause 6.7 makes that a typed error + naming the value kind rather than a silent rendering. +- The group defines **no second equality relation** for round-trip testing. + Both `value | to_yaml | from_yaml` and `value | to_nice_json | from_json` are + asserted equal under clause 6.7 only for values in its canonical-JSON domain: + JSON-compatible values whose mapping keys are strings at every nesting level. + The serializer may accept other values, but their conversion does not promise + canonical equality. +- **Integer and boolean JSON keys convert lossily.** A single integer key `1` + serializes as the JSON property `"1"`; a single boolean key `true` serializes + as `"true"`. `from_json` reads either as a string key, so neither input is + within the JSON round-trip guarantee. The same applies when a mapping with + such keys is nested inside a sequence. String-keyed mappings, including + nested mappings, remain within the guarantee. +- **Rendered-key collisions remain errors.** A mapping containing integer key + `1` alongside string key `"1"`, or boolean key `true` alongside string key + `"true"`, is rejected with `duplicate_key`, naming both source keys. The + check is on rendered keys because that is where the collision occurs. + +The acceptance cases make the boundary concrete: + +| Input mapping | Result | Canonical round trip | +| ------------------------------- | ----------------------------- | -------------------- | +| `{"name": "value"}` | String key preserved | Equal | +| `{"outer": {"inner": "value"}}` | Nested string keys preserved | Equal | +| `{1: "value"}` | Key becomes `"1"` | Lossy; excluded | +| `{true: "value"}` | Key becomes `"true"` | Lossy; excluded | +| `[{1: "value"}]` | Nested key becomes `"1"` | Lossy; excluded | +| `{1: "a", "1": "b"}` | Rejected with `duplicate_key` | Not serialized | +| `{true: "a", "true": "b"}` | Rejected with `duplicate_key` | Not serialized | + +### 5.8. Resource bounds + +The bounds are RFC 0006 table 3's, applied through checked comparison before +allocation. What this group adds is where each one is enforced, because the +JSON and YAML parsers do not share a code path. + +| Helper | Bounds enforced | +| --------------- | ---------------------------------------------------- | +| `from_json` | input 8 MiB; nesting depth 128 | +| `from_yaml` | input 8 MiB; depth 128; alias expansion 100000 nodes | +| `from_yaml_all` | input 8 MiB; depth 128; alias expansion 100000 nodes | +| | — the same three, applied to the stream as a whole | +| `to_yaml` | output 8 MiB; checked before the result is returned | +| `to_nice_json` | output 8 MiB; checked before the result is returned | + +Three consequences this group decides: + +- **`from_yaml_all`'s budget is stream-wide.** A stream of many small documents + is bounded in total, so a caller cannot evade the input ceiling by splitting + an expansion bomb across document boundaries. The diagnostic reports the + stream total rather than a per-document figure, which is what makes the bound + auditable after the fact. +- **The alias budget is the one bound that can fail the implementation slice + rather than the input.** Clause 6.8 requires the expansion to be rejected + before allocating, and if `serde-saphyr` cannot bound alias expansion then + this RFC rejects aliases outright and records that in the guide, per RFC 0006 + section 8.1 and roadmap task 6.2.2. That choice is carried as section 8's + open question 4 and is not resolved here. +- **The serializers bound their output because a shared value is not a bounded + input.** An earlier draft of this group read "none" into both rows, on the + reasoning that a serializer allocates proportionally to nothing but the value + it is handed. That reasoning is wrong, and the runtime shows how: MiniJinja + values are reference-counted, so a value built by repeated doubling is a + graph whose *logical* size is exponential in its construction depth, while + its in-memory footprint stays linear. One recursive macro with twenty + doublings emits `{{ v | to_nice_json }}` as **4,194,301** bytes — 2× per + level, from a four-line template with no large input anywhere. Clause 6.8 + names "materialized output" precisely for this and requires the rejection + *before* allocating, so counting bytes as they are written is not enough. + Each serializer counts first: a pass walks the value computing the output + length with checked arithmetic, abandoning the walk the moment the running + total passes the ceiling, so a doubled value stops after 8 MiB of *logical* + nodes instead of expanding. Only a value that fits is then written. The + failure is `output_too_large`, at the same 8 MiB ceiling the parsers apply to + input. + +### 5.9. Diagnostics and localization + +The group defines one private domain error enum, `InterchangeError`, and +exactly one `impl From<InterchangeError> for minijinja::Error`, per clause 6.9. +Every message is a Fluent key and every error carries a machine code. The codes +are this group's contribution to clause 6.9's policy, so they are enumerated +rather than described. + +| Condition | Code | +| -------------- | -------------------------------------------------- | +| not a string | `netsuke::jinja::interchange::wrong_kind` | +| syntax | `netsuke::jinja::interchange::syntax` | +| duplicate key | `netsuke::jinja::interchange::duplicate_key` | +| key kind | `netsuke::jinja::interchange::unsupported_key` | +| special tag | `netsuke::jinja::interchange::special_tag` | +| merge key | `netsuke::jinja::interchange::merge_key` | +| alias budget | `netsuke::jinja::interchange::alias_budget` | +| document count | `netsuke::jinja::interchange::document_count` | +| depth | `netsuke::jinja::interchange::depth_exceeded` | +| length | `netsuke::jinja::interchange::length_exceeded` | +| undefined | `netsuke::jinja::interchange::undefined_input` | +| indent | `netsuke::jinja::interchange::indent_out_of_range` | +| value kind | `netsuke::jinja::interchange::unsupported_kind` | +| output length | `netsuke::jinja::interchange::output_too_large` | + +Each code's Fluent key is the code's reason in upper snake case under +`STDLIB_INTERCHANGE_`, so `wrong_kind` pairs with +`STDLIB_INTERCHANGE_WRONG_KIND` and `indent_out_of_range` with +`STDLIB_INTERCHANGE_INDENT_OUT_OF_RANGE`, per clause 6.9's +`keys::STDLIB_<MODULE>_<CONDITION>` form. + +The module segment is `interchange` rather than `json` or `yaml`, because one +enum serves both parsers and both serializers and the code names the capability +group, not the syntax. All five helpers — `from_json`, `from_yaml`, +`from_yaml_all`, `to_yaml`, and `to_nice_json` — reach their errors through +this enum: every `Error::new` call in the group's leaf functions is replaced by +a variant of it, so a caller can tell an interchange failure from a manifest +diagnostic by the code alone. Clause 6.9 rejects ad hoc construction at this +scale, and a group with fourteen conditions is the case it names. + +### 5.10. Naming and alias policy + +No additional obligation beyond RFC 0006 section 6.10. The clause registers one +name per capability and this group adds five, none an alias. The group does, +however, put the clause under real pressure in a way no other group does, and a +reviewer should see why the outcome is still five names rather than six or four. + +`to_json` is **rejected** by RFC 0006 section 10.2 as an alias of MiniJinja's +existing `tojson`, while `to_nice_json` is **accepted** in section 8.1. The +obvious reading — that `to_nice_json` is the pretty-printer for a helper this +set does not add — is wrong, and the reason is worth stating once. `tojson` and +`to_nice_json` differ in *kind*, not in degree: `tojson` is compact and +whitespace-free by construction, and `to_nice_json(indent=…)` is the same +serialization with a layout parameter `tojson` does not accept. A parameterless +`to_json` would therefore be a true alias and is correctly rejected, whereas +`to_nice_json` carries an argument surface that makes it a distinct capability. +The near-miss is that `indent=0` *does* reproduce `tojson` exactly, so the two +overlap at one point of that parameter space. That overlap is deliberate and +harmless — it is what makes `indent=0` a usable "compact, but reachable through +the Netsuke helper" path — but it is the reason clause 6.10's one-name-per- +capability line cannot be read as one-*output*-per-capability. + +The clause's other live case is unsettled rather than resolved: `to_nice_yaml` +is rejected by RFC 0006 section 10.2 as redundant with `to_yaml(indent=...)`, +and whether that rejection is expressed as outright absence or as a +diagnostic-raising registration is RFC 0006 section 16 question 1, carried +unresolved to section 8 and decided at roadmap task 6.2.3. Note that the +asymmetry with JSON is intended and not an inconsistency: `to_nice_yaml`'s +redundancy is with `to_yaml`, a helper this standard library *already* adds, so +the rejection costs a caller nothing it cannot already reach; `to_json`'s +redundancy is with a MiniJinja builtin that is likewise already reachable. + +### 5.11. Documentation and testing obligations + +No additional obligation beyond RFC 0006 section 6.11. The clause's seven +obligations apply unmodified. One group-specific note rather than a +restatement: the two round-trip properties required by clause 6.11.4 use the +canonical-JSON domain stated in section 5.7. The serializer's lossy integer and +boolean key cases and collision errors are separate acceptance tests. The +serialization-determinism property it names is the same proposition as section +5.3's key-order requirement, tested from the other side. + +### Clause discharge + +| Clause | Discharge | +| ------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `6.1` | Five pure `New` helpers; the five section 5.1 rows are 5 of 52. | +| `6.2` | All five pure, so all register in `register_query_helpers`, none stubbed. | +| `6.3` | Mapping order in and out; one trailing newline for `to_yaml`, none for `to_nice_json`. | +| `6.4` | No filesystem, environment, or subprocess access; no handle taken. | +| `6.5` | No `dialect` argument; all five emit LF everywhere. | +| `6.6` | Undefined rejected, `none` accepted; duplicates rejected positionally; both `indent` ranges enumerated. | +| `6.7` | `sort_keys` sorts by canonical key; round trips use the canonical-JSON domain; converted JSON keys are lossy and excluded. | +| `6.8` | Table 3's input and depth bounds, stream-wide for `from_yaml_all`, plus the alias budget; both serializers pre-check their output length. | +| `6.9` | One enum, one `From` impl, fourteen `netsuke::jinja::interchange::*` codes. | +| `6.10` | Five new names, no alias family, none reused across namespaces. | +| `6.11` | The clause's seven obligations, plus the two round trips and the determinism property. | + +## 6. Dependencies + +No new crate. This group is the one RFC 0006 section 13.4 adds nothing for: it +uses `serde_json` with `preserve_order` for the JSON half and the existing +`serde-saphyr` stack for the YAML half, both of which Netsuke already carries, +plus `serde_json_canonicalizer` for `sort_keys=true`'s canonical key. Adding no +dependency is one reason the group can lead the wave. + +Within the RFC set it requires the shared contract RFC 0006 section 14.1's +"slice 0" describes, which roadmap steps 6.1.3 and 6.1.4 deliver: the +bounded-parser helper the two parsers share. Sections 14.2, 14.3, 14.4 and 14.9 +are the four that state a slice 0 requirement; sections 14.5 to 14.8 and 14.10 +state none. It requires no other child RFC, and no other child RFC requires it. + +## 7. Delivery + +Roadmap step 6.2, which implements this RFC in three tasks: + +- 6.2.1. `from_json`, with duplicate-key rejection and source offsets. +- 6.2.2. `from_yaml` and `from_yaml_all`, over the existing safe YAML stack. +- 6.2.3. `to_yaml` and `to_nice_json`, with pinned output. + +Each task carries the acceptance criteria that make this RFC checkable: the +duplicate-key diagnostic naming the key and its second offset, the +alias-expansion bomb failing with a bounded-resource diagnostic rather than +exhausting memory, and round-trip property tests over the canonical-JSON domain +alongside the YAML 1.1 `yes`, `no`, `on`, and `off` spellings being unable to +reach a generated file unquoted. + +## 8. Open questions + +Two of RFC 0006 section 16's questions are assigned to this group and are +carried unresolved rather than answered here. + +- **Question 1: is rejecting `to_nice_yaml` correct?** RFC 0006 section 10.2 + rejects it as redundant with `to_yaml(indent=4)`. The counter-argument is + discoverability: an Ansible-literate author will reach for the name and + MiniJinja's "unknown filter" error will not help them. The intermediate + option is to register it solely to raise a typed diagnostic naming + `to_yaml(indent=4)`. This RFC does not settle it; roadmap task 6.2.3 does, + before the serializer registers. Section 5.2 records the consequence either + way: the diagnostic-raising registration introduces no accepted helper, so it + is not a section 5.1 row. +- **Question 4: can `serde-saphyr` bound alias expansion?** RFC 0006 section + 8.1 requires either a bounded expansion budget or outright rejection of + aliases. Which applies is a fact about the dependency and must be established + during the implementation slice, not assumed here. Section 5.8 records both + outcomes as obligations; roadmap task 6.2.2 establishes which holds and + records it in the standard-library guide. + +Both questions were open when RFC 0006 was written and neither is a defect in +it: each names work the implementing slice must do, and each is answered by a +roadmap task rather than by a child RFC. + +## 9. Recommendation + +This group should be implemented first, at v0.1.x or later. RFC 0006 section +14.11's recommended first wave names slice 0 ahead of it and places `from_json` +sixth of its seven entries; this group leads the wave because it is the only +one whose prerequisite is slice 0 alone, it adds no crate, and it removes the +most common reason a manifest reaches for `shell()` at all. The group's five +helpers are also the ones whose absence is least defensible in a build tool: +reading a JSON field is not a template language feature request, it is the +minimum needed to consume the metadata that compilers and package managers +already emit. Implementing it first also exercises the whole contract at its +smallest: five pure filters with no capability handle, no platform variation, +and no dialect, which is exactly the shape that shows whether the cross-cutting +clauses in section 5 carry content or merely restate RFC 0006. diff --git a/docs/roadmap.md b/docs/roadmap.md index 9d982c283..7bb73ae3d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -835,14 +835,21 @@ boundary, can carry every later helper, or whether each capability group needs its own. Its outcome decides whether steps 6.2 to 6.9 can be reviewed as ordinary additions or need individual design passes. See RFC 0006 §§6 and 14.1. -- [ ] 6.1.1. Split the RFC 0006 accepted set into focused child issues. - - See RFC 0006 §14. - - Give each issue the full cross-cutting contract from RFC 0006 §6 rather - than a reference to Ansible. - - Record a release target of v0.1.x or later for each issue. +- [ ] 6.1.1. Split the RFC 0006 accepted set into focused child RFCs and + accompanying roadmap tasks. + - See RFC 0006 §14 and + [the execplan](execplans/6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task.md). + - Give each RFC the full cross-cutting contract from RFC 0006 §6 rather + than a reference to Ansible. Discharging each clause for the RFC's own + helpers satisfies this; citing §6 without discharging it does not. + - Record a release target of v0.1.x or later for each RFC. + - Track delivery through the roadmap tasks in steps 6.2 to 6.9 rather than + through separate issues, so progress stays in committed documentation. - Success: every accepted capability in RFC 0006 §7 is covered by exactly - one open child issue, and every deferred or rejected candidate is covered - by none. + one child RFC and by at least one accompanying roadmap task, and every + deferred or rejected candidate is covered by neither. A capability may be + named by more than one roadmap task, as `product` already is by 6.4.2 and + 6.4.5, so only the RFC count is exactly one. - [ ] 6.1.2. Implement the canonical value key and equality relation. - See RFC 0006 §6.7. - Derive the key with the existing `serde_json_canonicalizer` dependency and @@ -912,19 +919,22 @@ This step answers whether a manifest can consume compiler metadata, package manifests, and generated configuration fragments directly, or whether `jq`, `yq`, and a scripting runtime remain unavoidable host assumptions. Its outcome determines how much surrounding toolchain a `Netsukefile` still has to assume. -See RFC 0006 §8.1 and +See RFC 0006 §8.1, delivered by +[RFC 0013](rfcs/0013-structured-data-interchange-helpers.md), and [adr-001-replace-serde-yml-with-serde-saphyr.md](adr-001-replace-serde-yml-with-serde-saphyr.md). - [ ] 6.2.1. Add `from_json` with duplicate-key rejection and source offsets. Requires 6.1.3 and 6.1.4. - - See RFC 0006 §8.1. + - See RFC 0006 §8.1 and + [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §7. - Preserve object order and report line, column, and byte offset on failure. - Success: a document with a repeated object key fails naming the key and the offset of its second occurrence, rather than silently keeping the last value. - [ ] 6.2.2. Add `from_yaml` and `from_yaml_all` over the existing safe YAML stack. Requires 6.2.1. - - See RFC 0006 §8.1. + - See RFC 0006 §8.1 and + [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §§5.8 and 7. - Reject non-standard tags, duplicate keys, and merge keys, and materialize the multi-document result rather than exposing a lazy iterator. - Establish whether `serde-saphyr` can bound alias expansion; if it cannot, @@ -933,7 +943,9 @@ See RFC 0006 §8.1 and instead of exhausting memory. - [ ] 6.2.3. Add the deterministic `to_yaml` and `to_nice_json` serializers. Requires 6.2.2. - - See RFC 0006 §§6.3 and 8.1. + - See RFC 0006 §§6.3 and 8.1, and + [RFC 0013](rfcs/0013-structured-data-interchange-helpers.md) §§5.3, 5.7, and + 8. - Pin key ordering, indentation, scalar quoting, line endings, and trailing-newline behaviour, quoting every scalar that could be read back as a boolean, null, number, or timestamp. diff --git a/tests/makefile_test_target.rs b/tests/makefile_test_target.rs index f0af6d511..d40c663d6 100644 --- a/tests/makefile_test_target.rs +++ b/tests/makefile_test_target.rs @@ -37,7 +37,8 @@ use toml::Value; /// runs on its own because that suite needs a per-user systemd manager rather /// than the whole workspace. `test-kani-mutations` runs the same runner over /// the `#[ignore]`-gated mutation compile gate, which is too expensive for the -/// default profile. A contributor sets the bounds once and expects them +/// default profile. `test-rfc-stdlib-coverage` runs the RFC parser and coverage +/// contract in isolation. A contributor sets the bounds once and expects them /// honoured wherever nextest runs, so the list is a contract rather than a /// note of what happens to be true today. /// @@ -45,10 +46,11 @@ use toml::Value; /// [`behavioural_nextest_targets_forward_both_worker_bounds`] discovers the /// targets that actually invoke the runner and fails when the two disagree, so /// a new recipe joins the contract or breaks the build. -const NEXTEST_TARGETS: [&str; 3] = [ +const NEXTEST_TARGETS: [&str; 4] = [ "test-kani-mutations", "test-kani-scope-wrapper", "test-nextest", + "test-rfc-stdlib-coverage", ]; /// True when `line` is a tab-indented recipe line that invokes the nextest diff --git a/tests/rfc_stdlib_coverage/assertions.rs b/tests/rfc_stdlib_coverage/assertions.rs new file mode 100644 index 000000000..8f5ffac19 --- /dev/null +++ b/tests/rfc_stdlib_coverage/assertions.rs @@ -0,0 +1,158 @@ +//! Cross-checks between the derived sets and the words RFC 0006 states them in. +//! +//! These are part of the derivation rather than a separate check, because every +//! one of them compares two places the RFC states the same thing: the row counts +//! against table 11, the class counts against the reject-row count, the +//! new-filter and new-test counts against the tables, and the deny set against +//! the non-vacuity witnesses. A parse that silently returns nothing must fail +//! here. +//! +//! One comparison in this module is not of that shape. The deny set's size is +//! checked against the literal 71, because the complement rule that produces the +//! deny set has no second statement in the document to read a figure from: the +//! rule is this plan's decision `D10`, and 71 is what it yields over RFC 0006's +//! own reject and defer rows. That literal is the assertion. It is not a +//! transcription of a sentence somewhere that a rebase could leave behind, so +//! it is deliberately not moved into the table-11 reads above with the counts +//! that are. + +use anyhow::{Result, ensure}; + +use super::{Namespace, name_set, survey::Survey}; + +/// Assert the derived totals agree with what RFC 0006 states. +pub(super) fn check_against_document(survey: &Survey) -> Result<()> { + // The accept and defer counts are compared against table 11's own rows + // rather than against transcribed literals, because the table is what the + // comparison is between: a literal here would make this an assertion about + // a constant, and would have to be edited by hand every time the survey + // legitimately grew. Table 11 is expected to agree with section 7, and if + // it stops agreeing that is the finding, not a reason to re-read the table. + ensure!( + survey.accept_rows == survey.stated_accept, + "derived {} accept rows in RFC 0006 section 7; table 11 states {}", + survey.accept_rows, + survey.stated_accept + ); + ensure!( + survey.defer_rows == survey.stated_defer, + "derived {} defer rows in RFC 0006 section 7; table 11 states {}", + survey.defer_rows, + survey.stated_defer + ); + // The reject count is the one row count with no table 11 row of its own: + // table 11 splits rejections across three classes, so the sum of those three + // rows is what section 7's reject count has to equal. Both sides of the + // comparison are read from the document, which is why this needs no + // literal — and it is also why the message names both figures rather than + // asserting the equality of one of them to a constant. + let class_sum: usize = survey.reject_classes.iter().sum(); + ensure!( + survey.reject_rows == class_sum, + "derived {} reject rows in RFC 0006 section 7; table 11's classes {:?} sum to {class_sum}", + survey.reject_rows, + survey.reject_classes + ); + ensure!( + survey.new_by_namespace(Namespace::Filter) == survey.new_filters, + "derived {} new filters; table 11 states {}", + survey.new_by_namespace(Namespace::Filter), + survey.new_filters + ); + ensure!( + survey.new_by_namespace(Namespace::Test) == survey.new_tests, + "derived {} new tests; table 11 states {}", + survey.new_by_namespace(Namespace::Test), + survey.new_tests + ); + ensure!( + survey.new_rows.len() == survey.proposed, + "derived {} new helpers; section 6.1 states {}", + survey.new_rows.len(), + survey.proposed + ); + ensure!( + survey.optioned.len() == survey.optioned_total, + "derived {} existing helpers gaining an option; table 11 states {}", + survey.optioned.len(), + survey.optioned_total + ); + ensure!( + survey.accepted.len() == survey.new_rows.len() + survey.optioned.len(), + "the accepted set has {} members, but {} new plus {} optioned is {}", + survey.accepted.len(), + survey.new_rows.len(), + survey.optioned.len(), + survey.new_rows.len() + survey.optioned.len() + ); + Ok(()) +} + +/// Assert the deny set is what the complement rule says it is. +pub(super) fn check_denied(survey: &Survey) -> Result<()> { + ensure!( + survey.denied.len() == 71, + "derived {} forbidden names; the complement rule gives 71", + survey.denied.len() + ); + let must_deny = [ + "is_file", + "is_dir", + "is_link", + "quote", + "fileglob", + "lookup", + "win_dirname", + "expanduser", + ]; + let missing = name_set(must_deny) + .difference(&survey.denied) + .cloned() + .collect::<Vec<_>>(); + ensure!( + missing.is_empty(), + "the deny set omits {missing:?}, which section 7 or section 9 forbids" + ); + let must_allow = [ + "basename", + "dirname", + "abs", + "glob", + "shell_quote", + "splitdrive", + "text_hash", + ]; + let wrongly_denied = must_allow + .iter() + .filter(|name| survey.denied.contains(**name)) + .collect::<Vec<_>>(); + ensure!( + wrongly_denied.is_empty(), + "the deny set forbids {wrongly_denied:?}, which are accepted helpers the registries must \ + carry" + ); + // Both sets, not just `accepted`: `hash` is a *surveyed* spelling whose + // registered name is `text_hash`, so it is not in the accepted set by + // construction. What the message below claims is that the surveyed spelling + // reached neither set, and a future reject row naming `hash` would put it in + // `denied` — which is the case a check on `accepted` alone cannot see. + let hash_sets = [ + (survey.accepted.contains_key("hash"), "the accepted set"), + (survey.denied.contains("hash"), "the deny set"), + ]; + let present = hash_sets + .iter() + .filter_map(|(present, name)| present.then_some(*name)) + .collect::<Vec<_>>(); + ensure!( + present.is_empty(), + "`hash` is an existing Netsuke helper that RFC 0006 leaves unchanged, so it must be \ + neither accepted nor denied; it is in {}", + present.join(" and ") + ); + Ok(()) +} + +#[cfg(test)] +#[path = "assertions_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/assertions_tests.rs b/tests/rfc_stdlib_coverage/assertions_tests.rs new file mode 100644 index 000000000..6ee69a002 --- /dev/null +++ b/tests/rfc_stdlib_coverage/assertions_tests.rs @@ -0,0 +1,85 @@ +//! Exercise each document-total and deny-set assertion independently. + +use super::*; +use crate::rfc_stdlib_coverage::validation_fixtures as fixture; +use rstest::rstest; + +#[test] +fn accepts_consistent_totals_and_complement() -> Result<()> { + check_against_document(&fixture::survey())?; + check_denied(&fixture::survey()) +} + +#[rstest] +#[case::accept("accept", "derived 1 accept rows")] +#[case::defer("defer", "derived 0 defer rows")] +#[case::reject("reject", "table 11's classes [1, 0, 0] sum to 1")] +#[case::filters("filters", "derived 1 new filters")] +#[case::tests("tests", "derived 0 new tests")] +#[case::proposed("proposed", "derived 1 new helpers")] +#[case::optioned("optioned", "derived 0 existing helpers gaining an option")] +#[case::accepted("accepted", "accepted set has 0 members")] +fn rejects_inconsistent_document_totals(#[case] mutation: &str, #[case] diagnostic: &str) { + let mut survey = fixture::survey(); + match mutation { + "accept" => survey.stated_accept += 1, + "defer" => survey.stated_defer += 1, + "reject" => { + *survey + .reject_classes + .first_mut() + .expect("fixture reject class") += 1; + } + "filters" => survey.new_filters += 1, + "tests" => survey.new_tests += 1, + "proposed" => survey.proposed += 1, + "optioned" => survey.optioned_total += 1, + "accepted" => survey.accepted.clear(), + _ => {} + } + let error = check_against_document(&survey).expect_err("one total changed"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} + +#[rstest] +#[case::count("count", "derived 70 forbidden names")] +#[case::missing("missing", "deny set omits [\"is_file\"]")] +#[case::accepted("accepted", "deny set forbids [\"basename\"]")] +#[case::hash_accepted("hash_accepted", "it is in the accepted set")] +#[case::hash_denied("hash_denied", "it is in the deny set")] +#[case::hash_both("hash_both", "it is in the accepted set and the deny set")] +fn rejects_invalid_complement(#[case] mutation: &str, #[case] diagnostic: &str) { + let mut survey = fixture::survey(); + match mutation { + "count" => { + survey.denied.remove("denied0"); + } + "missing" => { + survey.denied.remove("is_file"); + survey.denied.insert("replacement".into()); + } + "accepted" => { + survey.denied.remove("denied0"); + survey.denied.insert("basename".into()); + } + "hash_accepted" => { + survey + .accepted + .insert("hash".into(), fixture::helper("hash")); + } + "hash_denied" => { + survey.denied.remove("denied0"); + survey.denied.insert("hash".into()); + } + "hash_both" => { + survey + .accepted + .insert("hash".into(), fixture::helper("hash")); + survey.denied.remove("denied0"); + survey.denied.insert("hash".into()); + } + _ => {} + } + let error = check_denied(&survey).expect_err("one complement condition changed"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} diff --git a/tests/rfc_stdlib_coverage/clauses.rs b/tests/rfc_stdlib_coverage/clauses.rs new file mode 100644 index 000000000..8e46e2eb5 --- /dev/null +++ b/tests/rfc_stdlib_coverage/clauses.rs @@ -0,0 +1,262 @@ +//! The cross-cutting contract clauses and their discharge in child RFCs. +//! +//! RFC 0006 section 6 is normative and says so: "A child RFC that does not +//! satisfy every clause below for every helper it adds is not complete." The +//! clause list is therefore derived from the document rather than transcribed — +//! each `### 6.N.` subsection is a clause — and each child RFC must discharge +//! every one of them. +//! +//! The discharge is recorded in a child's section 5 as a two-column table, +//! `Clause | Discharge`, whose clause column holds the clause id in backticks. +//! The set of ids must equal section 6's subsection ids exactly. That is the +//! smallest contract that makes "discharges every clause" checkable without +//! pretending a test can read prose, and it is deliberately not a subsection +//! apiece: the eleven clause subsections record the group's own contract, and +//! the table records how each clause is met. +//! +//! The third vacuity shape `CONF-1` names — a body that justifies a helper by +//! appealing to Ansible — is a judgement about prose rather than about document +//! structure, and it lives in `deference`, whose predicate this module calls +//! through `super`. +//! +//! Split at that seam, and `deference` out of this file, to keep this module +//! under Whitaker's 400-line `module_max_lines` ceiling; the judgement is a +//! different kind of thing from the grading below, which is why the seam is +//! there rather than at an arbitrary line count. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result, ensure}; + +use super::{RFC_0006, Repo, Section, backticked, deference::deference_phrase, strip_backticks}; + +/// The heading of a child RFC's clause-discharge subsection. +/// +/// Unnumbered on purpose. The table resolves all eleven clause subsections, and +/// numbering it `5.6` collided with the clause's own subsection title, "Type and +/// error contract" — the same title RFC 0006 clause 6.6 carries — leaving a +/// reader to reconcile a subsection titled one way with a table row reading +/// `6.6`. It closes section 5 as `### Clause discharge`. +const CLAUSES_HEADING: &str = "### Clause discharge"; + +/// The same heading as `tables()` reports it, without its `###`. +/// +/// The parser resolves the table by the heading directly above it rather than +/// taking the subsection's first table, so a child that places a table under +/// one of the eleven clause subsections cannot have it mistaken for the +/// discharge table. +const TABLE_HEADING: &str = "Clause discharge"; + +/// RFC 0006 section 6's clause ids, in document order. +/// +/// Read through [`Section::subsections`], which is fence-aware, rather than off +/// the section's raw lines. A fenced example quoting a `### 6.12. …` heading +/// would be read as a clause by a raw-line scan, and since every child RFC's +/// discharge table must equal this set exactly, that would fail every child +/// RFC for a heading the document does not actually state. Section 6 carries no +/// fences today; the scan goes through the structure layer so that stays a fact +/// about the document rather than a precondition of this function. +pub(super) fn clause_ids(repo: &Repo) -> Result<Vec<String>> { + let text = repo.read(RFC_0006)?; + let document = Section::whole(&text); + let section = document + .subsection("## 6. Cross-cutting contract") + .context("RFC 0006 has no section 6")?; + let mut ids: Vec<String> = Vec::new(); + for found in section.subsections() { + let id = found + .heading + .split('.') + .take(2) + .collect::<Vec<_>>() + .join("."); + if id.starts_with("6.") && !ids.contains(&id) { + ids.push(id); + } + } + ensure!( + !ids.is_empty(), + "RFC 0006 section 6 has no numbered subsections, so it states no clauses" + ); + Ok(ids) +} + +/// The clause ids a child RFC discharges. +pub(super) fn discharged(repo: &Repo, file: &str) -> Result<BTreeSet<String>> { + let text = repo.read(file)?; + let document = Section::whole(&text); + let section = document + .subsection(CLAUSES_HEADING) + .with_context(|| format!("{file} has no clause-discharge table at {CLAUSES_HEADING}"))?; + // Matched on the heading text rather than by taking the first table found: + // the subsection is located by its own heading, and pairing the table with + // the heading directly above it is what distinguishes it from any table a + // child places under one of the eleven clause subsections above. + let Some((_, rows)) = section + .tables() + .into_iter() + .find(|(heading, rows)| !rows.is_empty() && heading == TABLE_HEADING) + else { + return Err(anyhow::anyhow!( + "the clause-discharge subsection of {file} contains no table" + )); + }; + let mut ids = BTreeSet::new(); + for row in &rows { + let cell = row.cell(0, "clause")?; + let id = strip_backticks(cell).trim().to_owned(); + ensure!( + id.starts_with("6."), + "{file}:{} names clause {id:?}, which is not a section 6 clause", + row.line + ); + ensure!( + !strip_backticks(row.cell(1, "discharge")?).trim().is_empty(), + "{file}:{} discharges {id} with an empty cell", + row.line + ); + // Two rows for one clause would leave the *other* ten clauses covered by + // a set that looks complete, so the duplicate is rejected rather than + // absorbed. `clause_ids` already de-duplicates on the same rule. + ensure!( + ids.insert(id.clone()), + "{file}:{} discharges clause {id} a second time", + row.line + ); + } + Ok(ids) +} + +/// The heading of a child RFC's section 5. +/// +/// The child RFCs carry the same section 5 title RFC 0006's own capability-group +/// sections use, because a child RFC restates one group's section 5 in full. +const SECTION_5_HEADING: &str = "## 5. Cross-cutting contract conformance"; + +/// Locate a child RFC's section 5, and check each of its subsections. +/// +/// Split from [`check_subsections`] so the caller does not have to hold the +/// document open: the text is read here, scanned, and dropped. +pub(super) fn check_section_five( + repo: &Repo, + file: &str, + owned: &BTreeSet<String>, +) -> Result<BTreeSet<String>> { + let text = repo.read(file)?; + let document = Section::whole(&text); + let section = document + .subsection(SECTION_5_HEADING) + .with_context(|| format!("{file} has no section 5 at {SECTION_5_HEADING}"))?; + check_subsections(file, §ion, owned) +} + +/// The subsection heading that closes section 5. +/// +/// [`CLAUSES_HEADING`] carries its `### ` because a caller locates the +/// subsection by full heading text; the scans below compare against heading text +/// alone, so the marker is trimmed once here rather than at four call sites. +const CLAUSES_TITLE: &str = "Clause discharge"; + +/// The exact words `D6` permits in place of a group-specific consequence. +/// +/// The plan's anti-vacuity rule admits one escape: a subsection that has nothing +/// group-specific to add says so, naming the clause it defers to. That sentence +/// is fixed, so it is matched as a fixed string. Only the prefix is compared, +/// because the clause number it names depends on which clause the subsection +/// discharges. +const NO_ADDITIONAL_OBLIGATION: &str = "No additional obligation beyond RFC 0006 section 6."; + +/// Check one child RFC's section 5 against the `CONF-1` obligations. +/// +/// Three shapes of vacuity are mechanical, and each is caught here. The first is +/// the empty stub: a subsection whose body is present but says nothing. The +/// second is the generic discharge, which discusses the clause but never names a +/// helper the RFC owns — the anti-vacuity rule's own test, since a consequence +/// specific to a group of helpers has to name one of them. The third is the +/// appeal to Ansible, which is the survey's subject rather than its conclusion. +/// +/// `owned` is the set the child's registry claims, so a subsection may name any +/// helper it registers and no other. A subsection that names a helper outside +/// that set is the generic discharge wearing a plausible name. +pub(super) fn check_subsections( + file: &str, + section_five: &Section<'_>, + owned: &BTreeSet<String>, +) -> Result<BTreeSet<String>> { + let mut ids = BTreeSet::new(); + for found in section_five.subsections() { + // The discharge table closes section 5 and is graded by `discharged`, + // which reads its own content. Excluding it here is what keeps the two + // checks from disagreeing about one subsection. + if found.heading == CLAUSES_TITLE { + continue; + } + let Some(id) = clause_id_of(&found.heading) else { + continue; + }; + ensure!( + ids.insert(id.clone()), + "{file}:{} is a second section 5 subsection for clause {id}", + found.line + ); + let body = found.body.join("\n"); + ensure!( + !body.trim().is_empty(), + "{file}:{} is subsection {} of section 5 with an empty body", + found.line, + found.heading + ); + let escape = body.contains(NO_ADDITIONAL_OBLIGATION); + let named = names_an_owned_helper(&body, owned); + ensure!( + escape || named, + "{file}:{} is subsection {}, whose body names none of the helpers this RFC owns \ + and does not read {:?}. A reviewer cannot tell it apart from a restatement of \ + the clause it discharges", + found.line, + found.heading, + NO_ADDITIONAL_OBLIGATION + ); + if let Some(phrase) = deference_phrase(&body) { + return Err(anyhow::anyhow!( + "{file}:{} is subsection {}, which justifies a helper by appealing to Ansible \ + ({phrase:?}). RFC 0006 surveys Ansible; it does not adopt its choices", + found.line, + found.heading + )); + } + } + Ok(ids) +} + +/// The clause id a section 5 subsection title names, if it names one. +/// +/// The id is the leading `N.M` token, and requiring that shape is what keeps the +/// scan off a subsection that is not a clause: `5.1. Registry` resolves the +/// clause its title names, whereas a subsection titled `Worked example` resolves +/// nothing and is body, not a clause discharge. +fn clause_id_of(heading: &str) -> Option<String> { + let id = heading.split(' ').next()?.trim_end_matches('.'); + let mut parts = id.split('.'); + let (major, minor) = (parts.next()?, parts.next()?); + (parts.next().is_none() && major.chars().all(|ch| ch.is_ascii_digit())) + .then(|| format!("6.{minor}")) + .filter(|_| minor.chars().all(|ch| ch.is_ascii_digit())) +} + +/// Whether `body` names at least one helper in `owned`. +/// +/// A backticked span rather than a bare word: the registry's helper names are +/// written as code throughout the corpus, and a bare-word scan would read the +/// English words `size`, `hash`, `shell`, and `contents` as helper names in any +/// sentence that happened to use them. Requiring the code span is the whole-token +/// test — a word is either entirely a name or it is not a token this matches. +fn names_an_owned_helper(body: &str, owned: &BTreeSet<String>) -> bool { + backticked(body) + .iter() + .any(|name| owned.contains(name.trim())) +} + +#[cfg(test)] +#[path = "clauses_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/clauses_tests.rs b/tests/rfc_stdlib_coverage/clauses_tests.rs new file mode 100644 index 000000000..a9a2a2db0 --- /dev/null +++ b/tests/rfc_stdlib_coverage/clauses_tests.rs @@ -0,0 +1,200 @@ +//! Direct clause-discharge, anti-vacuity, and whole-token diagnostics. + +use super::*; +use rstest::rstest; + +const FILE: &str = "docs/rfcs/0013-child.md"; + +/// Write an isolated parent or child contract document. +fn fixture(file: &str, text: &str) -> Result<(tempfile::TempDir, Repo)> { + let temp = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temp.path()).context("UTF-8 fixture path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/rfcs")?; + repo.dir.write(file, text)?; + Ok((temp, repo)) +} + +#[rstest] +#[case::missing("## 7. Survey", "has no section 6")] +#[case::no_clauses("## 6. Cross-cutting contract\nProse", "has no numbered subsections")] +fn parent_clause_structure_is_required(#[case] text: &str, #[case] expected: &str) -> Result<()> { + let (_temp, repo) = fixture(RFC_0006, text)?; + let error = clause_ids(&repo).expect_err("missing clauses must fail"); + ensure!(error.to_string().contains(expected), "{error}"); + Ok(()) +} + +#[test] +fn parent_clauses_ignore_fences_other_ids_and_repeated_ids() -> Result<()> { + let text = concat!( + "## 6. Cross-cutting contract\n### 6.1. Registry\n", + "```markdown\n### 6.99. Example\n```\n", + "### 6.1. Repeated\n### Other heading\n### 6.2. Namespace\n", + "## 7. Survey\n### 6.3. Outside\n", + ); + let (_temp, repo) = fixture(RFC_0006, text)?; + { + let actual = clause_ids(&repo)?; + let expected_value = ["6.1", "6.2"]; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[rstest] +#[case::missing_heading("# Child", "no clause-discharge table")] +#[case::no_table("### Clause discharge\nProse", "contains no table")] +fn discharge_structure_is_required(#[case] text: &str, #[case] expected: &str) -> Result<()> { + let (_temp, repo) = fixture(FILE, text)?; + let error = discharged(&repo, FILE).expect_err("missing discharge must fail"); + ensure!(error.to_string().contains(expected), "{error}"); + ensure!(error.to_string().contains(FILE), "{error}"); + Ok(()) +} + +#[rstest] +#[case::bad_id("| `5.1` | `alpha` is pure. |", "not a section 6 clause", 4)] +#[case::empty("| `6.1` | `` |", "empty cell", 4)] +#[case::duplicate( + "| `6.1` | `alpha` is pure. |\n| `6.1` | `alpha` is pure. |", + "a second time", + 5 +)] +#[case::missing_cell("| `6.1` |", "has no discharge column", 4)] +fn bad_discharge_rows_report_source_lines( + #[case] rows: &str, + #[case] expected: &str, + #[case] line: usize, +) -> Result<()> { + let text = format!("### Clause discharge\n| Clause | Discharge |\n| --- | --- |\n{rows}\n"); + let (_temp, repo) = fixture(FILE, &text)?; + let error = discharged(&repo, FILE).expect_err("bad discharge must fail"); + let diagnostic = format!("{error:#}"); + ensure!(diagnostic.contains(expected), "{diagnostic}"); + if expected == "has no discharge column" { + ensure!(diagnostic.contains(&format!("line {line}")), "{diagnostic}"); + } else { + ensure!( + diagnostic.contains(&format!("{FILE}:{line}")), + "{diagnostic}" + ); + } + Ok(()) +} + +#[test] +fn a_discharge_returns_the_clause_set() -> Result<()> { + let (_temp, repo) = fixture( + FILE, + "### Clause discharge\n| Clause | Discharge |\n| --- | --- |\n| `6.1` | `alpha` is pure. |", + )?; + { + let actual = discharged(&repo, FILE)?; + let expected_value = BTreeSet::from(["6.1".into()]); + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[rstest] +#[case::empty("### 5.1. Registry\n", "empty body", 2)] +#[case::generic("### 5.1. Registry\nGeneric obligations apply.", "names none", 2)] +#[case::foreign_helper("### 5.1. Registry\n`beta` is pure.", "names none", 2)] +#[case::fenced_helper( + "### 5.1. Registry\n```\n`alpha`\n```\nGeneric prose.", + "names none", + 2 +)] +#[case::ansible( + "### 5.1. Registry\n`alpha` behaves as Ansible does.", + "appealing to Ansible", + 2 +)] +#[case::duplicate( + "### 5.1. Registry\n`alpha` is pure.\n### 5.1. Duplicate\n`alpha` is pure.", + "second section 5 subsection", + 4 +)] +fn vacuous_subsections_report_source_lines( + #[case] body: &str, + #[case] expected: &str, + #[case] line: usize, +) -> Result<()> { + let owned = BTreeSet::from(["alpha".into()]); + let text = format!("## 5. Cross-cutting contract conformance\n{body}\n"); + let (_temp, repo) = fixture(FILE, &text)?; + let error = check_section_five(&repo, FILE, &owned).expect_err("vacuity must fail"); + let diagnostic = error.to_string(); + ensure!(diagnostic.contains(expected), "{diagnostic}"); + ensure!( + diagnostic.contains(&format!("{FILE}:{line}")), + "{diagnostic}" + ); + Ok(()) +} + +#[test] +fn missing_section_five_is_rejected() -> Result<()> { + let (_temp, repo) = fixture(FILE, "## 4. Design")?; + let error = + check_section_five(&repo, FILE, &BTreeSet::new()).expect_err("missing section must fail"); + ensure!(error.to_string().contains("has no section 5"), "{error}"); + Ok(()) +} + +#[test] +fn explicit_escape_and_substantive_owned_helper_bodies_are_accepted() -> Result<()> { + let text = concat!( + "## 5. Cross-cutting contract conformance\n", + "### 5.1. Registry\n`alpha` is pure.\n", + "### 5.2. Namespace\nNo additional obligation beyond RFC 0006 section 6.2.\n", + "### Worked example\nNot a numbered obligation.\n", + "### Clause discharge\nThe separately checked table.\n", + ); + let owned = BTreeSet::from(["alpha".into()]); + let (_temp, repo) = fixture(FILE, text)?; + { + let actual = check_section_five(&repo, FILE, &owned)?; + let expected_value = BTreeSet::from(["6.1".into(), "6.2".into()]); + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[rstest] +#[case("5.1. Registry", Some("6.1"))] +#[case("Worked example", None)] +#[case("5.1.2. Nested", None)] +#[case("five.1. Registry", None)] +#[case("5.one. Registry", None)] +#[case("5 Registry", None)] +fn subsection_ids_require_numeric_pair_shape( + #[case] heading: &str, + #[case] expected: Option<&str>, +) { + assert_eq!(clause_id_of(heading).as_deref(), expected); +} + +#[rstest] +#[case("`alpha` is pure", true)] +#[case("` alpha ` is pure", true)] +#[case("alpha is pure", false)] +#[case("`alphabet` is pure", false)] +#[case("`alpha()` is pure", false)] +#[case("`beta` is pure", false)] +fn helper_justification_requires_whole_code_tokens(#[case] body: &str, #[case] expected: bool) { + assert_eq!( + names_an_owned_helper(body, &BTreeSet::from(["alpha".into()])), + expected + ); +} diff --git a/tests/rfc_stdlib_coverage/deference.rs b/tests/rfc_stdlib_coverage/deference.rs new file mode 100644 index 000000000..ac9ee08f6 --- /dev/null +++ b/tests/rfc_stdlib_coverage/deference.rs @@ -0,0 +1,216 @@ +//! The lexical half of the `CONF-1` anti-vacuity rule. +//! +//! `CONF-1` asks that a child RFC's section 5 discharge be group-specific rather +//! than a restatement of the clause it discharges. Two of the three vacuity +//! shapes the rule names are structural, and `clauses` grades them: an empty +//! body, and a body naming none of the helpers its RFC owns. The third — a body +//! that justifies a helper by appealing to Ansible — is a judgement about prose +//! rather than about document structure, and it is made here. +//! +//! Split from `clauses` to keep that module under Whitaker's 400-line +//! `module_max_lines` ceiling, not as a new resolution boundary: `clauses` reads +//! [`deference_phrase`] through `super`, exactly as it read the function when +//! both lived in one file. The seam is the kind of judgement rather than the +//! size of the code, which is why the unit tests below travel with it: they pin +//! the predicate against sentences, and every other test in this module tree is +//! exercised through a document. + +/// The claim that a helper exists because Ansible has one. +/// +/// This is literally the acceptance criterion the task exists to enforce: RFC +/// 0006 is a survey of Ansible's standard library, and a child RFC that +/// justifies a helper by appealing to Ansible rather than to Netsuke's own +/// contract has not made a decision, only deferred one. It is a substring +/// search, so leaving it to review would be indefensible. +/// +/// Each phrase is matched at a leading word boundary and case-insensitively. +/// The boundary keeps `Unlike Ansible` — which contains `like Ansible` and says +/// the opposite of what this check looks for — from being flagged as the appeal +/// it argues against. Case is folded because the appeal is as likely to open a +/// sentence as to sit inside one: `As Ansible does` and `as Ansible does` are +/// one phrase, and a case-sensitive search would catch only the second. +/// +/// A leading boundary alone does not clear `such as Ansible`, because `as` is +/// its own word there — the phrase is the tail of the compound preposition — +/// and the sentence introduces an example rather than a justification. That +/// shape is excluded by name in [`deference_phrase`]. A trailing boundary is +/// deliberately *not* required: `as Ansible does` is the shape the check exists +/// to catch, and it is this plan's own recorded seeded fault, so tightening the +/// right-hand side would delete a control that has been proven to fire. +/// +/// Each entry is a `(pattern, display)` pair. The pattern is the lowercase form +/// the folded body is searched for; the display form is what the diagnostic +/// quotes, because `as ansible` in an error message reads as a typo rather than +/// as the phrase the check names. +const DEFERENCE_PHRASES: [(&str, &str); 2] = [ + ("as ansible", "as Ansible"), + ("like ansible", "like Ansible"), +]; + +/// The exemplar idiom that contains `as Ansible` without deferring to it. +/// +/// `such as Ansible` names a source of examples, not a reason to have a helper. +/// Matched immediately before the phrase, on the lowercased text, so the +/// exclusion cannot swallow a genuine appeal that happens to follow one of +/// these words elsewhere in the sentence, and so `Such as Ansible` is cleared +/// as its lowercase form is. +/// +/// Only `such ` is listed. An earlier draft also carried `with ` and `in `, for +/// the shapes `as with Ansible` and `as in Ansible`; both were unreachable. The +/// scan finds `as Ansible` or `like Ansible` and then reads the text *before* +/// the match, so the prefix it tests is the text preceding `as` — for those two +/// shapes that is the empty string, never `with ` or `in `. The entries could +/// not change any answer, and their presence made the doc comment look as +/// though the two shapes were handled when only the boundary rule was doing any +/// work. `as with Ansible` and `as in Ansible` are cleared because neither +/// contains the substring `as Ansible` or `like Ansible` at all. +const EXEMPLAR_PREFIXES: [&str; 1] = ["such "]; + +/// Whether `body` justifies a helper by appeal to Ansible. +/// +/// Returns the phrase's display form, so the diagnostic can quote it. That form +/// is canonical rather than the text as it appeared: the match is made against +/// a folded copy of the body, so a sentence-initial `As Ansible does` would +/// otherwise be quoted back in whatever case the child RFC happened to use, and +/// a diagnostic naming three different spellings of one phrase as it fires +/// three times is harder to read than one naming a single shape. +pub(super) fn deference_phrase(body: &str) -> Option<&'static str> { + // Lowercased once per call rather than per candidate phrase. The offsets + // this yields are offsets into the folded text, which is what the + // surrounding-token tests below read; `to_lowercase` is not guaranteed to + // preserve byte length for every script, but the phrases and the context + // that decides their boundaries are ASCII throughout this corpus. + let folded = body.to_lowercase(); + DEFERENCE_PHRASES + .into_iter() + .find(|(pattern, _)| { + folded.match_indices(*pattern).any(|(at, _)| { + let Some(prefix) = folded.get(..at) else { + return false; + }; + // A boundary is the start of the text, or a character that cannot + // continue a word. `is_alphanumeric` alone would treat the `_` in + // `unlike_ansible` as a break, and the underscore is a word + // character in every identifier this corpus uses. + let bounded = !prefix + .chars() + .next_back() + .is_some_and(|ch| ch.is_alphanumeric() || ch == '_'); + let exemplar = EXEMPLAR_PREFIXES + .iter() + .any(|prefix_word| prefix.ends_with(prefix_word)); + bounded && !exemplar + }) + }) + .map(|(_, display)| display) +} + +#[cfg(test)] +mod deference_tests { + //! Unit tests for [`deference_phrase`](super::deference_phrase). + //! + //! The predicate is the mechanical half of this plan's own acceptance + //! criterion, and it is a lexical search over prose: the two false shapes + //! below are sentences a child RFC would plausibly write, and both read as + //! the reverse of the appeal it exists to catch. + + use super::deference_phrase; + + /// An appeal to Ansible is caught, and quoted back in the diagnostic. + /// + /// `as Ansible does` is the recorded seeded fault for `CONF-1`, so it is + /// pinned here: a change that stopped catching it would leave that control + /// green and empty. + #[test] + fn an_appeal_is_flagged() { + assert_eq!(deference_phrase("as Ansible does"), Some("as Ansible")); + assert_eq!(deference_phrase("as Ansible"), Some("as Ansible")); + assert_eq!( + deference_phrase("like Ansible's fileglob"), + Some("like Ansible") + ); + assert_eq!( + deference_phrase("This is like Ansible"), + Some("like Ansible") + ); + } + + /// The same appeal opening a sentence is caught too. + /// + /// The phrases are matched case-insensitively, so capitalizing the first + /// word does not hide the appeal. Each case asserts the canonical lowercase + /// phrase is what comes back, because that is what the diagnostic quotes. + #[test] + fn a_capitalized_appeal_is_flagged() { + assert_eq!(deference_phrase("As Ansible does"), Some("as Ansible")); + assert_eq!(deference_phrase("Like Ansible"), Some("like Ansible")); + assert_eq!( + deference_phrase("AS ANSIBLE DOES"), + Some("as Ansible"), + "a shouted appeal is the same appeal" + ); + } + + /// A sentence that argues *against* the appeal is not an appeal. + /// + /// `Unlike Ansible` contains `like Ansible`, and flagging it would fail a + /// subsection that has made exactly the decision this check demands. + #[test] + fn the_reverse_of_an_appeal_is_not_flagged() { + assert_eq!(deference_phrase("Unlike Ansible, Netsuke has none"), None); + assert_eq!(deference_phrase("unlike_ansible"), None); + assert_eq!(deference_phrase("unlikeAnsible"), None); + } + + /// Naming Ansible as a source of examples is not deference to it. + /// + /// `such as Ansible` introduces an instance, and its `as` is its own word, + /// so the leading boundary alone does not clear it. The `such ` entry in + /// [`EXEMPLAR_PREFIXES`](super::EXEMPLAR_PREFIXES) is what does, and the + /// last assertion below is that entry's liveness check: it carries the same + /// `as Ansible does` tail under a prefix that is not an exemplar, so it is + /// caught. An entry that stopped matching would turn the first two + /// assertions red instead of leaving them green and empty. + /// + /// `as with Ansible` and `as in Ansible` are cleared by the phrase search + /// itself — neither contains `as Ansible` or `like Ansible` as a substring + /// — so they are not this predicate's cases to argue about and are not + /// asserted here as though the exemplar list were clearing them. + #[test] + fn an_example_list_is_not_flagged() { + assert_eq!(deference_phrase("such as Ansible does today"), None); + assert_eq!(deference_phrase("helpers such as Ansible has"), None); + assert_eq!( + deference_phrase("Such as Ansible does"), + None, + "the exemplar prefix is matched against the folded text" + ); + assert_eq!( + deference_phrase("today, as Ansible does"), + Some("as Ansible"), + "the same words under a nonexemplar prefix are the appeal" + ); + } + + #[rstest::rstest] + #[case::underscore("_as Ansible", None)] + #[case::number("2as Ansible", None)] + #[case::unicode_word("éAs Ansible", None)] + #[case::punctuation("(as Ansible)", Some("as Ansible"))] + #[case::unicode_punctuation("—Like Ansible", Some("like Ansible"))] + #[case::later_real_appeal("such as Ansible; as Ansible does", Some("as Ansible"))] + fn phrase_boundaries_and_later_matches_are_respected( + #[case] text: &str, + #[case] expected: Option<&str>, + ) { + assert_eq!(deference_phrase(text), expected); + } + + /// A sentence mentioning Ansible for another reason is not flagged. + #[test] + fn an_unrelated_mention_is_not_flagged() { + assert_eq!(deference_phrase("because Ansible has one"), None); + assert_eq!(deference_phrase("Ansible"), None); + assert_eq!(deference_phrase(""), None); + } +} diff --git a/tests/rfc_stdlib_coverage/document.rs b/tests/rfc_stdlib_coverage/document.rs new file mode 100644 index 000000000..d132f7ea0 --- /dev/null +++ b/tests/rfc_stdlib_coverage/document.rs @@ -0,0 +1,273 @@ +//! The document layer: sections, subsections, and raw table rows. +//! +//! Every parser in this module tree reads its source through this layer, so the +//! question "where does this section end" is answered once. The lexical +//! judgements it rests on — is a line a heading, is it a table row, is it inside +//! a fence — belong to `super::markdown`; what lives here is the structure built +//! on top of them. +//! +//! Line numbers are absolute and preserved through subscripting, so a parse of a +//! nested subsection still reports where in the file a row came from. +//! +//! Split out of `mod.rs` to keep that module under Whitaker's 400-line +//! `module_max_lines` ceiling, not as a new resolution boundary: `mod.rs` owns +//! this module and re-exports it, so each item reaches a sibling exactly as it +//! did before the split, and nothing outside this module tree may use it. + +use anyhow::{Context, Result}; + +use super::{Fences, heading_depth, is_separator, row_cells, table_heading}; + +/// A contiguous slice of a Markdown document, with absolute line numbers. +/// +/// Line numbers are preserved through subscripting so that a parse of a nested +/// subsection can still report where in the file a row came from. +pub(super) struct Section<'a> { + /// One-indexed line number of the first element of `lines`. + first_line: usize, + /// The section's lines, with line terminators removed. + /// + /// Public to the module tree because two parsers scan the raw lines + /// themselves rather than through a method here: `section8` re-derives an + /// end offset for a `###` heading whose text is not known in advance, and + /// `totals` collapses section 6.1's prose to compare it against a + /// transcription. Each is a different question from the ones + /// [`Section::tables`] and [`Section::subsections`] answer, so an accessor + /// for either would be unused by the other. + /// + /// A scan added here is fence-blind unless it brings its own [`Fences`]. + /// `clauses` and `roadmap` used to walk these lines for headings and now go + /// through [`Section::subsections`], which is fence-aware; prefer that route + /// for anything that reads structure. + pub(super) lines: Vec<&'a str>, +} + +impl<'a> Section<'a> { + /// Treat a whole document as one section. + /// + /// The document's own path is not retained: every caller holds the `&str` + /// it read the text from, and takes its diagnostics from that. Carrying a + /// copy here would be state nothing reads. + pub(super) fn whole(text: &'a str) -> Self { + Self { + first_line: 1, + lines: text.lines().collect(), + } + } + + /// The subsection headed by `heading`, running to the next heading of the + /// same or a shallower depth. + /// + /// The heading line is part of the returned section. A table placed directly + /// under a subsection heading is introduced by it and by nothing else, so a + /// subsection that withheld its own heading would leave that table + /// attribute-less, and [`Section::tables`] would name it `""` rather than the + /// heading a caller filters on. + /// + /// The heading must match on its full text, so `14.1. Slice` cannot + /// accidentally select `14.10. Slice`. + /// + /// Note that `heading` is matched verbatim, and that the start scan skips + /// fenced lines, so a document that quotes the heading inside an example + /// still lands on the real one. Both scans are fence-aware for the same + /// reason: an example is not structure, and a start scan that stopped on one + /// would run to the end of the example's own block instead of the heading's. + pub(super) fn subsection(&self, heading: &str) -> Option<Self> { + let start = self.unfenced_heading(heading)?; + let depth = heading_depth(self.lines.get(start)?)?; + let rest = self.lines.get(start..)?; + let end = rest + .get(1..)? + .iter() + .scan(Fences::default(), |fences, line| { + let fenced = fences.mark(line); + Some((fenced, line)) + }) + .position(|(fenced, line)| !fenced && heading_depth(line).is_some_and(|d| d <= depth)) + .map_or(rest.len(), |offset| offset + 1); + Some(Self { + first_line: self.first_line + start, + lines: rest.get(..end)?.to_vec(), + }) + } + + /// This section's `###` subsections, in document order. + /// + /// A subsection runs from its own heading to the next heading of the same or + /// a shallower depth, which is [`Section::subsection`]'s rule applied to + /// every heading at once. Only depth three is collected, because the sections + /// that carry subsections in this corpus are depth two: a `####` inside one is + /// body content, and treating it as a sibling would split its parent. + /// + /// The body is fence-free in the same sense [`Section::tables`] is. A child + /// RFC's clause subsections quote Jinja, shell, and diagnostic text, and a + /// `#` line inside one of those snippets is a comment rather than a heading; + /// reading it as one would truncate the subsection and drop whatever + /// followed. Heading lines are not themselves body, so a `####` inside a + /// subsection contributes its prose but not its own line. + pub(super) fn subsections(&self) -> Vec<Subsection> { + let headings = self.headings(); + let mut found = Vec::new(); + for (index, (offset, line)) in headings.iter().enumerate() { + let end = headings + .get(index + 1) + .map_or(self.lines.len(), |(next, _)| *next); + found.push(Subsection { + heading: line.clone(), + line: self.first_line + offset, + body: self.unfenced_body(offset + 1, end), + }); + } + found + } + + /// The offset of the first unfenced line that equals `heading`. + /// + /// Shared with [`Section::subsection`] because both scans have to agree + /// about which lines are structure: a start scan that matched inside a fence + /// would hand the end scan a position the end scan does not consider real. + /// + /// The comparison is on the raw line and not on its trimmed text, because + /// [`heading_depth`] is the single authority on what a heading is and it + /// rejects an indented line: ` ### Foo` is body, not a heading, for every + /// scan in this module tree. Comparing trimmed text here would match a line + /// the rest of the tree treats as prose, and `subsection` would then fail at + /// the depth it reads next, reporting "no such section" for a heading the + /// caller can see. An indented `### Foo` in a document is therefore not a + /// heading to look up; that is `heading_depth`'s decision, made once. + pub(super) fn unfenced_heading(&self, heading: &str) -> Option<usize> { + let mut fences = Fences::default(); + for (offset, line) in self.lines.iter().enumerate() { + if fences.mark(line) { + continue; + } + if *line == heading { + return Some(offset); + } + } + None + } + + /// Every unfenced `###` heading, as (offset, text). + fn headings(&self) -> Vec<(usize, String)> { + let mut fences = Fences::default(); + let mut found = Vec::new(); + for (offset, line) in self.lines.iter().enumerate() { + if fences.mark(line) { + continue; + } + if heading_depth(line) == Some(3) + && let Some(text) = table_heading(line) + { + found.push((offset, text)); + } + } + found + } + + /// The unfenced lines in `start..end`, with trailing whitespace removed. + /// + /// The fence scan starts at the section's first line rather than at `start`, + /// so a body that begins inside a block its own heading opened is read as + /// fenced. That cannot happen for a heading the scan reached unfenced, but it + /// costs nothing to be right about. + fn unfenced_body(&self, start: usize, end: usize) -> Vec<String> { + let mut fences = Fences::default(); + let mut body = Vec::new(); + for (offset, line) in self.lines.iter().enumerate().take(end) { + if fences.mark(line) { + continue; + } + if offset >= start { + body.push(line.trim_end().to_owned()); + } + } + body + } + + /// Every Markdown table in this section, in document order, paired with the + /// heading text that precedes it. + /// + /// The header row is dropped and the `|---|` separator skipped, so each + /// returned row is a data row. A table ends at the first line that is not a + /// table row, which is what keeps two adjacent subtables distinct. + /// + /// Fenced lines are skipped in both roles. A `|` line inside a fence is + /// example content, and a `#` line inside a fence is a comment — in a Jinja + /// or shell snippet, both are ordinary text and neither is structure. + pub(super) fn tables(&self) -> Vec<(String, Vec<RawRow>)> { + let mut tables: Vec<(String, Vec<RawRow>)> = Vec::new(); + let mut fences = Fences::default(); + let mut heading = String::new(); + let mut body = false; + for (offset, line) in self.lines.iter().enumerate() { + if fences.mark(line) { + body = false; + continue; + } + let number = self.first_line + offset; + if let Some(text) = table_heading(line) { + heading = text; + body = false; + continue; + } + let Some(cells) = row_cells(line) else { + body = false; + continue; + }; + if is_separator(&cells) { + continue; + } + if !body { + // The first row of a table is its header, and the separator + // under it is skipped, so the rows that follow are data rows. + tables.push((heading.clone(), Vec::new())); + body = true; + continue; + } + if let Some((_, rows)) = tables.last_mut() { + rows.push(RawRow { + cells, + line: number, + }); + } + } + tables + .into_iter() + .filter(|(_, rows)| !rows.is_empty()) + .collect() + } +} + +/// One `###` subsection of a section, with its body. +pub(super) struct Subsection { + /// The heading text, without its hashes. + pub(super) heading: String, + /// One-indexed line number of the heading. + pub(super) line: usize, + /// The subsection's own lines, fences and heading excluded. + pub(super) body: Vec<String>, +} + +/// One Markdown table row, with the line it was read from. +pub(super) struct RawRow { + /// Cell text, trimmed, in column order. + cells: Vec<String>, + /// One-indexed line number. + pub(super) line: usize, +} + +impl RawRow { + /// The cell at `column`, or an error naming this row when the table is + /// narrower than the caller expects. + pub(super) fn cell(&self, column: usize, what: &str) -> Result<&str> { + self.cells + .get(column) + .map(String::as_str) + .with_context(|| format!("row at line {} has no {what} column", self.line)) + } +} + +#[cfg(test)] +#[path = "document_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/document_tests.rs b/tests/rfc_stdlib_coverage/document_tests.rs new file mode 100644 index 000000000..060d98118 --- /dev/null +++ b/tests/rfc_stdlib_coverage/document_tests.rs @@ -0,0 +1,67 @@ +//! Exercise section boundaries and source locations without tracked documents. +use super::*; +use rstest::rstest; + +#[test] +fn sections_skip_fenced_headings_and_stop_at_peer_boundaries() { + let text = concat!( + "# Intro\n```\n## Target\n```\n## Target\n", + "### Child\nbody\n~~~\n# ignored\n~~~\nafter\n## Peer\nend\n" + ); + let whole = Section::whole(text); + let section = whole.subsection("## Target").expect("real target exists"); + assert_eq!(section.first_line, 5); + assert_eq!(section.lines.last(), Some(&"after")); + assert!(whole.subsection("## Missing").is_none()); + assert!(whole.subsection("## Tar").is_none()); + let child = section.subsection("### Child").expect("child exists"); + assert_eq!(child.first_line, 6); + let subsections = section.subsections(); + assert_eq!(subsections.len(), 1); + let first = subsections.first().expect("one subsection"); + assert_eq!(first.line, 6); + assert_eq!(first.body, ["body", "after"]); +} + +#[test] +fn table_recognition_preserves_absolute_lines_and_separate_headers() { + let text = concat!( + "# Intro\n## Tables\n### First\n| a | b |\n|---|:---:|\n", + "| value | other |\n\n| header |\n| second |\n", + "```\n### Fake\n| h |\n| fake |\n```\n### Empty\n| h |\n" + ); + let section = Section::whole(text) + .subsection("## Tables") + .expect("tables exist"); + let tables = section.tables(); + assert_eq!(tables.len(), 2); + let first = tables.first().expect("first table"); + let row = first.1.first().expect("first data row"); + assert_eq!(first.0, "First"); + assert_eq!(row.line, 6); + assert_eq!(row.cell(1, "value").expect("second cell"), "other"); + let second = tables.get(1).expect("second table"); + assert_eq!(second.1.first().expect("second data row").line, 9); +} + +#[rstest] +#[case::shallow("## peer")] +#[case::root("# root")] +#[case::same("### peer")] +fn subsection_ends_at_same_or_shallower_heading(#[case] boundary: &str) { + let text = format!("### target\nbody\n#### nested\nmore\n{boundary}\nafter"); + let section = Section::whole(&text) + .subsection("### target") + .expect("target exists"); + assert_eq!(section.lines.last(), Some(&"more")); +} + +#[test] +fn missing_raw_cell_reports_column_and_absolute_source_line() { + let row = RawRow { + cells: vec!["only".into()], + line: 42, + }; + let error = row.cell(1, "resolution").expect_err("missing cell"); + assert_eq!(error.to_string(), "row at line 42 has no resolution column"); +} diff --git a/tests/rfc_stdlib_coverage/inventory.rs b/tests/rfc_stdlib_coverage/inventory.rs new file mode 100644 index 000000000..655ce948d --- /dev/null +++ b/tests/rfc_stdlib_coverage/inventory.rs @@ -0,0 +1,145 @@ +//! The section 7 inventories the parse needs in literal form. +//! +//! Two of the three cannot be derived from the survey tables: section 7.8 +//! states the rename exceptions in a sentence, and table 11 gives the count of +//! optioned helpers without ever listing them, their names appearing in three +//! separate places. Each is transcribed here and witnessed against the document +//! by the reader that consumes it, so a prose edit fails loudly instead of +//! drifting silently. +//! +//! The third is the set of disposition tables itself. The parse needs each +//! table's heading, disposition column, and namespace, and no row of the +//! document states them. + +use super::Namespace; + +/// A section 7 table whose rows carry a disposition. +pub(super) struct CandidateTable { + /// Heading text as it appears in the document, without the hashes. + pub(super) heading: &'static str, + /// Zero-based index of the disposition column. + pub(super) disposition_column: usize, + /// Namespace its rows register into. + pub(super) namespace: Namespace, +} + +/// The seven disposition tables of RFC 0006 section 7, in document order. +/// +/// The global-function table at 7.7 has one fewer leading column than the six +/// before it, because a global function has no surveyed Ansible spelling +/// distinct from its name. +pub(super) const CANDIDATE_TABLES: [CandidateTable; 7] = [ + CandidateTable { + heading: "7.1. Core filters", + disposition_column: 2, + namespace: Namespace::Filter, + }, + CandidateTable { + heading: "7.2. Collection and mathematical filters", + disposition_column: 2, + namespace: Namespace::Filter, + }, + CandidateTable { + heading: "7.3. URL filters", + disposition_column: 2, + namespace: Namespace::Filter, + }, + CandidateTable { + heading: "7.4. Core tests", + disposition_column: 2, + namespace: Namespace::Test, + }, + CandidateTable { + heading: "7.5. Filesystem tests", + disposition_column: 2, + namespace: Namespace::Test, + }, + CandidateTable { + heading: "7.6. Collection tests", + disposition_column: 2, + namespace: Namespace::Test, + }, + CandidateTable { + heading: "7.7. Global functions", + disposition_column: 1, + namespace: Namespace::Function, + }, +]; + +/// An accepted helper registered under a name other than the surveyed one. +pub(super) struct Rename { + /// The surveyed spelling, as it appears in section 7. + pub(super) surveyed: &'static str, + /// The registered Netsuke name. + pub(super) registered: &'static str, + /// Section that specifies the registered name, as `N.N`. + pub(super) section: &'static str, +} + +/// RFC 0006 section 7.8's rename exceptions, asserted to number three. +/// +/// Section 7.8 states these in prose: "Ansible's `hash` becomes `text_hash` +/// (§11.1), its `quote` becomes `shell_quote` (§10.2), and its `win_splitdrive` +/// becomes `splitdrive(dialect='windows')` (§8.6)." The sections recorded here +/// are where each *registered* name is specified in section 8, which for `quote` +/// is not the section its survey row cites. +pub(super) const RENAMES: [Rename; 3] = [ + Rename { + surveyed: "hash", + registered: "text_hash", + section: "8.9", + }, + Rename { + surveyed: "quote", + registered: "shell_quote", + section: "8.9", + }, + Rename { + surveyed: "win_splitdrive", + registered: "splitdrive", + section: "8.6", + }, +]; + +/// An existing helper that gains a behaviour-preserving option. +pub(super) struct Optioned { + /// The existing Netsuke helper name. + pub(super) name: &'static str, + /// The surveyed section 7 row that names it, in either column. + pub(super) evidence_row: &'static str, + /// The namespace RFC 0006 section 3.2 places it in. + /// + /// The namespace is recorded rather than assumed. Two of the three are + /// filters and one is a function, and a hardcoded default is not merely + /// inaccurate for the odd one out: the coverage check compares a child + /// registry's namespace column against the value derived here, so a wrong + /// answer makes a *correct* child RFC fail. `glob` is the one that differs, + /// which is exactly the case a default gets wrong. + pub(super) namespace: Namespace, +} + +/// The three existing helpers gaining an option, from table 11's count of 3. +/// +/// Table 11 gives the count. The names come from three separate places: +/// `basename` and `dirname` are section 7 row names, and `glob` appears only in +/// the resolution cell of the `fileglob` row. The namespaces are section 3.2's +/// two lists: `basename` and `dirname` are under Filters, and `glob` is under +/// Functions. `glob` is the only one of the three that is not a filter, which is +/// what makes a default wrong here rather than merely redundant. +pub(super) const OPTIONED: [Optioned; 3] = [ + Optioned { + name: "basename", + evidence_row: "basename", + namespace: Namespace::Filter, + }, + Optioned { + name: "dirname", + evidence_row: "dirname", + namespace: Namespace::Filter, + }, + Optioned { + name: "glob", + evidence_row: "fileglob", + namespace: Namespace::Function, + }, +]; diff --git a/tests/rfc_stdlib_coverage/links.rs b/tests/rfc_stdlib_coverage/links.rs new file mode 100644 index 000000000..e2fbc4be2 --- /dev/null +++ b/tests/rfc_stdlib_coverage/links.rs @@ -0,0 +1,143 @@ +//! Resolution of relative Markdown links across the RFC corpus. +//! +//! Nothing else in the toolchain checks these. `markdownlint-cli2` is configured +//! without cross-file link or anchor validation, and `mdtablefix` only +//! canonicalizes tables, so a relative link between two documents can rot +//! unnoticed. The RFC corpus is where that matters most: the child RFCs cite +//! each other, RFC 0006, the roadmap, and the ADRs, and a reader following a +//! stale path has no fallback. + +use anyhow::{Context, Result}; + +use super::Repo; + +/// A relative link target found in a document. +pub(super) struct Target { + /// The target as written, before resolution. + pub(super) target: String, + /// One-indexed line the target was found on. + pub(super) line: usize, +} + +/// Every relative Markdown link target in `text`. +/// +/// Absolute URLs, bare anchors, and `mailto:` targets are skipped: none is a +/// path this test can resolve. Targets that wrap across lines are read whole, +/// because a link split by the 80-column wrap is still a link. +pub(super) fn targets(text: &str) -> Vec<Target> { + let mut found = Vec::new(); + let mut line = 1; + let mut rest = text; + while let Some(open) = rest.find("](") { + // `open` indexes the `]` of a `](`, and every byte skipped here is ASCII, + // so each split lands on a character boundary. + let (head, tail) = rest.split_at(open); + line += head.matches('\n').count(); + let after = tail.split_at(2).1; + let Some(close) = after.find(')') else { + break; + }; + let (raw, remainder) = after.split_at(close); + let target = raw.split_whitespace().next().unwrap_or("").to_owned(); + if is_relative(&target) { + found.push(Target { target, line }); + } + line += raw.matches('\n').count(); + rest = remainder.split_at(1).1; + } + found +} + +/// Whether a link target is a relative path this test should resolve. +pub(super) fn is_relative(target: &str) -> bool { + !target.is_empty() + && !target.starts_with('#') + && !target.contains("://") + && !target.starts_with("mailto:") +} + +/// The path part of a link target, with any `#fragment` removed. +/// +/// A target that is nothing but a fragment is returned unchanged, so a bare +/// anchor that reached here still fails to resolve rather than passing as the +/// directory it sits in. +fn path_of(target: &str) -> &str { + let (path, _) = target.split_once('#').unwrap_or((target, "")); + if path.is_empty() { target } else { path } +} + +/// Resolve a relative target against the directory holding `file`. +/// +/// Only `..` segments are collapsed, which is all the RFC corpus uses. +/// +/// Returns `None` when the traversal climbs above the repository root, rather +/// than dropping the surplus `..` segments and handing back a path that looks +/// ordinary. `../../../etc/passwd` and `etc/passwd` are not the same link, and +/// a resolver that silently reported the second as resolvable would give the +/// caller a path it never asked about — resolvable or not, the link itself +/// points outside the repository. +/// +/// A `#fragment` selects a heading inside the target document, so only the path +/// before it names a file. Fragments are stripped and not validated: the anchor +/// slugs are a renderer's business, and every renderer spells them differently. +pub(super) fn resolve(file: &str, target: &str) -> Option<String> { + let path = path_of(target); + let mut segments: Vec<&str> = file.split('/').collect(); + segments.pop(); + for segment in path.split('/') { + match segment { + "" | "." => {} + ".." => { + // Popping the last segment of an empty path would climb past + // the repository root. + segments.pop()?; + } + other => segments.push(other), + } + } + Some(segments.join("/")) +} + +/// Every dangling relative link in `file`. +/// +/// Returns the failures rather than asserting, so a caller can report all of +/// them at once instead of one per run. +pub(super) fn dangling_in(repo: &Repo, file: &str) -> Result<Vec<String>> { + let text = repo.read(file)?; + let mut failures = Vec::new(); + for target in targets(&text) { + let failure = match resolve(file, &target.target) { + Some(path) => { + if repo + .exists(&path) + .with_context(|| format!("resolve {}:{} -> {path}", file, target.line))? + { + continue; + } + format!( + "{file}:{} links to {} which resolves to {path}, and no such file exists", + target.line, target.target + ) + } + None => format!( + "{file}:{} links to {}, which climbs above the repository root", + target.line, target.target + ), + }; + failures.push(failure); + } + Ok(failures) +} + +/// Every dangling relative link in the RFC corpus. +pub(super) fn dangling(repo: &Repo) -> Result<Vec<String>> { + let mut failures = Vec::new(); + for file in repo.markdown_files(super::RFC_DIR)? { + failures.extend(dangling_in(repo, &file)?); + } + Ok(failures) +} + +#[cfg(test)] +#[path = "links_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/links_tests.rs b/tests/rfc_stdlib_coverage/links_tests.rs new file mode 100644 index 000000000..60f36e63a --- /dev/null +++ b/tests/rfc_stdlib_coverage/links_tests.rs @@ -0,0 +1,158 @@ +//! Exercise relative-link extraction and repository-root containment. + +use super::*; +use anyhow::ensure; +use proptest::prelude::*; +use rstest::rstest; + +#[test] +fn extracts_relative_targets_with_original_source_lines() { + let text = concat!( + "[external](https://example.org) [anchor](#heading)\n", + "[mail](mailto:user@example.org) [empty]()\n", + "[local](../guide.md#section) [wrapped](\nchild.md)\n", + "[title](other.md \"description\") [unfinished](missing\n", + ); + let actual: Vec<_> = targets(text) + .into_iter() + .map(|target| (target.target, target.line)) + .collect(); + assert_eq!( + actual, + vec![ + ("../guide.md#section".into(), 3), + ("child.md".into(), 3), + ("other.md".into(), 5) + ] + ); +} + +#[rstest] +#[case("", false)] +#[case("#anchor", false)] +#[case("https://example.org", false)] +#[case("mailto:a@b", false)] +#[case("child.md#heading", true)] +fn classifies_relative_targets(#[case] target: &str, #[case] expected: bool) { + assert_eq!(is_relative(target), expected); +} + +#[test] +fn preserves_a_bare_fragment_when_resolving_directly() { + assert_eq!( + resolve("docs/source.md", "#anchor"), + Some("docs/#anchor".into()) + ); +} + +#[test] +fn reports_missing_and_escaping_links_with_file_and_line() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?; + dir.create_dir_all("docs/rfcs")?; + dir.write("docs/rfcs/present.md", "present")?; + dir.write( + "docs/rfcs/source.md", + "[valid](present.md)\n[missing](absent.md)\n[escape](../../../outside.md)\n", + )?; + let repo = Repo::fixture(root)?; + let failures = dangling_in(&repo, "docs/rfcs/source.md")?; + ensure!( + failures.len() == 2, + "fixture result differs from expected contract" + ); + ensure!( + failures + .first() + .context("missing-target diagnostic")? + .contains("docs/rfcs/source.md:2 links to absent.md") + ); + ensure!( + failures + .first() + .context("missing-target diagnostic")? + .contains("resolves to docs/rfcs/absent.md") + ); + ensure!( + failures + .get(1) + .context("above-root diagnostic")? + .contains("docs/rfcs/source.md:3") + ); + ensure!( + failures + .get(1) + .context("above-root diagnostic")? + .contains("climbs above the repository root") + ); + ensure!( + dangling(&repo)? == failures, + "fixture result differs from expected contract" + ); + let missing = dangling_in(&repo, "docs/rfcs/missing.md") + .err() + .context("missing read fails")?; + ensure!(missing.to_string().contains("read docs/rfcs/missing.md")); + Ok(()) +} + +/// Validate traversal depth independently of the reference model's rendering. +/// +/// Return `None` when any prefix climbs above the root. Only `reference_path` +/// uses this test-only helper; it deliberately does not call the resolver. +fn reference_depth(initial_depth: usize, target: &[String]) -> Option<usize> { + let mut depth = initial_depth; + for segment in target { + if segment == ".." { + depth = depth.checked_sub(1)?; + } else if segment != "." && !segment.is_empty() { + depth += 1; + } + } + Some(depth) +} + +/// Model traversal by counting depth first, then reducing matched pairs. +fn reference_path(directory: &[String], target: &[String]) -> Option<String> { + reference_depth(directory.len(), target)?; + let mut stack = directory.to_vec(); + for segment in target { + if segment == ".." { + stack.truncate(stack.len().saturating_sub(1)); + } else if segment != "." && !segment.is_empty() { + stack.push(segment.clone()); + } + } + Some(stack.join("/")) +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: Some(Box::new(proptest::test_runner::FileFailurePersistence::Direct( + "tests/rfc_stdlib_coverage/links_tests.proptest-regressions", + ))), + .. ProptestConfig::default() + })] + #[test] + fn resolution_matches_repository_stack_model( + directory in proptest::collection::vec("[a-z]{1,5}", 0..6), + filename in "[a-z]{1,5}", + target in proptest::collection::vec( + prop_oneof![Just(String::new()), Just(".".into()), Just("..".into()), "[a-z]{1,5}"], + 1..12), + fragment in proptest::option::of("[a-z]{1,5}"), + ) { + let file = directory.iter().cloned().chain([format!("{filename}.md")]).collect::<Vec<_>>().join("/"); + let raw = target.join("/"); + // A bare fragment is deliberately a direct-resolver special case. + let suffix = fragment.map_or_else(String::new, |anchor| format!("#{anchor}")); + let linked = format!("{raw}{suffix}"); + let expected = if raw.is_empty() && !suffix.is_empty() { + reference_path(&directory, &[suffix]) + } else { + reference_path(&directory, &target) + }; + prop_assert_eq!(resolve(&file, &linked), expected); + } +} diff --git a/tests/rfc_stdlib_coverage/map.rs b/tests/rfc_stdlib_coverage/map.rs new file mode 100644 index 000000000..54a8b18ed --- /dev/null +++ b/tests/rfc_stdlib_coverage/map.rs @@ -0,0 +1,347 @@ +//! Reading and checking RFC 0006's coverage map. +//! +//! The map is a table in RFC 0006 section 14.13 with one row per child RFC. It +//! is simultaneously the artefact RFC 0006 section 6.1 asks for and the anchor +//! for the ownership bijection: each row names the section 8 subsections a +//! child RFC owns, and the rows together must partition the accepted set +//! exactly. +//! +//! The `Owns` grammar is deliberately tiny, because a richer one would be a +//! language nobody reviews. Clause forms, separated by semicolons: +//! +//! - `` `8.6` `` — every helper section 8.6 specifies; +//! - `` `8.6` except `expandvars` `` — all of them but the named one; +//! - `` `8.7` only `abs` `` — the named one alone. +//! +//! It is ASCII on purpose. The document's own section references use `§`, but no +//! Rust source in this repository does, and a map whose grammar is borrowed from +//! prose punctuation is harder to review than one that spells out `only` and +//! `except`. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, ensure}; + +use super::{RFC_0006, RawRow, Repo, Section, backticked, registries}; + +/// The heading of the coverage map's subsection in RFC 0006 section 14. +const MAP_HEADING: &str = "### 14.13. Coverage map"; + +/// One row of the coverage map. +pub(super) struct MapRow { + /// The RFC number reserved for this child, without the `RFC` prefix. + pub(super) number: String, + /// Repository-relative path of the child RFC, when it has been written. + pub(super) written: Option<String>, + /// The helpers this child owns, resolved from the `Owns` clauses. + pub(super) owns: Vec<String>, + /// The existing helpers this child adds an option to. + pub(super) optioned: Vec<String>, + /// The roadmap step that delivers the group. + pub(super) step: String, + /// Whether the child RFC has been written. + pub(super) is_written: bool, +} + +impl MapRow { + /// Every helper this row claims, owned or optioned. + pub(super) fn claims(&self) -> Vec<String> { + let mut all = self.owns.clone(); + all.extend(self.optioned.iter().cloned()); + all + } +} + +/// The parsed coverage map. +pub(super) struct Map { + /// Its rows, in document order. + pub(super) rows: Vec<MapRow>, +} + +impl Map { + /// The number of rows whose child RFC is still unwritten. + pub(super) fn unwritten(&self) -> usize { + self.rows.iter().filter(|row| !row.is_written).count() + } + + /// Map every claimed helper name to the child that claims it. + /// + /// Fails when two rows claim the same helper, naming both, which is the + /// two-owner control the ownership obligation needs. + pub(super) fn ownership(&self) -> Result<BTreeMap<String, String>> { + let mut owners: BTreeMap<String, String> = BTreeMap::new(); + for row in &self.rows { + claim(row, &mut owners)?; + } + Ok(owners) + } +} + +/// Record one row's claims, failing when another row already claimed a name. +fn claim(row: &MapRow, owners: &mut BTreeMap<String, String>) -> Result<()> { + for name in row.claims() { + if let Some(previous) = owners.get(&name) + && previous != &row.number + { + return Err(anyhow::anyhow!( + "helper {name} is claimed by both RFC {previous} and RFC {}", + row.number + )); + } + owners.insert(name, row.number.clone()); + } + Ok(()) +} + +/// Read the coverage map from RFC 0006. +pub(super) fn parse(repo: &Repo, sections: &BTreeMap<String, Vec<String>>) -> Result<Map> { + let text = repo.read(RFC_0006)?; + let document = Section::whole(&text); + let map = document.subsection(MAP_HEADING).with_context(|| { + format!( + "RFC 0006 section 14 contains no coverage map table at {MAP_HEADING}; \ + expected 8 rows" + ) + })?; + + let rows = map + .tables() + .into_iter() + .find(|(heading, rows)| heading_is_map(heading) && !rows.is_empty()) + .map(|(_, rows)| rows) + .context("the coverage map subsection contains no table")?; + + ensure!( + rows.len() == 8, + "the coverage map has {} rows; expected 8, one per child RFC", + rows.len() + ); + + let mut parsed = Vec::new(); + let mut numbers: BTreeSet<String> = BTreeSet::new(); + for row in &rows { + let parsed_row = parse_row(row, sections)?; + // One row per child RFC is the split's central rule — a child may own + // several capability groups, but no two rows may reserve one number — + // and this is the only place a second row could claim a number already + // taken. `ownership` does not catch it: it permits a repeated helper + // when both rows carry the same number, which is exactly the shape a + // duplicate reservation produces. `parse_all` would then find at most + // one registry for that number, and both the status and registry checks + // match rows with `find`/`any`, so one child would silently represent + // two slices of the accepted set. + ensure!( + numbers.insert(parsed_row.number.clone()), + "the coverage map reserves RFC {} more than once; \ + each child RFC owns exactly one row", + parsed_row.number + ); + parsed.push(parsed_row); + } + Ok(Map { rows: parsed }) +} + +/// Read one coverage-map row. +fn parse_row(row: &RawRow, sections: &BTreeMap<String, Vec<String>>) -> Result<MapRow> { + let child = row.cell(0, "child RFC")?; + let (number, written) = child_number(child).with_context(|| { + format!( + "coverage map row at {RFC_0006}:{} has an unreadable child RFC cell: {child}", + row.line + ) + })?; + let is_written = parse_status(row, &number, written.is_some())?; + // A written row links to a file, and the file's name carries the RFC + // number the row reserves. The two are written by hand and nothing else + // compares them: without this, row `0013` could link to `0014-….md`, and + // every check downstream would resolve the link, find the file, and pass + // — while `registries::parse_all` would file that document's helpers + // under RFC 0013's name. The link is the only place the mismatch is + // visible before the child is parsed. + check_link_names_the_row(row, &number, written.as_deref())?; + let optioned = backticked(row.cell(3, "optioned")?); + ensure_distinct(&optioned, "`Optioned`", row.line)?; + let owns = resolve_owns(row.cell(2, "owns")?, sections, row.line)?; + ensure_distinct(&owns, "`Owns`", row.line)?; + Ok(MapRow { + number, + written, + owns, + optioned, + step: row.cell(4, "roadmap step")?.trim().to_owned(), + is_written, + }) +} + +/// Fail when one cell names the same helper twice. +/// +/// Each cell is checked on its own rather than through [`MapRow::claims`]. A +/// name may legitimately appear in *both* cells: row `0018`'s `Owns` clause +/// resolves to `glob`, because `Survey::sections` is built from `sections_of` +/// and `section7::apply_optioned` files each optioned helper in the +/// subsection that specifies it, and that same row's `Optioned` cell names +/// `glob` again. The two records say different things — the first is which +/// section 8 subsection specifies it, the second that it gains an option rather +/// than being introduced — so a check on the union would red the live document. +/// +/// A duplicate *within* one cell says nothing at all, and is the shape a +/// copy-paste produces: an `Owns` cell reading `` `8.1`; `8.1` `` claims every +/// helper in the subsection twice. Nothing downstream can see it, because every +/// consumer collects the claims into a set before using them — [`Map::ownership`] +/// inserts into a `BTreeMap`, and the partition check collects into a +/// `BTreeSet` — so the repeat is collapsed rather than counted. +fn ensure_distinct(names: &[String], cell: &str, line: usize) -> Result<()> { + let mut seen: BTreeSet<&str> = BTreeSet::new(); + for name in names { + ensure!( + seen.insert(name.as_str()), + "the {cell} cell at {RFC_0006}:{line} names {name} twice" + ); + } + Ok(()) +} + +/// Read a row's status cell, checking it agrees with whether it links. +fn parse_status(row: &RawRow, number: &str, links: bool) -> Result<bool> { + let status = row.cell(5, "status")?.trim().to_ascii_lowercase(); + let is_written = match status.as_str() { + "written" => true, + "unwritten" => false, + other => { + return Err(anyhow::anyhow!( + "coverage map row for RFC {number} at {RFC_0006}:{} has status {other:?}; \ + expected `written` or `unwritten`", + row.line + )); + } + }; + ensure!( + is_written == links, + "coverage map row for RFC {number} is marked {status} but its child RFC cell is {}", + if links { "a link" } else { "not a link" } + ); + Ok(is_written) +} + +/// Assert a written row's link carries the RFC number the row reserves. +fn check_link_names_the_row(row: &RawRow, number: &str, link: Option<&str>) -> Result<()> { + let Some(target) = link else { + return Ok(()); + }; + let target_number = registries::rfc_number(target); + ensure!( + target_number.as_deref() == Some(number), + "coverage map row for RFC {number} at {RFC_0006}:{} links to {target}, whose \ + number is {}", + row.line, + target_number.as_deref().unwrap_or("not an RFC number") + ); + Ok(()) +} + +/// Whether a table heading belongs to the coverage map. +pub(super) fn heading_is_map(heading: &str) -> bool { + heading.starts_with("14.13.") +} + +/// Read a child RFC cell, which is a link once written and a code span before. +/// +/// Returns the row's RFC number and, when written, the link target. The link +/// text is the same four-digit number as the bare form and is unwrapped the same +/// way, so a written row is not merely a differently-shaped cell: both forms +/// have to yield a number the caller can compare against the map's own row. +/// +/// The number is required to be exactly four ASCII digits. A cell reading `13`, +/// `0013b`, or `٠٠١٣` is not a reserved number, and accepting it would let a +/// link whose text disagrees with its target through the caller's comparison. +pub(super) fn child_number(cell: &str) -> Option<(String, Option<String>)> { + let trimmed = cell.trim(); + let (text, target) = match trimmed.strip_prefix('[') { + Some(rest) => { + let (text, tail) = rest.split_once("](")?; + let (target, _) = tail.split_once(')')?; + (text.trim(), Some(target.trim())) + } + None => (trimmed.trim_matches('`').trim(), None), + }; + let number = text.to_owned(); + is_rfc_number(&number).then_some((number, target.map(ToOwned::to_owned))) +} + +/// Whether `text` is a four-digit RFC number. +fn is_rfc_number(text: &str) -> bool { + text.len() == 4 && text.chars().all(|ch| ch.is_ascii_digit()) +} + +/// Resolve an `Owns` cell into the helper names it claims. +pub(super) fn resolve_owns( + cell: &str, + sections: &BTreeMap<String, Vec<String>>, + line: usize, +) -> Result<Vec<String>> { + let mut claimed = Vec::new(); + for clause in cell + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + let tokens = backticked(clause); + let section = tokens.first().with_context(|| { + format!("`Owns` clause {clause:?} at {RFC_0006}:{line} names no section") + })?; + let members = sections.get(section).with_context(|| { + format!( + "`Owns` clause {clause:?} at {RFC_0006}:{line} names section {section}, which \ + specifies no accepted helper" + ) + })?; + let lower = clause.to_ascii_lowercase(); + let takes_member = lower.contains(" except ") || lower.contains(" only "); + // Both member-taking forms read their member from the second backticked + // token, so a clause carrying a third is rejected here rather than read + // for its first two and truncated in silence. The bare-section form + // checks its own arity in the `else` arm below. + ensure!( + !takes_member || tokens.len() == 2, + "`Owns` clause {clause:?} at {RFC_0006}:{line} takes a section and one member, but \ + names {tokens:?}" + ); + if lower.contains(" except ") { + let excluded = tokens.get(1).with_context(|| { + format!("`Owns` clause {clause:?} at {RFC_0006}:{line} excludes nothing") + })?; + ensure!( + members.contains(excluded), + "`Owns` clause {clause:?} at {RFC_0006}:{line} excludes {excluded}, which section \ + {section} does not specify" + ); + claimed.extend(members.iter().filter(|name| *name != excluded).cloned()); + } else if lower.contains(" only ") { + let included = tokens.get(1).with_context(|| { + format!("`Owns` clause {clause:?} at {RFC_0006}:{line} includes nothing") + })?; + ensure!( + members.contains(included), + "`Owns` clause {clause:?} at {RFC_0006}:{line} includes {included}, which section \ + {section} does not specify" + ); + claimed.push(included.clone()); + } else { + ensure!( + tokens.len() == 1, + "`Owns` clause {clause:?} at {RFC_0006}:{line} names section {section} with extra \ + backticked tokens; use `except` or `only`" + ); + claimed.extend(members.iter().cloned()); + } + } + ensure!( + !claimed.is_empty(), + "`Owns` cell at {RFC_0006}:{line} claims no helper" + ); + Ok(claimed) +} + +#[cfg(test)] +#[path = "map_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/map_tests.rs b/tests/rfc_stdlib_coverage/map_tests.rs new file mode 100644 index 000000000..7731b2823 --- /dev/null +++ b/tests/rfc_stdlib_coverage/map_tests.rs @@ -0,0 +1,288 @@ +//! Direct coverage-map diagnostics and independent ownership set properties. + +use super::*; +use proptest::prelude::*; +use proptest::test_runner::{FileFailurePersistence, TestCaseError}; +use rstest::rstest; + +/// Build a one-section inventory with two distinct helpers. +fn sections() -> BTreeMap<String, Vec<String>> { + BTreeMap::from([("8.1".into(), vec!["alpha".into(), "beta".into()])]) +} + +/// Parse a synthetic row through the document layer to preserve source lines. +fn row(cells: &str) -> RawRow { + let text = format!("# Fixture\n| header |\n| --- |\n{cells}\n"); + Section::whole(&text).tables().remove(0).1.remove(0) +} + +#[rstest] +#[case::short("13")] +#[case::suffix("0013b")] +#[case::unicode("٠٠١٣")] +#[case::bad_link("[0013](missing")] +#[case::bad_label("[RFC 0013](0013-child.md)")] +fn child_numbers_reject_malformed_cells(#[case] input: &str) { + assert!(child_number(input).is_none()); +} + +#[test] +fn child_numbers_accept_bare_and_linked_cells() { + assert_eq!(child_number("`0013`"), Some(("0013".into(), None))); + assert_eq!( + child_number("[0013](0013-child.md)"), + Some(("0013".into(), Some("0013-child.md".into()))) + ); +} + +#[rstest] +#[case::number( + "| bad | group | `8.1` | — | 6.2 | unwritten |", + "unreadable child RFC cell" +)] +#[case::status( + "| `0013` | group | `8.1` | — | 6.2 | draft |", + "expected `written` or `unwritten`" +)] +#[case::missing_link("| `0013` | group | `8.1` | — | 6.2 | written |", "marked written")] +#[case::unexpected_link( + "| [0013](0013-child.md) | group | `8.1` | — | 6.2 | unwritten |", + "marked unwritten" +)] +#[case::wrong_target( + "| [0013](0014-child.md) | group | `8.1` | — | 6.2 | written |", + "whose number is 0014" +)] +#[case::non_rfc_target( + "| [0013](child.md) | group | `8.1` | — | 6.2 | written |", + "not an RFC number" +)] +#[case::owns_repeat( + "| `0013` | group | `8.1`; `8.1` | — | 6.2 | unwritten |", + "`Owns` cell" +)] +#[case::option_repeat( + "| `0013` | group | `8.1` | `glob` / `glob` | 6.2 | unwritten |", + "`Optioned` cell" +)] +fn map_rows_reject_one_bad_condition(#[case] input: &str, #[case] expected: &str) { + let error = parse_row(&row(input), §ions()) + .err() + .expect("invalid row must fail"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains(expected), "{diagnostic}"); + if !expected.starts_with("marked") { + assert!( + diagnostic.contains(&format!("{RFC_0006}:4")), + "{diagnostic}" + ); + } +} + +#[rstest] +#[case::no_section("plain", "names no section")] +#[case::unknown_section("`8.99`", "specifies no accepted helper")] +#[case::bare_arity("`8.1` `alpha`", "extra backticked tokens")] +#[case::missing_member("`8.1` only missing", "takes a section and one member")] +#[case::extra_member("`8.1` except `alpha` `beta`", "takes a section and one member")] +#[case::unknown_only("`8.1` only `missing`", "includes missing")] +#[case::unknown_except("`8.1` except `missing`", "excludes missing")] +#[case::empty(" ; ", "claims no helper")] +fn ownership_clauses_reject_invalid_inputs(#[case] input: &str, #[case] expected: &str) { + let error = resolve_owns(input, §ions(), 42).expect_err("invalid ownership must fail"); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains(expected), "{diagnostic}"); + assert!( + diagnostic.contains(&format!("{RFC_0006}:42")), + "{diagnostic}" + ); +} + +#[test] +fn excluding_the_only_member_is_empty_ownership() { + let inventory = BTreeMap::from([("8.1".into(), vec!["alpha".into()])]); + let error = + resolve_owns("`8.1` except `alpha`", &inventory, 9).expect_err("empty result must fail"); + assert!(error.to_string().contains("claims no helper")); +} + +#[test] +fn ownership_detects_cross_child_claims_but_allows_owned_option_overlap() { + let first = parse_row( + &row("| `0013` | group | `8.1` | `alpha` | 6.2 | unwritten |"), + §ions(), + ) + .expect("valid overlap"); + let second = parse_row( + &row("| `0014` | group | `8.1` | — | 6.3 | unwritten |"), + §ions(), + ) + .expect("valid second row"); + assert_eq!(first.claims().len(), 3); + let one = Map { rows: vec![first] }; + assert_eq!( + one.ownership() + .expect("same child overlap is allowed") + .len(), + 2 + ); + let mut two = one; + two.rows.push(second); + let error = two.ownership().expect_err("cross-child claim must fail"); + assert!( + error + .to_string() + .contains("alpha is claimed by both RFC 0013 and RFC 0014") + ); + assert_eq!(two.unwritten(), 2); +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/rfc_stdlib_coverage/map_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + #[test] + fn owns_matches_independent_set_operations( + members in proptest::collection::btree_set(0u8..16, 2..9), + section in 1u8..11, + choice in 0usize..32, + mode in 0u8..3, + ) { + let universe: BTreeSet<String> = members.iter().map(|id| format!("helper_{id}")).collect(); + let selected = universe.iter().nth(choice.checked_rem(universe.len()).ok_or_else(|| TestCaseError::fail("non-zero inventory length"))?).ok_or_else(|| TestCaseError::fail("non-empty inventory"))?; + let key = format!("8.{section}"); + let inventory = BTreeMap::from([(key.clone(), universe.iter().cloned().collect())]); + let (clause, expected) = match mode { + 0 => (format!("`{key}`"), universe.clone()), + 1 => (format!("`{key}` only `{selected}`"), BTreeSet::from([selected.clone()])), + _ => (format!("`{key}` except `{selected}`"), universe.difference(&BTreeSet::from([selected.clone()])).cloned().collect()), + }; + let actual = resolve_owns(&clause, &inventory, 17).map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!(actual.into_iter().collect::<BTreeSet<_>>(), expected); + } + + #[test] + fn owns_rejects_generated_arity_and_unknown_references( + section in 1u8..11, + member in 0u8..16, + invalid in 0u8..5, + ) { + let key = format!("8.{section}"); + let helper = format!("helper_{member}"); + let inventory = BTreeMap::from([(key.clone(), vec![helper.clone()])]); + let (clause, expected) = match invalid { + 0 => (format!("`{key}` only `{helper}` `extra`"), "takes a section and one member"), + 1 => (format!("`{key}` except missing"), "takes a section and one member"), + 2 => (format!("`{key}` `{helper}`"), "extra backticked tokens"), + 3 => ("`8.99`".to_owned(), "specifies no accepted helper"), + _ => (format!("`{key}` only `unknown`"), "includes unknown"), + }; + let result = resolve_owns(&clause, &inventory, 17); + prop_assert!(result.is_err()); + let diagnostic = result.err().ok_or_else(|| TestCaseError::fail("rejection expected"))?.to_string(); + prop_assert!(diagnostic.contains(expected), "{diagnostic}"); + let source_context = format!("{RFC_0006}:17"); + prop_assert!(diagnostic.contains(&source_context), "{diagnostic}"); + } +} + +/// Write a coverage-map document inside an isolated repository fixture. +fn map_fixture(text: &str) -> Result<(tempfile::TempDir, Repo)> { + let temp = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temp.path()).context("UTF-8 fixture path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/rfcs")?; + repo.dir.write(RFC_0006, text)?; + Ok((temp, repo)) +} + +/// Render eight distinct reservations, changing only the final number on request. +fn map_document(duplicate: bool) -> String { + let rows = (0..8) + .map(|index| { + let number = if duplicate && index == 7 { + 13 + } else { + 13 + index + }; + format!("| `{number:04}` | group | `8.1` | — | 6.2 | unwritten |\n") + }) + .collect::<Vec<_>>() + .concat(); + format!( + "### 14.13. Coverage map\n| RFC | Group | Owns | Optioned | Step | Status |\n| --- | --- | --- | --- | --- | --- |\n{rows}" + ) +} + +#[rstest] +#[case::missing_heading("## 14. Delivery", "contains no coverage map table")] +#[case::missing_table("### 14.13. Coverage map\nProse only", "subsection contains no table")] +#[case::wrong_count( + "### 14.13. Coverage map\n| RFC | Group | Owns | Optioned | Step | Status |\n| --- | --- | --- | --- | --- | --- |\n| `0013` | group | `8.1` | — | 6.2 | unwritten |", + "has 1 rows; expected 8" +)] +fn map_structure_rejects_missing_and_partial_tables( + #[case] text: &str, + #[case] expected: &str, +) -> Result<()> { + let (_temp, repo) = map_fixture(text)?; + let error = parse(&repo, §ions()) + .err() + .context("bad map must fail")?; + ensure!(error.to_string().contains(expected), "{error}"); + Ok(()) +} + +#[test] +fn duplicate_reservations_fail_before_ownership_is_checked() -> Result<()> { + let (_temp, repo) = map_fixture(&map_document(true))?; + let error = parse(&repo, §ions()) + .err() + .context("duplicate reservation must fail")?; + ensure!( + error + .to_string() + .contains("reserves RFC 0013 more than once"), + "{error}" + ); + Ok(()) +} + +#[test] +fn eight_distinct_reservations_parse() -> Result<()> { + let (_temp, repo) = map_fixture(&map_document(false))?; + { + let actual = parse(&repo, §ions())?.rows.len(); + let expected_value = 8; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[test] +fn written_rows_preserve_link_step_and_option_claims() { + let parsed = parse_row( + &row("| [0013](0013-child.md) | group | `8.1` only `alpha` | `glob` | 6.2 | written |"), + §ions(), + ) + .expect("written row agrees with its numbered link"); + assert_eq!(parsed.written.as_deref(), Some("0013-child.md")); + assert_eq!(parsed.step, "6.2"); + assert_eq!(parsed.claims(), ["alpha", "glob"]); + assert_eq!(Map { rows: vec![parsed] }.unwritten(), 0); +} + +#[test] +fn missing_status_column_reports_the_source_line() { + let error = parse_row(&row("| `0013` | group | `8.1` | — | 6.2 |"), §ions()) + .err() + .expect("narrow row must fail"); + assert!(format!("{error:#}").contains("row at line 4 has no status column")); +} diff --git a/tests/rfc_stdlib_coverage/markdown.rs b/tests/rfc_stdlib_coverage/markdown.rs new file mode 100644 index 000000000..ba6c3488e --- /dev/null +++ b/tests/rfc_stdlib_coverage/markdown.rs @@ -0,0 +1,300 @@ +//! The Markdown lexical layer: headings, fences, and table rows. +//! +//! Every scan in this module tree reads a document line by line and asks the +//! same three questions of each line: is it a heading, is it a table row, and is +//! it inside a fenced code block. Keeping those three answers here means the +//! answers cannot drift between the scans that ask them. +//! +//! Fence tracking is the reason this is a module rather than three functions. A +//! child RFC's section 5 carries example snippets — Jinja, diagnostic text, +//! shell — in which a `#` opens a comment, a `|` separates shell pipeline +//! stages, and a backticked word is prose. A scan that reads any of those as +//! structure corrupts the parse silently, and it corrupts it by *truncating*: a +//! fenced `#` line reads as a depth-1 heading, ends the enclosing subsection, and +//! drops every helper specified below it. The resulting diagnostic blames the +//! helper rather than the code block. + +/// The Markdown heading depth of `line`, if it is a heading. +/// +/// An ATX heading is one to six hashes followed by a space or tab and then +/// heading text. The separator is required rather than merely assumed, because +/// the RFC corpus cites issues as bare `#596` and a scan that accepted any +/// hash-prefixed line would read a citation at the start of a paragraph as a +/// heading. The damage would be silent: a heading ends the enclosing section, +/// so the citation would truncate the scan and drop every helper below it. +/// +/// Seven or more hashes is not a heading of any depth, so the count is bounded +/// above as well. +pub(super) fn heading_depth(line: &str) -> Option<usize> { + let hashes = line.len() - line.trim_start_matches('#').len(); + let rest = line.get(hashes..)?; + let separated = rest.starts_with(' ') || rest.starts_with('\t'); + ((1..=6).contains(&hashes) && separated && !rest.trim().is_empty()).then_some(hashes) +} + +/// The heading text of `line`, if it is a heading. +pub(super) fn table_heading(line: &str) -> Option<String> { + heading_depth(line)?; + Some(line.trim().trim_start_matches('#').trim().to_owned()) +} + +/// The trimmed cells of `line`, if it is a Markdown table row. +/// +/// A line that is only `|` yields no cells rather than an empty slice, so a +/// stray pipe cannot be mistaken for a one-column row. +pub(super) fn row_cells(line: &str) -> Option<Vec<String>> { + let inner = line.trim().strip_prefix('|')?.strip_suffix('|')?; + Some( + inner + .split('|') + .map(|cell| cell.trim().to_owned()) + .collect(), + ) +} + +/// Whether a row is the `|---|---|` separator under a table header. +pub(super) fn is_separator(cells: &[String]) -> bool { + !cells.is_empty() + && cells.iter().all(|cell| { + !cell.is_empty() && cell.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ') + }) +} + +/// Fenced-code-block state for a line-by-line scan. +/// +/// Callers feed lines in order and use [`Fences::mark`]'s return value: a fenced +/// line is opaque, and the fence's own opening and closing lines count as +/// fenced. A scan that skips those lines is a scan that never mistakes an +/// example for the document. +/// +/// Only the two Markdown fence styles are tracked. The delimiter is remembered +/// rather than just the open/closed state, so a `~~~` block containing a line of +/// backticks does not close early, and a much longer closing run than the one +/// that opened the block is accepted, as `CommonMark` requires. +#[derive(Default)] +pub(super) struct Fences { + /// The delimiter that opened the current block, while one is open. + open: Option<Delimiter>, +} + +impl Fences { + /// Record `line` against the current state. + /// + /// Returns `true` when the line is part of a fenced block, including the + /// line that opens or closes it. + pub(super) fn mark(&mut self, line: &str) -> bool { + let Some(delimiter) = Delimiter::opening(line) else { + return self.open.is_some(); + }; + self.open = match self.open { + // Anything met inside an open block either closes it or is content: + // CommonMark nests neither fence style, so a `~~~` line does not + // open inside a backtick block. + Some(opened) if delimiter.closes(opened) => None, + Some(opened) => Some(opened), + None => Some(delimiter), + }; + true + } +} + +/// A fence delimiter line, decomposed. +#[derive(Clone, Copy)] +struct Delimiter { + /// The character the fence is drawn with. + character: char, + /// How many of them run together. + run: usize, + /// Whether an info string follows the run. + info: bool, +} + +impl Delimiter { + /// Read `line` as a fence delimiter, if it is one. + /// + /// The indent bound is `CommonMark`'s: an opening fence may carry up to + /// three leading spaces, and four or more is an indented code block rather + /// than a fence. A leading tab is rejected for the same reason — it advances + /// to a tab stop four columns wide, so it is never fence indentation. + /// + /// This predicate is line-level and so has no container context, which makes + /// every bound here a heuristic and this one no exception: a fence nested in + /// a list item sits at the item's content column, four spaces for an ordered + /// marker, and is legal `CommonMark` that this rule does not recognize. The + /// trade is taken deliberately, because the two errors are not equally + /// costly. Missing a fence hands its body to the heading and table scans, + /// which misreads structure and fails loudly — the corpus carries every + /// fence at column zero, so no document has yet needed the nested form. + /// Accepting any indentation does the opposite: an arbitrary run of + /// backticks, indented deeply enough to be ordinary code content to every + /// other tool, opens a block here and swallows the structure below it, and + /// [`Fences`] exists precisely to stop that happening silently. + /// + /// `None` also covers a backtick fence whose info string itself contains a + /// backtick, which `CommonMark` forbids — that shape is an inline code span + /// opening a line, not a fence. + fn opening(line: &str) -> Option<Self> { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 { + return None; + } + let (character, run) = fence_run(trimmed); + if run < 3 || !matches!(character, '`' | '~') { + return None; + } + let rest = trimmed.get(run..).unwrap_or(""); + if character == '`' && rest.contains('`') { + return None; + } + Some(Self { + character, + run, + info: !rest.trim().is_empty(), + }) + } + + /// Whether this delimiter closes a block that `opened` opened. + const fn closes(self, opened: Self) -> bool { + self.character == opened.character && self.is_closing_run(opened.run) + } + + /// Whether the run closes: long enough, and carrying no info string. + /// + /// Split from [`Delimiter::closes`] so each predicate holds one conjunction, + /// which is what keeps the guard in [`Fences::mark`] within the branch limit + /// Whitaker's `conditional_max_n_branches` sets. + const fn is_closing_run(self, opened_run: usize) -> bool { + self.run >= opened_run && !self.info + } +} + +/// The leading run of fence characters in `line`, with its length. +/// +/// `line` must already be trimmed of leading whitespace. +fn fence_run(line: &str) -> (char, usize) { + let character = line.chars().next().unwrap_or(' '); + ( + character, + line.chars().take_while(|ch| *ch == character).count(), + ) +} + +#[cfg(test)] +mod heading_tests { + //! Unit tests for [`heading_depth`](super::heading_depth). + //! + //! The predicate decides where every scan in this module tree ends, so its + //! boundaries are pinned here rather than only exercised through a document: + //! a wrong answer truncates a scan silently instead of failing it. + + use super::heading_depth; + + /// A citation is not a heading, and the two are one space apart. + /// + /// This is the contract that keeps a bare `#596` in prose from ending the + /// enclosing section: accepting it would truncate the scan and drop every + /// helper specified below it, and nothing else in the suite would notice, + /// because the corpus cites issues only inside linked prose today. + #[test] + fn citation_is_not_a_heading() { + assert_eq!(heading_depth("#596 is the originating issue"), None); + assert_eq!(heading_depth("####596"), None); + assert_eq!(heading_depth("## comment"), Some(2)); + } + + /// One to six hashes, a separating space or tab, and text is a heading. + #[test] + fn separated_hashes_are_headings() { + assert_eq!(heading_depth("# Title"), Some(1)); + assert_eq!(heading_depth("### 5.1. Registry"), Some(3)); + assert_eq!(heading_depth("###### deep"), Some(6)); + assert_eq!(heading_depth("#\tTabbed"), Some(1)); + } + + /// The forms that carry no heading text, or too many hashes, are not + /// headings at any depth. + #[test] + fn degenerate_runs_are_not_headings() { + assert_eq!(heading_depth(""), None); + assert_eq!(heading_depth("#"), None); + assert_eq!(heading_depth("# "), None); + assert_eq!(heading_depth("#\t "), None); + assert_eq!(heading_depth("####### seven"), None); + assert_eq!(heading_depth("no leading hash"), None); + assert_eq!(heading_depth(" # indented above three"), None); + } +} + +#[cfg(test)] +mod fence_tests { + //! Unit tests for [`Fences`](super::Fences) and its opening rule. + //! + //! The indent bound is pinned here rather than only exercised through a + //! document, because the scanned corpus carries every one of its 206 fence + //! lines at column zero: neither the 22 RFCs nor `docs/roadmap.md` has an + //! indented fence, so a document-level test cannot tell the correct bound + //! from an unbounded one. + + use super::Fences; + + /// `CommonMark` permits at most three leading spaces before an opening + /// fence, and no leading tab. + #[test] + fn only_column_zero_to_three_opens_a_fence() { + for spaces in 0..=3 { + let line = format!("{}```text", " ".repeat(spaces)); + assert!( + Fences::default().mark(&line), + "{spaces} spaces is legal fence indentation" + ); + } + for spaces in 4..=8 { + let line = format!("{}```text", " ".repeat(spaces)); + assert!( + !Fences::default().mark(&line), + "{spaces} spaces is an indented code block, not a fence" + ); + } + assert!( + !Fences::default().mark("\t```text"), + "a leading tab advances to a four-column tab stop, so it is not fence indentation" + ); + } + + /// A rejected opener leaves the state closed, so the structure below it is + /// still read as structure. + /// + /// This is the consequence the bound exists for: were the deep line treated + /// as an opening fence, every heading and table under it would be reported + /// as fenced content and dropped from the scan. + #[test] + fn a_rejected_opener_hides_nothing() { + let mut fences = Fences::default(); + assert!(!fences.mark(" ```text")); + assert!( + !fences.mark("### 5.1. Registry"), + "a heading under a rejected opener is still structure" + ); + assert!(!fences.mark("| a | b |")); + } + + /// The bound applies to the opening fence only. + /// + /// A block opened at column zero still closes on a longer run, including one + /// indented within the three-space allowance, so tightening the opener did + /// not make a legal close unreadable. + #[test] + fn an_indented_closer_still_closes() { + let mut fences = Fences::default(); + assert!(fences.mark("```text")); + assert!(fences.mark(" ```"), "an indented closer ends the block"); + assert!( + !fences.mark("### 5.1. Registry"), + "structure after the close is structure again" + ); + } +} + +#[cfg(test)] +#[path = "markdown_property_tests.rs"] +mod property_tests; diff --git a/tests/rfc_stdlib_coverage/markdown_property_tests.rs b/tests/rfc_stdlib_coverage/markdown_property_tests.rs new file mode 100644 index 000000000..7defcfb2c --- /dev/null +++ b/tests/rfc_stdlib_coverage/markdown_property_tests.rs @@ -0,0 +1,72 @@ +//! Compare fence state transitions with independent boundary predicates. +use super::{Fences, is_separator, row_cells}; +use proptest::prelude::*; +use proptest::test_runner::FileFailurePersistence; +use rstest::rstest; + +/// Render one generated delimiter candidate. +fn candidate(character: char, run: usize, indentation: (usize, bool), info: &str) -> String { + let (indent, tab) = indentation; + let prefix = if tab { + "\t".to_owned() + } else { + " ".repeat(indent) + }; + format!("{prefix}{}{info}", character.to_string().repeat(run)) +} + +proptest! { + #![proptest_config(ProptestConfig { + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/rfc_stdlib_coverage/markdown_property_tests.proptest-regressions"))), + .. ProptestConfig::default() + })] + #[test] + fn opening_boundaries_preserve_later_structure( + tilde in any::<bool>(), run in 0usize..9, indent in 0usize..7, + tab in any::<bool>(), info in prop::sample::select(vec!["", " rust", " ", " x`y", " ~"])) { + let character = if tilde { '~' } else { '`' }; + let line = candidate(character, run, (indent, tab), info); + // The model states grammar constraints directly, without inspecting parsed delimiters. + let legal = !tab && indent <= 3 && run >= 3 && (tilde || !info.contains('`')); + let mut fences = Fences::default(); + prop_assert_eq!(fences.mark(&line), legal); + prop_assert_eq!(fences.mark("### Heading"), legal); + prop_assert_eq!(fences.mark("| Header |"), legal); + } + + #[test] + fn closing_requires_matching_delimiter_sufficient_length_and_no_info( + opening_tilde in any::<bool>(), closing_tilde in any::<bool>(), + opening_run in 3usize..9, closing_run in 0usize..11, + indent in 0usize..7, tab in any::<bool>(), + info in prop::sample::select(vec!["", " ", " rust", " x`y", " ~"])) { + let opening_character = if opening_tilde { '~' } else { '`' }; + let closing_character = if closing_tilde { '~' } else { '`' }; + let mut fences = Fences::default(); + prop_assert!(fences.mark(&candidate(opening_character, opening_run, (0, false), " rust"))); + let closes = !tab && indent <= 3 && opening_tilde == closing_tilde + && closing_run >= opening_run && info.trim().is_empty(); + prop_assert!(fences.mark(&candidate(closing_character, closing_run, (indent, tab), info))); + prop_assert_eq!(fences.mark("### After"), !closes); + prop_assert_eq!(fences.mark("| a | b |"), !closes); + } +} + +#[rstest] +#[case::empty("")] +#[case::single_pipe("|")] +#[case::no_left("a |")] +#[case::no_right("| a")] +fn malformed_rows_are_not_tables(#[case] line: &str) { + assert!(row_cells(line).is_none()); +} + +#[rstest] +#[case::aligned(vec![":---:".into(), " --- ".into()], true)] +#[case::empty_row(vec![], false)] +#[case::empty_cell(vec![String::new()], false)] +#[case::ordinary_cell(vec!["data".into()], false)] +fn separator_vocabulary_is_narrow(#[case] cells: Vec<String>, #[case] expected: bool) { + assert_eq!(is_separator(&cells), expected); +} diff --git a/tests/rfc_stdlib_coverage/mod.rs b/tests/rfc_stdlib_coverage/mod.rs new file mode 100644 index 000000000..6e29e67c8 --- /dev/null +++ b/tests/rfc_stdlib_coverage/mod.rs @@ -0,0 +1,314 @@ +//! Parsers and checks backing the RFC 0006 child-RFC coverage contract. +//! +//! RFC 0006 is a survey RFC: it enumerates every ansible-core candidate it +//! considered and records a disposition for each. This module tree turns that +//! survey, the roadmap, and the child RFCs into a single machine-checkable +//! bijection, so a helper cannot be dropped, double-assigned, or reintroduced +//! under a rejected spelling without a test naming the file and line. +//! +//! Everything here is derived. The accepted set, the deny set, the expected +//! owners, and the clause list are all read out of tracked Markdown. What cannot +//! be derived is transcribed, and each transcription is witnessed against the +//! document it came from: the section 7 inventories and section 6.1's purity +//! aggregate in `survey`, and the ownership grammar of the coverage map's `Owns` +//! column in `map`. RFC 0006 states the former in prose rather than in a table, +//! and the latter is a mini-language no row spells out. +//! +//! The parser contract is deliberately narrow: it reads Markdown table rows +//! inside a named section and never headings. Heading anchoring was rejected +//! because three headings under RFC 0006 section 8 are prose rather than +//! helpers, three name two helpers each, and `basename` and `dirname` have no +//! heading at all. Names are matched by whole-token equality, never substring, +//! because the vocabulary contains `abs` against `is_abs`, `quote` against +//! `shell_quote`, `hash` against `text_hash`, and `subset` against +//! `issubset`. +//! +//! Fenced code blocks are opaque to every scan. A child RFC's section 5 carries +//! example blocks — Jinja snippets, diagnostic text, shell — and a `#`, a `|`, +//! or a backticked name inside one is example content rather than structure. + +mod assertions; +mod clauses; +mod deference; +mod document; +mod inventory; +mod links; +mod map; +mod markdown; +mod partition; +mod progress; +mod registries; +#[cfg(test)] +#[path = "repo_tests.rs"] +mod repo_tests; +mod roadmap; +mod section7; +mod section8; +mod survey; +mod totals; +#[cfg(test)] +mod validation_fixtures; + +pub use partition::{ + every_accepted_helper_has_exactly_one_owner, no_forbidden_helper_is_registered, +}; +pub use progress::{ + coverage_map_status_is_reported, every_capability_has_a_roadmap_task, + every_child_discharges_every_clause, inter_document_links_resolve, + totals_and_purity_aggregate_agree, +}; +// Private, but reachable as `super::…` from every child module, which is how +// `section8` gets at `Fences` and `heading_depth`. +use markdown::{Fences, heading_depth, is_separator, row_cells, table_heading}; +// Same arrangement, and for the same reason: the document layer moved to its +// own module to stay under the 400-line cap, so this import is what keeps every +// `super::Section` path in the module tree resolving as it did before the split. +use document::{RawRow, Section}; + +use std::collections::BTreeSet; + +use anyhow::{Context, Result}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; + +/// Repository-relative path of RFC 0006, the survey RFC. +pub(super) const RFC_0006: &str = "docs/rfcs/0006-ansible-inspired-template-standard-library.md"; + +/// Repository-relative path of the delivery roadmap. +pub(super) const ROADMAP: &str = "docs/roadmap.md"; + +/// Repository-relative directory holding the RFC corpus. +pub(super) const RFC_DIR: &str = "docs/rfcs"; + +/// Jinja namespace a helper occupies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Namespace { + /// Invoked as a filter on a piped value. + Filter, + /// Invoked as a test after `is`. + Test, + /// Invoked as a bare function call. + Function, +} + +impl Namespace { + /// The lowercase label RFC 0006's tables spell the namespace with. + pub(super) const fn label(self) -> &'static str { + match self { + Self::Filter => "filter", + Self::Test => "test", + Self::Function => "function", + } + } +} + +/// Whether a registry row introduces a helper or adds an option to one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Registration { + /// One of the 57 new helpers. + New, + /// One of the 3 existing helpers gaining an option. + OptionAdded, +} + +impl Registration { + /// The lowercase label a child RFC's registry spells the kind with. + pub(super) const fn label(self) -> &'static str { + match self { + Self::New => "new", + Self::OptionAdded => "option added", + } + } +} + +/// A surveyed name's disposition, derived from RFC 0006 section 7. +/// +/// Rejection is one variant, not three. RFC 0006 table 11 splits the 50 reject +/// rows into 22 already-provides, 10 redundant-alias, and 18 on-principle, but +/// the document states no rule assigning a row to a class and no rule fitted to +/// the tables recovers the split, so a three-way rejection type here would be +/// undecidable at runtime. Under decision `D10` the deny set is the complement +/// of the accepted set and needs no classes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Disposition { + /// Accepted, with the owning section 8 subsection. + Accept, + /// Deferred by section 9. + Defer, + /// Rejected by section 10, for any of the three reasons table 11 counts. + Reject, +} + +/// One accepted helper, as the document that established it records it. +/// +/// The row deliberately carries no source location: every parser that builds one +/// already reports `file:line` in the message that rejects a bad row, and no +/// later check has a use for the provenance. +#[derive(Debug, Clone)] +pub(super) struct Row { + /// Registered helper name, backticks stripped. + name: String, + /// Namespace the helper occupies. + namespace: Namespace, +} + +/// A capability-scoped view of the repository working tree. +/// +/// The handle is rooted at the package directory Cargo built this test from, +/// so every path in this module is repository-relative and independent of the +/// working directory the test happens to be run from. +pub(super) struct Repo { + /// Directory handle rooted at the package root. + dir: Dir, +} + +impl Repo { + /// Open an isolated fixture root for parser and validator tests. + /// + /// Keep fixture access capability-scoped like repository access; callers + /// own the temporary directory and never mutate tracked documents. + #[cfg(test)] + fn fixture(root: &Utf8Path) -> Result<Self> { + let dir = Dir::open_ambient_dir(root, ambient_authority()) + .with_context(|| format!("open fixture root {root}"))?; + Ok(Self { dir }) + } + + /// Open the package root named by Cargo at build time. + pub(super) fn open() -> Result<Self> { + let root = Utf8Path::new(env!("CARGO_MANIFEST_DIR")); + let dir = Dir::open_ambient_dir(root, ambient_authority()) + .with_context(|| format!("open repository root {root}"))?; + Ok(Self { dir }) + } + + /// Read a repository-relative file as UTF-8. + fn read(&self, path: &str) -> Result<String> { + self.dir + .read_to_string(path) + .with_context(|| format!("read {path}")) + } + + /// Whether a repository-relative path exists. + fn exists(&self, path: &str) -> Result<bool> { + self.dir + .try_exists(path) + .with_context(|| format!("test existence of {path}")) + } + + /// Repository-relative paths of the `.md` files directly under `dir`. + /// + /// Entries are sorted, so a parse over the corpus is deterministic and a + /// failure message names a stable file. + fn markdown_files(&self, dir: &str) -> Result<Vec<String>> { + let mut found = Vec::new(); + let entries = self + .dir + .read_dir(dir) + .with_context(|| format!("list {dir}"))?; + for candidate in entries { + let entry = candidate.with_context(|| format!("read a {dir} directory entry"))?; + let name = entry + .file_name() + .with_context(|| format!("read a {dir} entry name"))?; + let path = Utf8Path::new(&name); + if path.extension() == Some("md") { + found.push(format!("{dir}/{name}")); + } + } + found.sort_unstable(); + Ok(found) + } +} + +/// Strip one surrounding pair of backticks and any surrounding whitespace. +/// +/// A cell may hold several backticked names separated by ` / `; callers that +/// expect that split on [`NAME_SEPARATOR`] first. +pub(super) fn strip_backticks(cell: &str) -> &str { + let trimmed = cell.trim(); + trimmed + .strip_prefix('`') + .and_then(|rest| rest.strip_suffix('`')) + .unwrap_or(trimmed) +} + +/// The separator RFC 0006 section 7.8 uses between alias-group names. +pub(super) const NAME_SEPARATOR: &str = " / "; + +/// Expand a name cell into its individual backtick-stripped names. +/// +/// Section 7.8 records that rows cover alias groups as single entries, so +/// `` `d` / `default` `` contributes two names. +pub(super) fn names_in(cell: &str) -> Vec<String> { + if cell.trim() == "—" || cell.trim() == "-" { + return Vec::new(); + } + cell.split(NAME_SEPARATOR) + .map(|name| strip_backticks(name).to_owned()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// Every backticked span in `text`, in order. +pub(super) fn backticked(text: &str) -> Vec<String> { + text.split('`') + .skip(1) + .step_by(2) + .map(ToOwned::to_owned) + .collect() +} + +/// A sorted, de-duplicated view of `names`, for diffing in assertions. +pub(super) fn name_set<'a>(names: impl IntoIterator<Item = &'a str>) -> BTreeSet<String> { + names.into_iter().map(ToOwned::to_owned).collect() +} + +/// Context every check needs, derived once per call. +/// +/// Owned here rather than by either check module because both read it: the +/// ownership partition and the progress totals derive from the same four +/// documents, and a single parse bug must surface in whichever check depends on +/// the part it corrupts. +pub(in crate::rfc_stdlib_coverage) struct World { + /// The parse of RFC 0006's dispositions and totals. + pub(in crate::rfc_stdlib_coverage) survey: survey::Survey, + /// The parse of RFC 0006's coverage map. + pub(in crate::rfc_stdlib_coverage) map: map::Map, + /// Every existing child RFC's registry. + pub(in crate::rfc_stdlib_coverage) registries: Vec<registries::Registry>, + /// The roadmap's capability steps. + pub(in crate::rfc_stdlib_coverage) roadmap: roadmap::Steps, + /// The failure path each child RFC's number resolves to, when written. + pub(in crate::rfc_stdlib_coverage) child_paths: std::collections::BTreeMap<String, String>, +} + +impl World { + /// Derive everything from the working tree. + pub(in crate::rfc_stdlib_coverage) fn load(repo: &Repo) -> Result<Self> { + let survey = survey::derive_and_check(repo)?; + let sections = survey + .sections + .iter() + .map(|(section, names)| (section.clone(), names.iter().cloned().collect())) + .collect(); + let parsed = map::parse(repo, §ions)?; + let reserved: Vec<String> = parsed.rows.iter().map(|row| row.number.clone()).collect(); + let child_paths = parsed + .rows + .iter() + .filter_map(|row| { + row.written + .as_ref() + .map(|target| (row.number.clone(), target.clone())) + }) + .collect(); + Ok(Self { + survey, + map: parsed, + registries: registries::parse_all(repo, &reserved)?, + roadmap: roadmap::parse(repo)?, + child_paths, + }) + } +} diff --git a/tests/rfc_stdlib_coverage/partition.rs b/tests/rfc_stdlib_coverage/partition.rs new file mode 100644 index 000000000..999751f38 --- /dev/null +++ b/tests/rfc_stdlib_coverage/partition.rs @@ -0,0 +1,150 @@ +//! The ownership partition: every accepted helper has exactly one owner. +//! +//! These two checks read the coverage map as a partition of RFC 0006's section 7 +//! dispositions. `COV-1` asserts the map's rows cover the accepted set exactly +//! once, and `COV-2` asserts no child RFC registers a name the survey defers or +//! rejects. Together they are the mechanism the task exists to provide: a helper +//! cannot be dropped, double-assigned, or reintroduced under a rejected spelling +//! without a test naming the file and line. +//! +//! Split out of `checks.rs` to keep both modules under Whitaker's 400-line +//! `module_max_lines` ceiling. The seam is the derivation each check reads: both +//! start from a registry compared against the survey's dispositions, whereas the +//! checks in `super::progress` start from the map's own reported state. + +use std::collections::BTreeSet; + +use anyhow::{Result, ensure}; + +use super::{Registration, Repo, World, registries, survey}; + +/// Every accepted helper has exactly one owning child RFC, and the map's rows +/// together claim every accepted helper exactly once. +pub fn every_accepted_helper_has_exactly_one_owner(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + check_ownership(&world) +} + +/// Validate ownership against one already parsed document snapshot. +fn check_ownership(world: &World) -> Result<()> { + let ownership = world.map.ownership()?; + + let accepted: BTreeSet<String> = world.survey.accepted.keys().cloned().collect(); + let claimed: BTreeSet<String> = ownership.keys().cloned().collect(); + let unowned: Vec<_> = accepted.difference(&claimed).take(10).cloned().collect(); + ensure!( + unowned.is_empty(), + "the coverage map claims no owner for {unowned:?}; every accepted helper needs exactly one" + ); + let extra: Vec<_> = claimed.difference(&accepted).take(10).cloned().collect(); + ensure!( + extra.is_empty(), + "the coverage map claims {extra:?}, which RFC 0006 does not accept" + ); + + // A written child's registry must match the rows that claim it. + for registry in &world.registries { + let Some(claimed_by_row) = world + .map + .rows + .iter() + .find(|row| row.number == registry.number) + else { + return Err(anyhow::anyhow!( + "RFC {} exists at {} but the coverage map has no row for it", + registry.number, + registry.file + )); + }; + let expected: BTreeSet<String> = claimed_by_row.claims().into_iter().collect(); + let actual = registry.names(); + let missing: Vec<_> = expected.difference(&actual).take(10).cloned().collect(); + let unexpected: Vec<_> = actual.difference(&expected).take(10).cloned().collect(); + ensure!( + missing.is_empty() && unexpected.is_empty(), + "RFC {}'s registry does not match its coverage map row: missing {missing:?}; \ + unexpected {unexpected:?}", + registry.number + ); + // A matching name set is not a matching registry. The namespace and the + // registration kind are parsed from the child's own row, so without + // this comparison a row could move a helper to the wrong namespace or + // mark an optioned helper as new and still pass every check — and the + // namespace is exactly what `COV-3`'s filter and test totals count. + check_rows_agree_with_survey(registry, &world.survey)?; + } + Ok(()) +} + +/// Each registry row's namespace and registration agree with RFC 0006. +/// +/// The survey is the source of truth on both: its `accepted` rows carry the +/// namespace section 7 assigns the helper, and `optioned` lists the three names +/// that gain an option rather than being introduced. Comparing the two makes +/// the registry columns load-bearing rather than decorative. +fn check_rows_agree_with_survey( + registry: ®istries::Registry, + survey: &survey::Survey, +) -> Result<()> { + for row in ®istry.rows { + let name = &row.helper.name; + let Some(accepted) = survey.accepted.get(name) else { + return Err(anyhow::anyhow!( + "{} registers {name}, which RFC 0006 section 7 does not accept", + registry.file + )); + }; + ensure!( + accepted.namespace == row.helper.namespace, + "{} gives {name} namespace {}, but RFC 0006 section 7 places it in {}", + registry.file, + row.helper.namespace.label(), + accepted.namespace.label() + ); + let optioned = survey.optioned.contains(name); + let expected = if optioned { + Registration::OptionAdded + } else { + Registration::New + }; + ensure!( + row.registration == expected, + "{} marks {name} `{}`, but RFC 0006 {} it `{}`", + registry.file, + row.registration.label(), + if optioned { "lists" } else { "introduces" }, + expected.label() + ); + } + Ok(()) +} + +/// No child RFC registers a name RFC 0006 defers or rejects. +pub fn no_forbidden_helper_is_registered(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + check_forbidden(&world) +} + +/// Reject denied registrations in one already parsed document snapshot. +fn check_forbidden(world: &World) -> Result<()> { + let mut violations = Vec::new(); + for registry in &world.registries { + for name in registry.names() { + if world.survey.denied.contains(&name) { + violations.push(format!( + "{} registers {name}, which RFC 0006 section 7 or section 9 forbids", + registry.file + )); + } + } + } + ensure!( + violations.is_empty(), + "forbidden helpers registered: {violations:?}" + ); + Ok(()) +} + +#[cfg(test)] +#[path = "partition_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/partition_tests.rs b/tests/rfc_stdlib_coverage/partition_tests.rs new file mode 100644 index 000000000..c4abcaaef --- /dev/null +++ b/tests/rfc_stdlib_coverage/partition_tests.rs @@ -0,0 +1,138 @@ +//! Exercise ownership, namespace, registration and denied-name validators. + +use super::*; +use crate::rfc_stdlib_coverage::validation_fixtures as fixture; +use rstest::rstest; + +#[test] +fn accepts_matching_ownership_and_registry() -> Result<()> { + check_ownership(&fixture::world())?; + check_forbidden(&fixture::world()) +} + +#[rstest] +#[case::unowned("unowned", "claims no owner for [\"helper\"]")] +#[case::extra("extra", "which RFC 0006 does not accept")] +#[case::missing_map("missing_map", "coverage map has no row for it")] +#[case::registry_names("registry_names", "missing [\"helper\"]; unexpected [\"other\"]")] +#[case::namespace( + "namespace", + "namespace test, but RFC 0006 section 7 places it in filter" +)] +#[case::registration("registration", "marks helper `option added`")] +#[case::two_owners("two_owners", "claimed by both RFC 0013 and RFC 0014")] +fn rejects_partition_mismatches(#[case] mutation: &str, #[case] diagnostic: &str) { + let mut world = fixture::world(); + match mutation { + "unowned" => world + .map + .rows + .first_mut() + .expect("fixture owning map row") + .owns + .clear(), + "extra" => world + .map + .rows + .get_mut(1) + .expect("fixture second map row") + .owns + .push("extra".into()), + "missing_map" => { + world + .registries + .first_mut() + .expect("fixture registry") + .number = "0021".into(); + } + "registry_names" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .helper + .name = "other".into(); + } + "namespace" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .helper + .namespace = super::super::Namespace::Test; + } + "registration" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .registration = Registration::OptionAdded; + } + "two_owners" => world + .map + .rows + .get_mut(1) + .expect("fixture second map row") + .owns + .push("helper".into()), + _ => {} + } + let error = check_ownership(&world).expect_err("one partition condition changed"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} + +#[test] +fn rejects_unaccepted_rows_before_namespace_comparison() { + let mut registry = fixture::registry(); + registry.rows.first_mut().expect("fixture row").helper.name = "unknown".into(); + let error = + check_rows_agree_with_survey(®istry, &fixture::survey()).expect_err("unknown helper"); + assert!( + error + .to_string() + .contains("docs/rfcs/0013-child.md registers unknown") + ); +} + +#[test] +fn requires_optioned_helpers_to_be_marked_option_added() { + let mut survey = fixture::survey(); + survey.optioned.push("helper".into()); + let error = + check_rows_agree_with_survey(&fixture::registry(), &survey).expect_err("wrong kind"); + assert!( + error + .to_string() + .contains("RFC 0006 lists it `option added`") + ); +} + +#[test] +fn rejects_denied_registrations_with_the_child_filename() { + let mut world = fixture::world(); + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .helper + .name = "is_file".into(); + let error = check_forbidden(&world).expect_err("denied helper"); + assert!( + error + .to_string() + .contains("docs/rfcs/0013-child.md registers is_file") + ); + assert!(error.to_string().contains("section 7 or section 9 forbids")); +} diff --git a/tests/rfc_stdlib_coverage/progress.rs b/tests/rfc_stdlib_coverage/progress.rs new file mode 100644 index 000000000..5942944bb --- /dev/null +++ b/tests/rfc_stdlib_coverage/progress.rs @@ -0,0 +1,335 @@ +//! The reported state of the split: totals, coverage status, links, and clauses. +//! +//! These five checks read what the documents *say* about the split rather than +//! what they claim: the registries' running totals against RFC 0006 section +//! 6.1's aggregate, the coverage map's own status column, the link graph between +//! documents, the roadmap steps that schedule each capability, and each child's +//! discharge of RFC 0006 section 6's clauses. +//! +//! They are grouped because a half-finished split satisfies every obligation in +//! `super::partition`: the abandoned state and the finished state are +//! indistinguishable to a bijection check. What these checks add is the ability +//! to tell the two apart — `COV-4` prints the count of unwritten groups, and the +//! totals are asserted over whatever prefix of the split exists. +//! +//! Split out of `checks.rs` to keep both modules under Whitaker's 400-line +//! `module_max_lines` ceiling. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result, ensure}; + +use super::{Registration, Repo, World, clauses, links, registries}; + +/// The derived totals and the registries' purity aggregate agree with RFC 0006. +pub fn totals_and_purity_aggregate_agree(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + check_totals(&world) +} + +/// Validate helper totals against one already parsed document snapshot. +fn check_totals(world: &World) -> Result<()> { + let registered: usize = world + .registries + .iter() + .map(|registry| registry.names().len()) + .sum(); + let written = world.map.rows.len() - world.map.unwritten(); + + // Both halves are partial on purpose. Gating either on *all eight* rows + // being written would defer every assertion in this function to the end of + // the split, and the totals a half-written split can already contradict are + // the ones that matter: full coverage is an exact equality because every + // registry is read at that point, while partial coverage asserts only that + // the rows read so far do not collectively exceed what RFC 0006 accepts. + // Without the partial form, a milestone that writes a registry row twice + // would be reported by nothing until the last child RFC landed. + if world.map.unwritten() == 0 { + ensure!( + registered == world.survey.accepted.len(), + "the registries together list {registered} helpers; RFC 0006 accepts {}", + world.survey.accepted.len() + ); + } else { + ensure!( + registered <= world.survey.accepted.len(), + "the registries together list {registered} helpers, but RFC 0006 accepts only {} — \ + with {} of 8 capability groups written", + world.survey.accepted.len(), + written + ); + } + + check_registry_aggregate(world) +} + +/// The registries' purity classes and `option added` count, against RFC 0006. +/// +/// Takes the [`World`] the caller has already parsed rather than a [`Repo`]: +/// this check and the totals beside it must describe one tree, so it must not +/// derive a second one of its own. +fn check_registry_aggregate(world: &World) -> Result<()> { + // The purity aggregate is taken over `New` rows only, because section 6.1's + // 52/4/1 counts the 57 proposed helpers. The registries carry all 60 + // accepted helpers, and the optioned rows include the filesystem-observing + // `glob`, so an all-row aggregate would be 54/5/1. + // + // Three of these hold for every prefix of the split rather than only at the + // end, so they run unconditionally and the exact equality does not. + // Section 6.1 states that *no* proposed helper is clock-, network-, or + // subprocess-observing, which is a hard zero and not a budget; the classes + // that do have a budget can be over-spent but never under-spent, so each is + // bounded above by its stated total. Deferring all of this until the last + // child RFC landed would mean a registry declaring `subprocess-observing` + // was reported by nothing for seven milestones. + let (want_pure, want_filesystem, want_environment) = world.survey.purity; + for forbidden in [ + registries::Purity::Clock, + registries::Purity::Network, + registries::Purity::Subprocess, + ] { + let found = new_rows_with(&world.registries, forbidden); + ensure!( + found == 0, + "the registries declare {found} helper(s) `{}`; RFC 0006 section 6.1 states no \ + proposed helper is", + forbidden.label() + ); + } + let pure = new_rows_with(&world.registries, registries::Purity::Pure); + let filesystem = new_rows_with(&world.registries, registries::Purity::Filesystem); + let environment = new_rows_with(&world.registries, registries::Purity::Environment); + let optioned: usize = world + .registries + .iter() + .map(|registry| registry.with_registration(Registration::OptionAdded)) + .sum(); + ensure!( + pure <= want_pure && filesystem <= want_filesystem && environment <= want_environment, + "the registries already declare {pure} pure / {filesystem} filesystem / {environment} \ + environment; RFC 0006 section 6.1 states only {want_pure}/{want_filesystem}/\ + {want_environment} in total" + ); + ensure!( + optioned <= world.survey.optioned.len(), + "the registries mark {optioned} rows `option added`; RFC 0006 table 11 lists only {}", + world.survey.optioned.len() + ); + if world.map.unwritten() == 0 { + ensure!( + (pure, filesystem, environment) == (want_pure, want_filesystem, want_environment), + "the registries' purity aggregate is {pure} pure / {filesystem} filesystem / \ + {environment} environment; RFC 0006 section 6.1 states {want_pure}/{want_filesystem}/\ + {want_environment}" + ); + ensure!( + optioned == world.survey.optioned.len(), + "the registries mark {optioned} rows `option added`; RFC 0006 table 11 states {}", + world.survey.optioned.len() + ); + } + Ok(()) +} + +/// How many `New` rows across `registries` carry `purity`. +/// +/// The aggregate is over `New` rows only, which is +/// [`registries::Registry::new_with_purity`]'s contract; this merely folds it +/// across the registries. +fn new_rows_with(registries: &[registries::Registry], purity: registries::Purity) -> usize { + registries + .iter() + .map(|registry| registry.new_with_purity(purity)) + .sum() +} + +/// The coverage map reports progress honestly. +/// +/// The count is printed rather than only asserted, because a half-finished +/// split satisfies every other obligation: the abandoned state and the finished +/// state are indistinguishable to a bijection check, so the one thing that +/// announces a stall is the number of groups still unwritten. `nextest.toml` +/// raises this test's success output to `immediate` so the line reaches the +/// terminal on a green run rather than being captured. +#[expect( + clippy::print_stdout, + reason = "obligation COV-4 requires the unwritten-group count in the passing \ + test's output, and this target prints nowhere else" +)] +pub fn coverage_map_status_is_reported(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + let unwritten = world.map.unwritten(); + println!( + "coverage map: {} of {} capability groups written; {unwritten} remaining", + world.map.rows.len() - unwritten, + world.map.rows.len() + ); + check_status(repo, &world) +} + +/// Validate reported map status against parsed registries and file existence. +fn check_status(repo: &Repo, world: &World) -> Result<()> { + ensure!( + world.map.rows.len() == 8, + "the coverage map has {} rows; expected 8", + world.map.rows.len() + ); + // Every accepted helper the map claims must have an owning row, and every + // written row must have a child file that exists. `parse` already checked + // that a written row carries a link; here the link is resolved. + // + // The base is the map's *own* file rather than its directory: a relative + // link is relative to the document containing it, and `resolve` derives the + // directory by popping the last segment. Passing `RFC_DIR` would pop into + // `docs/`, so every correct `0013-….md` link would resolve to + // `docs/0013-….md` and be reported missing. + for row in &world.map.rows { + if let Some(target) = &world.child_paths.get(&row.number) { + let path = links::resolve(super::RFC_0006, target).with_context(|| { + format!( + "coverage map row for RFC {} links to {target}, which climbs above the \ + repository root", + row.number + ) + })?; + ensure!( + repo.exists(&path)?, + "coverage map row for RFC {} is marked written and links to {target}, which \ + resolves to {path}; no such file exists", + row.number + ); + } + } + for registry in &world.registries { + let matched = world + .map + .rows + .iter() + .find(|row| row.number == registry.number); + ensure!( + matched.is_none_or(|row| row.is_written), + "RFC {} exists at {} but its coverage map row still says unwritten", + registry.number, + registry.file + ); + } + // The converse of the loop above, and the load-bearing direction: a registry + // that fails to parse is *absent* from `world.registries`, which every check + // reads as "not written yet". A row marked written with a link that resolves + // to an unparseable document would therefore satisfy every other check by + // being invisible to all of them. Requiring a parsed registry per written row + // closes that, and it is the same link resolution the loop above uses. + for row in &world.map.rows { + ensure!( + !row.is_written + || world + .registries + .iter() + .any(|registry| registry.number == row.number), + "coverage map row for RFC {} is marked written, but no registry was parsed for it — \ + its link must resolve to a child RFC with a readable section 5 registry table", + row.number + ); + } + Ok(()) +} + +/// Every relative link in the RFC corpus resolves. +pub fn inter_document_links_resolve(repo: &Repo) -> Result<()> { + let failures = links::dangling(repo)?; + ensure!( + failures.is_empty(), + "dangling inter-document links: {failures:?}" + ); + Ok(()) +} + +/// Every capability has a roadmap task, and each child's step names what it owns. +pub fn every_capability_has_a_roadmap_task(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + check_schedule(&world) +} + +/// Validate that each accepted helper is scheduled in its owning step. +fn check_schedule(world: &World) -> Result<()> { + let unscheduled = world.roadmap.unscheduled(world.survey.accepted.keys()); + ensure!( + unscheduled.is_empty(), + "accepted helpers {:?} are named in no roadmap capability step at all, so nothing \ + schedules them", + // Name the step each one belongs to. A helper named nowhere is exactly + // the case where the reader needs to be told where it was meant to go, + // and the coverage map is the only place that records it. + unscheduled + .iter() + .map(|name| { + world + .map + .rows + .iter() + .find(|row| row.claims().contains(name)) + .map_or_else( + || format!("{name} (claimed by no coverage-map row)"), + |row| format!("{name} (RFC {}, step {})", row.number, row.step), + ) + }) + .collect::<Vec<_>>() + ); + for row in &world.map.rows { + let missing = world + .roadmap + .missing_from_step(&row.step, row.claims().iter())?; + ensure!( + missing.is_empty(), + "the coverage map gives RFC {} roadmap step {}, but that step names none of {missing:?}", + row.number, + row.step + ); + } + Ok(()) +} + +/// Every child RFC discharges every clause of RFC 0006 section 6. +pub fn every_child_discharges_every_clause(repo: &Repo) -> Result<()> { + let world = World::load(repo)?; + check_discharges(repo, &world) +} + +/// Validate child discharges against the parent clauses in one snapshot. +fn check_discharges(repo: &Repo, world: &World) -> Result<()> { + let clauses: BTreeSet<String> = clauses::clause_ids(repo)?.into_iter().collect(); + for registry in &world.registries { + let discharged = clauses::discharged(repo, ®istry.file)?; + let undischarged: Vec<_> = clauses.difference(&discharged).cloned().collect(); + ensure!( + undischarged.is_empty(), + "{} does not discharge RFC 0006 clause(s) {undischarged:?}", + registry.file + ); + let invented: Vec<_> = discharged.difference(&clauses).cloned().collect(); + ensure!( + invented.is_empty(), + "{} discharges {invented:?}, which is not a clause of RFC 0006 section 6", + registry.file + ); + // The table says a clause was discharged; it cannot say the discharge + // says anything. The subsections are where the group's own contract is + // recorded, and their vacuity is what this task's principal risk names. + // + // Their ids and the table's must agree exactly. A subsection with no + // table row is a discharge a reviewer would read but the table would + // deny, and one is the only place the other's absence is visible. + let sections = clauses::check_section_five(repo, ®istry.file, ®istry.names())?; + ensure!( + sections == discharged, + "{} has section 5 subsections {sections:?} but a clause-discharge table listing \ + {discharged:?}; the two must name the same clauses", + registry.file + ); + } + Ok(()) +} + +#[cfg(test)] +#[path = "progress_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/progress_tests.rs b/tests/rfc_stdlib_coverage/progress_tests.rs new file mode 100644 index 000000000..6468d21fa --- /dev/null +++ b/tests/rfc_stdlib_coverage/progress_tests.rs @@ -0,0 +1,313 @@ +//! Validate partial budgets, complete equalities and reported delivery state. + +use super::*; +use crate::rfc_stdlib_coverage::validation_fixtures as fixture; +use rstest::rstest; + +#[test] +fn accepts_partial_and_complete_exact_totals() -> Result<()> { + let mut world = fixture::world(); + check_totals(&world)?; + world + .map + .rows + .iter_mut() + .for_each(|row| row.is_written = true); + check_totals(&world) +} + +#[rstest] +#[case::partial_helpers("partial_helpers", "accepts only 0")] +#[case::complete_helpers("complete_helpers", "RFC 0006 accepts 2")] +#[case::clock("clock", "helper(s) `clock-observing`")] +#[case::network("network", "helper(s) `network-observing`")] +#[case::subprocess("subprocess", "helper(s) `subprocess-observing`")] +#[case::pure_budget("pure_budget", "states only 0/0/0 in total")] +#[case::filesystem_budget("filesystem_budget", "declare 0 pure / 1 filesystem / 0 environment")] +#[case::environment_budget("environment_budget", "declare 0 pure / 0 filesystem / 1 environment")] +#[case::option_budget("option_budget", "table 11 lists only 0")] +#[case::pure_equality("pure_equality", "purity aggregate is 1 pure / 0 filesystem")] +#[case::filesystem_equality("filesystem_equality", "states 1/1/0")] +#[case::environment_equality("environment_equality", "states 1/0/1")] +#[case::option_equality("option_equality", "table 11 states 1")] +fn rejects_total_or_purity_mismatches(#[case] mutation: &str, #[case] diagnostic: &str) { + let mut world = fixture::world(); + if mutation.starts_with("complete") || mutation.ends_with("equality") { + world + .map + .rows + .iter_mut() + .for_each(|row| row.is_written = true); + } + match mutation { + "partial_helpers" => world.survey.accepted.clear(), + "complete_helpers" => { + world + .survey + .accepted + .insert("other".into(), fixture::helper("other")); + } + "clock" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Clock; + } + "network" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Network; + } + "subprocess" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Subprocess; + } + "pure_budget" => world.survey.purity.0 = 0, + "filesystem_budget" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Filesystem; + } + "environment_budget" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Environment; + } + "option_budget" => { + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .registration = Registration::OptionAdded; + } + "pure_equality" => world.survey.purity.0 = 2, + "filesystem_equality" => world.survey.purity.1 = 1, + "environment_equality" => world.survey.purity.2 = 1, + "option_equality" => world.survey.optioned.push("optioned".into()), + _ => {} + } + let error = check_totals(&world).expect_err("one aggregate condition changed"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} + +#[test] +fn optioned_rows_do_not_consume_new_helper_purity_budgets() -> Result<()> { + let mut world = fixture::world(); + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .registration = Registration::OptionAdded; + world + .registries + .first_mut() + .expect("fixture registry") + .rows + .first_mut() + .expect("fixture row") + .purity = registries::Purity::Filesystem; + world.survey.optioned.push("helper".into()); + world.survey.purity = (0, 0, 0); + check_registry_aggregate(&world) +} + +#[rstest] +#[case::unscheduled("unscheduled", "helper (RFC 0013, step 6.2)")] +#[case::unclaimed("unclaimed", "helper (claimed by no coverage-map row)")] +#[case::wrong_step("wrong_step", "step 6.2, but that step names none of [\"helper\"]")] +#[case::unknown_step("unknown_step", "roadmap step 6.11 does not exist")] +fn rejects_missing_or_misplaced_scheduled_helpers( + #[case] mutation: &str, + #[case] diagnostic: &str, +) { + let mut world = fixture::world(); + match mutation { + "unscheduled" => { + world + .roadmap + .names + .get_mut("6.2") + .expect("fixture step") + .clear(); + } + "unclaimed" => { + world + .roadmap + .names + .get_mut("6.2") + .expect("fixture step") + .clear(); + world + .map + .rows + .first_mut() + .expect("fixture owning map row") + .owns + .clear(); + } + "wrong_step" => { + world + .roadmap + .names + .get_mut("6.2") + .expect("fixture step") + .clear(); + world + .roadmap + .names + .get_mut("6.3") + .expect("fixture step") + .insert("helper".into()); + } + "unknown_step" => { + world + .map + .rows + .first_mut() + .expect("fixture owning map row") + .step = "6.11".into(); + } + _ => {} + } + let error = check_schedule(&world).expect_err("one scheduling condition changed"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} + +#[rstest] +#[case::count("count", "coverage map has 7 rows; expected 8")] +#[case::escape("escape", "links to ../../../outside.md, which climbs above")] +#[case::missing_file( + "missing_file", + "resolves to docs/rfcs/missing.md; no such file exists" +)] +#[case::unwritten("unwritten", "coverage map row still says unwritten")] +#[case::unparsed("unparsed", "marked written, but no registry was parsed for it")] +fn rejects_dishonest_progress(#[case] mutation: &str, #[case] diagnostic: &str) -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let repo = Repo::fixture(root)?; + let mut world = fixture::world(); + match mutation { + "count" => { + world.map.rows.pop(); + } + "escape" => { + world + .child_paths + .insert("0013".into(), "../../../outside.md".into()); + } + "missing_file" => { + world.child_paths.insert("0013".into(), "missing.md".into()); + } + "unwritten" => { + world + .map + .rows + .first_mut() + .expect("fixture owning map row") + .is_written = false; + } + "unparsed" => world.registries.clear(), + _ => {} + } + let error = check_status(&repo, &world).expect_err("one progress condition changed"); + ensure!(error.to_string().contains(diagnostic), "{error:#}"); + Ok(()) +} + +#[test] +fn accepts_a_written_row_linking_to_an_existing_parsed_child() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?; + dir.create_dir_all("docs/rfcs")?; + dir.write("docs/rfcs/0013-child.md", "fixture")?; + let mut world = fixture::world(); + world + .child_paths + .insert("0013".into(), "0013-child.md#registry".into()); + check_status(&Repo::fixture(root)?, &world)?; + check_schedule(&world) +} + +#[rstest] +#[case::missing("missing", "does not discharge RFC 0006 clause(s) [\"6.1\"]")] +#[case::invented("invented", "discharges [\"6.2\"], which is not a clause")] +#[case::section_mismatch("section_mismatch", "the two must name the same clauses")] +fn rejects_inconsistent_clause_sets( + #[case] mutation: &str, + #[case] diagnostic: &str, +) -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?; + dir.create_dir_all("docs/rfcs")?; + dir.write( + super::super::RFC_0006, + "## 6. Cross-cutting contract\n### 6.1. Purity\nContract.\n", + )?; + let child = concat!( + "## 5. Cross-cutting contract conformance\n", + "### 5.1. Registry\n`helper` has a group-specific obligation.\n", + "### Clause discharge\n| Clause | Discharge |\n| --- | --- |\n| `6.1` | Met |\n" + ); + let mutated_child = match mutation { + "missing" => child.replace("`6.1`", "`6.2`"), + "invented" => format!("{child}| `6.2` | Met |\n"), + "section_mismatch" => child.replace("### 5.1.", "### 5.2."), + _ => child.into(), + }; + let world = fixture::world(); + dir.write( + &world.registries.first().context("fixture registry")?.file, + mutated_child, + )?; + let error = check_discharges(&Repo::fixture(root)?, &world).expect_err("clause mismatch"); + ensure!(error.to_string().contains("docs/rfcs/0013-child.md")); + ensure!(error.to_string().contains(diagnostic), "{error:#}"); + Ok(()) +} + +#[test] +fn inter_document_check_reports_corpus_failures() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?; + dir.create_dir_all("docs/rfcs")?; + dir.write("docs/rfcs/0013-child.md", "[missing](missing.md)")?; + let error = inter_document_links_resolve(&Repo::fixture(root)?).expect_err("missing document"); + ensure!(error.to_string().contains("dangling inter-document links")); + ensure!(error.to_string().contains("docs/rfcs/0013-child.md:1")); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/registries.rs b/tests/rfc_stdlib_coverage/registries.rs new file mode 100644 index 000000000..2035e4b6c --- /dev/null +++ b/tests/rfc_stdlib_coverage/registries.rs @@ -0,0 +1,279 @@ +//! Reading the five-column registry every child RFC carries. +//! +//! Each child RFC's section 5.1 is a table with one row per helper it owns: +//! Helper, Namespace, Registration, Purity class, Manifest query. That table is +//! the artefact RFC 0006 section 6.1 asks for — the per-helper record of the +//! contract that section 6.1, table 3, section 6.2, and section 6.9 impose — +//! and it is also the machine-readable anchor for the ownership bijection. +//! +//! Sections 5.2 to 5.5 of each child discharge the remaining contract clauses +//! per helper. This module reads only the registry; [`super::clauses`] reads the +//! rest. + +use std::collections::BTreeSet; + +use anyhow::{Context, Result, ensure}; + +use super::{Namespace, RawRow, Registration, Repo, Row, Section, strip_backticks}; + +/// The heading of a child RFC's registry table. +const REGISTRY_HEADING: &str = "### 5.1. Registry"; + +/// Purity classes as RFC 0006 table 2 names them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum Purity { + /// Result depends only on the supplied value and arguments. + Pure, + /// Reads the wall clock. + Clock, + /// Reads process environment variables. + Environment, + /// Reads filesystem metadata or contents. + Filesystem, + /// Performs a network request. + Network, + /// Spawns a child process. + Subprocess, +} + +impl Purity { + /// Parse a purity cell, which must spell the class as table 2 does. + pub(super) fn parse(cell: &str) -> Option<Self> { + match cell.trim().to_ascii_lowercase().as_str() { + "pure" => Some(Self::Pure), + "clock-observing" => Some(Self::Clock), + "environment-observing" => Some(Self::Environment), + "filesystem-observing" => Some(Self::Filesystem), + "network-observing" => Some(Self::Network), + "subprocess-observing" => Some(Self::Subprocess), + _ => None, + } + } + + /// Whether RFC 0006 table 2 admits this class to manifest queries. + const fn manifest_query(self) -> bool { + matches!(self, Self::Pure) + } + + /// The lowercase label used in failure messages. + pub(super) const fn label(self) -> &'static str { + match self { + Self::Pure => "pure", + Self::Clock => "clock-observing", + Self::Environment => "environment-observing", + Self::Filesystem => "filesystem-observing", + Self::Network => "network-observing", + Self::Subprocess => "subprocess-observing", + } + } +} + +/// One row of a child RFC's registry. +pub(super) struct RegistryRow { + /// The helper the row registers or extends. + pub(super) helper: Row, + /// Whether the row introduces a helper or adds an option to an existing one. + pub(super) registration: super::Registration, + /// The helper's purity class. + pub(super) purity: Purity, +} + +/// A child RFC's registry. +pub(super) struct Registry { + /// Repository-relative path of the child RFC. + pub(super) file: String, + /// The RFC number the file reserves, as `N`. + pub(super) number: String, + /// Its rows, in document order. + pub(super) rows: Vec<RegistryRow>, +} + +impl Registry { + /// The helper names the registry lists. + pub(super) fn names(&self) -> BTreeSet<String> { + self.rows + .iter() + .map(|row| row.helper.name.clone()) + .collect() + } + + /// The `New` rows with the given purity class. + /// + /// The registration kind is part of the query rather than a caller-side + /// filter, because the only purity count anyone asks for is section 6.1's + /// aggregate, and that ranges over the 57 proposed helpers. The `OptionAdded` + /// rows are excluded: they are existing helpers being extended, none of them + /// helped make up section 6.1's 52/4/1, and the three together would turn a + /// correct document's aggregate into 54/5/1. Making that the accessor's + /// contract means a future caller cannot get the wrong answer by forgetting + /// the filter. + pub(super) fn new_with_purity(&self, purity: Purity) -> usize { + self.rows + .iter() + .filter(|row| row.purity == purity && row.registration == Registration::New) + .count() + } + + /// The registered names with the given registration kind. + pub(super) fn with_registration(&self, registration: Registration) -> usize { + self.rows + .iter() + .filter(|row| row.registration == registration) + .count() + } +} + +/// Read one child RFC's registry. +pub(super) fn parse(repo: &Repo, file: &str) -> Result<Registry> { + let text = repo.read(file)?; + let document = Section::whole(&text); + let number = rfc_number(file).with_context(|| { + format!("{file} is not named as an RFC; expected a leading four-digit number") + })?; + ensure!( + number.as_str() >= "0013", + "{file} reserves RFC {number}; child RFCs start at 0013" + ); + let section = document + .subsection(REGISTRY_HEADING) + .with_context(|| format!("{file} has no registry table at {REGISTRY_HEADING}"))?; + let Some((_, rows)) = section + .tables() + .into_iter() + .find(|(heading, rows)| heading.starts_with("5.1.") && !rows.is_empty()) + else { + return Err(anyhow::anyhow!( + "the registry subsection of {file} contains no table" + )); + }; + + let mut parsed = Vec::new(); + let mut seen: BTreeSet<String> = BTreeSet::new(); + for row in &rows { + let parsed_row = parse_row(file, row, &mut seen)?; + parsed.push(parsed_row); + } + Ok(Registry { + file: file.to_owned(), + number, + rows: parsed, + }) +} + +/// Read one registry row, rejecting a name the registry already lists. +fn parse_row(file: &str, row: &RawRow, seen: &mut BTreeSet<String>) -> Result<RegistryRow> { + let name = strip_backticks(row.cell(0, "helper")?).to_owned(); + ensure!( + !name.is_empty(), + "{file}:{} has an empty helper cell", + row.line + ); + ensure!( + seen.insert(name.clone()), + "{file}:{} registers {name} twice", + row.line + ); + let namespace = parse_namespace(file, row, &name)?; + let registration = parse_registration(file, row, &name)?; + let purity_cell = row.cell(3, "purity class")?; + let purity = Purity::parse(purity_cell).with_context(|| { + format!( + "{file}:{} gives {name} purity class {purity_cell:?}, which RFC 0006 table 2 does not \ + define", + row.line + ) + })?; + check_manifest_query(file, row, &name, purity)?; + Ok(RegistryRow { + helper: Row { name, namespace }, + registration, + purity, + }) +} + +/// Read a row's namespace cell. +fn parse_namespace(file: &str, row: &RawRow, name: &str) -> Result<Namespace> { + let cell = row.cell(1, "namespace")?.trim().to_ascii_lowercase(); + match cell.as_str() { + "filter" => Ok(Namespace::Filter), + "test" => Ok(Namespace::Test), + "function" => Ok(Namespace::Function), + other => Err(anyhow::anyhow!( + "{file}:{} gives {name} namespace {other:?}; expected `filter`, `test`, or `function`", + row.line + )), + } +} + +/// Read a row's registration cell. +fn parse_registration(file: &str, row: &RawRow, name: &str) -> Result<Registration> { + let cell = strip_backticks(row.cell(2, "registration")?) + .trim() + .to_ascii_lowercase(); + match cell.as_str() { + "new" => Ok(Registration::New), + "option added" => Ok(Registration::OptionAdded), + other => Err(anyhow::anyhow!( + "{file}:{} gives {name} registration {other:?}; expected `new` or `option added`", + row.line + )), + } +} + +/// Assert a row's manifest-query cell agrees with its purity class. +/// +/// RFC 0006 table 2 admits only pure helpers to manifest queries, so the two +/// cells are not independent. +fn check_manifest_query(file: &str, row: &RawRow, name: &str, purity: Purity) -> Result<()> { + let cell = row.cell(4, "manifest query")?.trim().to_ascii_lowercase(); + let declared = match cell.as_str() { + "yes" => true, + "no" => false, + other => { + return Err(anyhow::anyhow!( + "{file}:{} gives {name} manifest query {other:?}; expected `yes` or `no`", + row.line + )); + } + }; + ensure!( + declared == purity.manifest_query(), + "{file}:{} registers {name} as {} with manifest query {cell}; RFC 0006 table 2 says {}", + row.line, + purity.label(), + if purity.manifest_query() { "yes" } else { "no" } + ); + Ok(()) +} + +/// The RFC number a child RFC's filename reserves. +pub(super) fn rfc_number(file: &str) -> Option<String> { + let name = file.rsplit('/').next()?; + let digits: String = name.chars().take_while(char::is_ascii_digit).collect(); + (digits.len() == 4).then_some(digits) +} + +/// Every child RFC the coverage map reserves, sorted by number. +/// +/// The number range is taken from the map's rows rather than from "every file +/// numbered 0013 or later". The corpus is open-ended — RFC 0021 and beyond are +/// a question of when, not whether — and a future RFC that happens to carry a +/// five-column table under a `5.1.` heading is not a child of this survey. The +/// map is where the reservation is recorded, so it is what decides. +pub(super) fn parse_all(repo: &Repo, children: &[String]) -> Result<Vec<Registry>> { + let mut found = Vec::new(); + for file in repo.markdown_files(super::RFC_DIR)? { + let Some(number) = rfc_number(&file) else { + continue; + }; + if children.contains(&number) { + found.push(parse(repo, &file)?); + } + } + found.sort_by(|left, right| left.number.cmp(&right.number)); + Ok(found) +} + +#[cfg(test)] +#[path = "registries_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/registries_tests.rs b/tests/rfc_stdlib_coverage/registries_tests.rs new file mode 100644 index 000000000..366417ba9 --- /dev/null +++ b/tests/rfc_stdlib_coverage/registries_tests.rs @@ -0,0 +1,225 @@ +//! Isolated registry fixtures and per-cell vocabulary diagnostics. + +use super::*; +use rstest::rstest; + +const FILE: &str = "docs/rfcs/0013-child.md"; + +/// Write one isolated registry document, keeping its directory alive. +fn fixture(file: &str, text: &str) -> Result<(tempfile::TempDir, Repo)> { + let temp = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temp.path()).context("UTF-8 fixture path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/rfcs")?; + repo.dir.write(file, text)?; + Ok((temp, repo)) +} + +/// Build a registry whose data row starts at line five. +fn registry(cells: &str) -> String { + format!( + "# Child\n### 5.1. Registry\n| Helper | Namespace | Registration | Purity | Query |\n| --- | --- | --- | --- | --- |\n{cells}\n" + ) +} + +#[rstest] +#[case::empty("| `` | filter | new | pure | yes |", "empty helper cell")] +#[case::namespace( + "| `alpha` | macro | new | pure | yes |", + "expected `filter`, `test`, or `function`" +)] +#[case::registration( + "| `alpha` | filter | old | pure | yes |", + "expected `new` or `option added`" +)] +#[case::purity( + "| `alpha` | filter | new | ambient | yes |", + "table 2 does not define" +)] +#[case::query("| `alpha` | filter | new | pure | maybe |", "expected `yes` or `no`")] +#[case::pure_disagreement("| `alpha` | filter | new | pure | no |", "table 2 says yes")] +#[case::impure_disagreement( + "| `alpha` | filter | new | filesystem-observing | yes |", + "table 2 says no" +)] +fn invalid_registry_cells_report_file_and_line( + #[case] cells: &str, + #[case] expected: &str, +) -> Result<()> { + let (_temp, repo) = fixture(FILE, ®istry(cells))?; + let error = parse(&repo, FILE) + .err() + .context("invalid registry must fail")?; + let diagnostic = format!("{error:#}"); + ensure!(diagnostic.contains(expected), "{diagnostic}"); + ensure!(diagnostic.contains(&format!("{FILE}:5")), "{diagnostic}"); + Ok(()) +} + +#[rstest] +#[case::missing_heading("# Child", "no registry table")] +#[case::empty_table("### 5.1. Registry\n| Helper |\n| --- |", "contains no table")] +#[case::no_table("### 5.1. Registry\nProse only", "contains no table")] +fn missing_registry_data_is_rejected(#[case] text: &str, #[case] expected: &str) -> Result<()> { + let (_temp, repo) = fixture(FILE, text)?; + let error = parse(&repo, FILE) + .err() + .context("missing registry must fail")?; + ensure!(error.to_string().contains(expected), "{error}"); + ensure!(error.to_string().contains(FILE), "{error}"); + Ok(()) +} + +#[rstest] +#[case::malformed("docs/rfcs/child.md", "not named as an RFC")] +#[case::parent_number("docs/rfcs/0012-child.md", "child RFCs start at 0013")] +fn registry_filename_is_validated(#[case] file: &str, #[case] expected: &str) -> Result<()> { + let (_temp, repo) = fixture(file, ®istry("| `alpha` | filter | new | pure | yes |"))?; + let error = parse(&repo, file).err().context("bad filename must fail")?; + ensure!(error.to_string().contains(expected), "{error}"); + Ok(()) +} + +#[test] +fn duplicate_registry_names_report_the_second_row() -> Result<()> { + let data = "| `alpha` | filter | new | pure | yes |"; + let (_temp, repo) = fixture(FILE, ®istry(&format!("{data}\n{data}")))?; + let error = parse(&repo, FILE).err().context("duplicate must fail")?; + ensure!( + error + .to_string() + .contains(&format!("{FILE}:6 registers alpha twice")), + "{error}" + ); + Ok(()) +} + +#[rstest] +#[case::filter("filter", Namespace::Filter)] +#[case::test("test", Namespace::Test)] +#[case::function("function", Namespace::Function)] +fn all_namespace_forms_parse(#[case] label: &str, #[case] expected: Namespace) -> Result<()> { + let (_temp, repo) = fixture( + FILE, + ®istry(&format!("| `alpha` | {label} | new | pure | yes |")), + )?; + let parsed = parse(&repo, FILE)?; + { + let actual = parsed + .rows + .first() + .context("registry row exists")? + .helper + .namespace; + let expected_value = expected; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[rstest] +#[case::pure("pure", Purity::Pure, "yes")] +#[case::clock("clock-observing", Purity::Clock, "no")] +#[case::environment("environment-observing", Purity::Environment, "no")] +#[case::filesystem("filesystem-observing", Purity::Filesystem, "no")] +#[case::network("network-observing", Purity::Network, "no")] +#[case::subprocess("subprocess-observing", Purity::Subprocess, "no")] +fn purity_vocabulary_and_counts_exclude_option_rows( + #[case] label: &str, + #[case] expected: Purity, + #[case] query: &str, +) -> Result<()> { + let rows = format!( + "| `alpha` | filter | new | {label} | {query} |\n| `beta` | filter | option added | {label} | {query} |" + ); + let (_temp, repo) = fixture(FILE, ®istry(&rows))?; + let parsed = parse(&repo, FILE)?; + { + let actual = parsed.new_with_purity(expected); + let expected_value = 1; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + { + let actual = parsed.with_registration(Registration::New); + let expected_value = 1; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + { + let actual = parsed.with_registration(Registration::OptionAdded); + let expected_value = 1; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + { + let actual = parsed.names(); + let expected_value = BTreeSet::from(["alpha".into(), "beta".into()]); + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[test] +fn parse_all_uses_only_reserved_numbers_and_sorts_them() -> Result<()> { + let text = registry("| `alpha` | filter | new | pure | yes |"); + let (_temp, repo) = fixture("docs/rfcs/0014-child.md", &text)?; + repo.dir.write(FILE, &text)?; + repo.dir + .write("docs/rfcs/0099-other.md", "not a registry")?; + repo.dir.write("docs/rfcs/readme.md", "not numbered")?; + repo.dir.write("docs/rfcs/0015-child.txt", "not Markdown")?; + let parsed = parse_all(&repo, &["0014".into(), "0013".into()])?; + { + let actual = parsed + .iter() + .map(|registry| registry.number.as_str()) + .collect::<Vec<_>>(); + let expected_value = ["0013", "0014"]; + ensure!( + actual == expected_value, + "expected {expected_value:?}, found {actual:?}" + ); + }; + Ok(()) +} + +#[rstest] +#[case("13-child.md", None)] +#[case("00133-child.md", None)] +#[case("٠٠١٣-child.md", None)] +#[case("docs/rfcs/0013-child.md", Some("0013"))] +fn filename_numbers_require_four_ascii_digits(#[case] input: &str, #[case] expected: Option<&str>) { + assert_eq!(rfc_number(input).as_deref(), expected); +} + +#[rstest] +#[case::namespace("| `alpha` |", "namespace")] +#[case::registration("| `alpha` | filter |", "registration")] +#[case::purity("| `alpha` | filter | new |", "purity class")] +#[case::query("| `alpha` | filter | new | pure |", "manifest query")] +fn narrow_registry_rows_report_the_missing_column( + #[case] cells: &str, + #[case] expected: &str, +) -> Result<()> { + let (_temp, repo) = fixture(FILE, ®istry(cells))?; + let error = parse(&repo, FILE).err().context("narrow row must fail")?; + let diagnostic = format!("{error:#}"); + ensure!( + diagnostic.contains(&format!("row at line 5 has no {expected} column")), + "{diagnostic}" + ); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/repo_tests.rs b/tests/rfc_stdlib_coverage/repo_tests.rs new file mode 100644 index 000000000..826ab391d --- /dev/null +++ b/tests/rfc_stdlib_coverage/repo_tests.rs @@ -0,0 +1,100 @@ +//! Exercise the capability-scoped document adapter and shared name readers. + +use super::*; +use anyhow::ensure; +use rstest::rstest; + +#[rstest] +#[case(" `name` ", "name")] +#[case("name", "name")] +#[case("`name", "`name")] +#[case("name`", "name`")] +#[case("``name``", "`name`")] +fn strips_only_a_surrounding_backtick_pair(#[case] text: &str, #[case] expected: &str) { + assert_eq!(strip_backticks(text), expected); +} + +#[rstest] +#[case("—", &[])] +#[case(" - ", &[])] +#[case("`alpha` / `beta`", &["alpha", "beta"])] +#[case("`alpha` / ``", &["alpha"])] +#[case("`alpha/beta`", &["alpha/beta"])] +fn reads_alias_groups_without_substring_splitting(#[case] text: &str, #[case] expected: &[&str]) { + assert_eq!(names_in(text), expected); +} + +#[test] +fn shared_name_readers_preserve_order_and_set_identity() { + assert_eq!( + backticked("`beta` then `alpha` then `beta`"), + ["beta", "alpha", "beta"] + ); + assert_eq!( + name_set(["beta", "alpha", "beta"]), + name_set(["alpha", "beta"]) + ); + assert_eq!(backticked("plain text"), Vec::<String>::new()); + assert_eq!(backticked("`unterminated"), ["unterminated"]); + assert_eq!(Namespace::Function.label(), "function"); + assert_eq!(Registration::OptionAdded.label(), "option added"); +} + +#[test] +fn repository_reads_and_lists_only_its_isolated_fixture() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = Utf8Path::from_path(temporary.path()).context("UTF-8 fixture path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/nested")?; + repo.dir.write("docs/z.md", "last")?; + repo.dir.write("docs/a.md", "first")?; + repo.dir.write("docs/ignored.txt", "ignored")?; + repo.dir.write("docs/nested/hidden.md", "nested")?; + ensure!(repo.read("docs/a.md")? == "first", "fixture read differs"); + ensure!(repo.exists("docs/a.md")?, "fixture document should exist"); + ensure!( + !repo.exists("docs/missing.md")?, + "missing document should not exist" + ); + let listed = repo.markdown_files("docs")?; + ensure!( + listed == ["docs/a.md", "docs/z.md"], + "unexpected listed files: {listed:?}" + ); + let read_error = repo.read("missing.md").expect_err("missing read must fail"); + ensure!( + format!("{read_error:#}").contains("read missing.md"), + "{read_error:#}" + ); + let list_error = repo + .markdown_files("missing") + .expect_err("missing directory must fail"); + ensure!( + format!("{list_error:#}").contains("list missing"), + "{list_error:#}" + ); + repo.dir.write("invalid.md", [0xff])?; + let encoding_error = repo + .read("invalid.md") + .expect_err("non-UTF-8 document must fail"); + ensure!( + format!("{encoding_error:#}").contains("read invalid.md"), + "{encoding_error:#}" + ); + Ok(()) +} + +#[test] +fn fixture_open_failure_keeps_the_root_context() -> Result<()> { + let temporary = tempfile::tempdir()?; + let root = Utf8Path::from_path(temporary.path()).context("UTF-8 fixture path")?; + let missing = root.join("missing"); + let error = Repo::fixture(&missing) + .err() + .context("missing root must fail")?; + ensure!( + format!("{error:#}").contains(&format!("open fixture root {missing}")), + "{error:#}" + ); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/roadmap.rs b/tests/rfc_stdlib_coverage/roadmap.rs new file mode 100644 index 000000000..c38793074 --- /dev/null +++ b/tests/rfc_stdlib_coverage/roadmap.rs @@ -0,0 +1,165 @@ +//! Reading the roadmap's phase 6 capability steps. +//! +//! Roadmap 6.1.1 settles that delivery is tracked by roadmap checkboxes rather +//! than by issues, so the roadmap is the only place a reviewer can see which +//! capability is still outstanding. That makes the link from a child RFC to its +//! step load-bearing: a helper with no roadmap task has no channel through which +//! it can be scheduled, and a step whose bullets name no helper has nothing to +//! tick off. +//! +//! Steps 6.1 and 6.10 onwards are outside the capability set. Step 6.1 is this +//! split itself, and 6.10 decides the deferred candidates, which by construction +//! must not appear in a registry. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, ensure}; + +use super::{ROADMAP, Repo, Section, backticked}; + +/// The first capability step's heading text, with the `### ` prefix removed. +const FIRST_STEP: &str = "6.2."; + +/// The heading text that ends the capability range, `### ` prefix removed. +const LAST_STEP: &str = "6.10."; + +/// The roadmap's capability steps, keyed by their number. +pub(super) struct Steps { + /// Backticked names per step number, in document order. + pub(super) names: BTreeMap<String, BTreeSet<String>>, + /// The step number each heading carried, in document order. + pub(super) order: Vec<String>, +} + +impl Steps { + /// The names named anywhere in the capability steps. + pub(super) fn all_names(&self) -> BTreeSet<String> { + self.names.values().flatten().cloned().collect() + } + + /// The backticked names a step contains, or an error naming the known steps. + pub(super) fn names_for(&self, step: &str) -> Result<&BTreeSet<String>> { + self.names.get(step).with_context(|| { + format!( + "roadmap step {step} does not exist; the capability steps are {:?}", + self.order + ) + }) + } + + /// The subset of `names` that appears in no capability step at all. + pub(super) fn unscheduled<'a>( + &self, + names: impl IntoIterator<Item = &'a String>, + ) -> Vec<String> { + let scheduled = self.all_names(); + names + .into_iter() + .filter(|name| !scheduled.contains(*name)) + .cloned() + .collect() + } + + /// The subset of `names` absent from `step`. + pub(super) fn missing_from_step<'a>( + &self, + step: &str, + names: impl IntoIterator<Item = &'a String>, + ) -> Result<Vec<String>> { + let present = self.names_for(step)?; + Ok(names + .into_iter() + .filter(|name| !present.contains(*name)) + .cloned() + .collect()) + } +} + +/// Read the roadmap's capability steps. +pub(super) fn parse(repo: &Repo) -> Result<Steps> { + let text = repo.read(ROADMAP)?; + let document = Section::whole(&text); + let section_6 = document + .subsection("## 6. Template standard-library expansion") + .context("docs/roadmap.md has no section 6")?; + + let mut names: BTreeMap<String, BTreeSet<String>> = BTreeMap::new(); + let mut order: Vec<String> = Vec::new(); + let mut in_range = false; + + // Walked through `subsections()`, which pairs each heading with its own + // body and skips fenced lines, rather than off the section's raw lines. + // The raw-line scan was fence-blind: a fenced example quoting a `### 6.5.` + // heading would have been read as a step heading, and a fenced bullet would + // have been read as naming helpers the roadmap does not schedule. Phase 6 + // carries no fences today, so both would have been latent; going through + // the structure layer keeps that a fact about the document rather than a + // precondition of this function. + // + // The heading itself is not part of the body `subsections()` returns, so + // the per-step split that the raw-line scan maintained with a latch falls + // out of the iteration: each subsection's bullets belong to that + // subsection's own step and to no other. The `in_range` latch still spans + // the whole walk, because step 6.1's own bullets must stay ignored and it + // precedes 6.2. + for found in section_6.subsections() { + let Some(step) = step_of(&found.heading, &mut in_range)? else { + continue; + }; + order.push(step.clone()); + names + .entry(step) + .or_default() + .extend(found.body.iter().flat_map(|line| backticked(line))); + } + + ensure!( + !order.is_empty(), + "docs/roadmap.md has no capability steps starting {FIRST_STEP}" + ); + ensure!( + names.len() == 8, + "docs/roadmap.md yields {} capability steps between {FIRST_STEP} and {LAST_STEP}; \ + expected eight, one per child RFC 0013 to 0020", + names.len() + ); + ensure!( + names.values().any(|step| !step.is_empty()), + "no roadmap capability step names a backticked helper, so the task-bullet parse matched \ + nothing and every helper would look unscheduled" + ); + Ok(Steps { names, order }) +} + +/// Read one `### ` heading, updating the capability-range latch. +/// +/// Returns the step number when the heading falls inside the range and `None` +/// when it falls outside it, in which case the latch is set so the bullets that +/// follow are ignored. `in_range` is threaded rather than returned so the +/// caller can go on recording bullets per step without re-deriving the range. +/// +/// The latch opens at [`FIRST_STEP`] and closes at [`LAST_STEP`]. A heading +/// between them that is not numbered under phase 6 is rejected here, which is +/// what makes the phase-6 prefix and the step-number derivation agree: the +/// prefix alone would admit a heading whose number is read back as something +/// else. +fn step_of(heading: &str, in_range: &mut bool) -> Result<Option<String>> { + let number = heading.split('.').take(2).collect::<Vec<_>>().join("."); + if heading.starts_with(FIRST_STEP) { + *in_range = true; + } else if heading.starts_with(LAST_STEP) { + *in_range = false; + } + if !*in_range { + return Ok(None); + } + ensure!( + number.starts_with("6."), + "roadmap step heading {heading:?} is not numbered under phase 6" + ); + Ok(Some(number)) +} + +#[cfg(test)] +#[path = "roadmap_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/roadmap_tests.rs b/tests/rfc_stdlib_coverage/roadmap_tests.rs new file mode 100644 index 000000000..918a27d5c --- /dev/null +++ b/tests/rfc_stdlib_coverage/roadmap_tests.rs @@ -0,0 +1,106 @@ +//! Validate capability-step boundaries and scheduling diagnostics. + +use super::*; +use rstest::rstest; + +/// Render eight capability steps without depending on the live roadmap. +fn roadmap() -> String { + let steps = (2..10) + .map(|step| format!("### 6.{step}. Capability\n- `helper{step}`\n")) + .collect::<Vec<_>>() + .concat(); + format!( + "## 6. Template standard-library expansion\n### 6.1. Shared\n`ignored`\n{steps}### 6.10. Deferred\n`deferred`\n" + ) +} + +/// Parse one isolated roadmap without writing tracked documents. +fn read(text: &str) -> Result<Steps> { + let temporary = tempfile::tempdir()?; + let root = camino::Utf8Path::from_path(temporary.path()).context("UTF-8 fixture root")?; + let dir = cap_std::fs_utf8::Dir::open_ambient_dir(root, cap_std::ambient_authority())?; + dir.create_dir_all("docs")?; + dir.write(ROADMAP, text)?; + parse(&Repo::fixture(root)?) +} + +#[test] +fn schedules_only_real_capability_step_bodies() -> Result<()> { + let text = roadmap().replace( + "- `helper2`", + "- `helper2`\n```md\n### 6.5. Example\n`ghost`\n```", + ); + let steps = read(&text)?; + ensure!( + steps.order.len() == 8, + "fixture result differs from expected contract" + ); + ensure!( + steps.all_names().len() == 8, + "fixture result differs from expected contract" + ); + ensure!(!steps.all_names().contains("ignored")); + ensure!(!steps.all_names().contains("ghost")); + ensure!(!steps.all_names().contains("deferred")); + let names = vec!["helper2".into(), "absent".into()]; + ensure!( + steps.unscheduled(&names) == vec!["absent"], + "fixture result differs from expected contract" + ); + ensure!( + steps.missing_from_step("6.3", &names)? == names, + "fixture result differs from expected contract" + ); + ensure!( + steps + .names_for("6.11") + .err() + .context("unknown step")? + .to_string() + .contains("roadmap step 6.11 does not exist") + ); + Ok(()) +} + +#[rstest] +#[case::section("section", "docs/roadmap.md has no section 6")] +#[case::start("start", "no capability steps starting 6.2.")] +#[case::count("count", "yields 7 capability steps")] +#[case::names("names", "no roadmap capability step names a backticked helper")] +#[case::phase("phase", "not numbered under phase 6")] +fn rejects_invalid_capability_structure(#[case] mutation: &str, #[case] diagnostic: &str) { + let valid = roadmap(); + let text = match mutation { + "section" => valid.replace("## 6.", "## 5."), + "start" => valid.replace("### 6.2.", "### 6.1."), + "count" => valid.replace("### 6.3. Capability\n- `helper3`\n", ""), + "names" => valid.replace('`', ""), + "phase" => valid.replace("### 6.3.", "### 7.3."), + _ => valid, + }; + let error = read(&text).err().expect("invalid fixture must fail"); + assert!(error.to_string().contains(diagnostic), "{error:#}"); +} + +#[test] +fn range_latch_opens_and_closes_at_exact_step_boundaries() -> Result<()> { + let mut latch = false; + ensure!( + step_of("6.1. Shared", &mut latch)?.is_none(), + "fixture result differs from expected contract" + ); + ensure!( + step_of("6.2. First", &mut latch)? == Some("6.2".into()), + "fixture result differs from expected contract" + ); + ensure!( + step_of("6.9. Last", &mut latch)? == Some("6.9".into()), + "fixture result differs from expected contract" + ); + ensure!( + step_of("6.10. Deferred", &mut latch)?.is_none(), + "fixture result differs from expected contract" + ); + ensure!(!latch); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/section7.rs b/tests/rfc_stdlib_coverage/section7.rs new file mode 100644 index 000000000..1078ed54a --- /dev/null +++ b/tests/rfc_stdlib_coverage/section7.rs @@ -0,0 +1,336 @@ +//! Reading RFC 0006 section 7's disposition tables. +//! +//! The survey records one disposition per surveyed candidate across seven +//! tables. This module reads those tables, expands the alias groups the tables +//! collapse into single rows, and applies the two prose exceptions: section +//! 7.8's rename list, and the optioned helpers table 11 counts but never names. +//! +//! What it returns is [`Read`], not the accepted set. The caller still has to +//! take the complement of the rejected and deferred names against it, since the +//! deny set is defined by that complement rather than by any row. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result, ensure}; + +use super::{ + Disposition, Namespace, RFC_0006, Row, Section, backticked, + inventory::{CANDIDATE_TABLES, CandidateTable, OPTIONED, RENAMES}, + names_in, +}; + +/// What reading section 7 yields before the two prose exceptions are applied. +pub(super) struct Read { + /// Accepted helpers by registered name. + pub(super) accepted: BTreeMap<String, Row>, + /// The new helpers, in document order. + pub(super) new_rows: Vec<Row>, + /// The section 8 subsection each accepted name is specified in. + pub(super) sections_of: BTreeMap<String, String>, + /// The section 8 subsection cited by each row of *any* disposition. + /// + /// Kept apart from `sections_of` because the optioned helpers are named by + /// reject rows, whose names must not enter the section-8 membership the + /// coverage map expands. Only [`apply_optioned`] reads this map. + pub(super) cited: BTreeMap<String, String>, + /// Every name that appears in a section 7 name cell. + pub(super) surveyed_names: BTreeSet<String>, + /// The namespace each surveyed name's own table places it in. + /// + /// Recorded rather than assumed, for the reason [`Optioned`]'s namespace + /// field gives: the coverage check compares a child registry's namespace + /// column against the value derived here, so a hardcoded default makes a + /// *correct* child RFC fail the moment a renamed helper is not a filter. + pub(super) namespaces: BTreeMap<String, Namespace>, + /// Names section 9 defers. + pub(super) deferred: BTreeSet<String>, + /// Names section 10 rejects. + pub(super) rejected: BTreeSet<String>, + /// Accept rows read. + pub(super) accept_rows: usize, + /// Defer rows read. + pub(super) defer_rows: usize, + /// Reject rows read. + pub(super) reject_rows: usize, +} + +/// Read the seven disposition tables of section 7. +pub(super) fn read_section_7(section_7: &Section<'_>) -> Result<Read> { + let mut read = Read { + accepted: BTreeMap::new(), + new_rows: Vec::new(), + sections_of: BTreeMap::new(), + cited: BTreeMap::new(), + surveyed_names: BTreeSet::new(), + namespaces: BTreeMap::new(), + deferred: BTreeSet::new(), + rejected: BTreeSet::new(), + accept_rows: 0, + defer_rows: 0, + reject_rows: 0, + }; + + for (heading, rows) in section_7.tables() { + let Some(table) = CANDIDATE_TABLES + .iter() + .find(|table| table.heading == heading) + else { + continue; + }; + for row in &rows { + let names = names_in(row.cell(0, "name")?); + read.surveyed_names.extend(names.iter().cloned()); + record_namespaces(&mut read, &names, table.namespace)?; + let disposition = row.cell(table.disposition_column, "disposition")?.trim(); + let resolution = row.cell(table.disposition_column + 1, "resolution")?; + record_cited(&mut read, resolution, &names); + match classify(disposition) { + Some(Disposition::Accept) => { + read.accept_rows += 1; + let entry = Entry { + table, + names: &names, + disposition, + resolution, + line: row.line, + }; + record_accept(&mut read, &entry)?; + } + Some(Disposition::Defer) => { + read.defer_rows += 1; + read.deferred.extend(names); + } + Some(Disposition::Reject) => { + read.reject_rows += 1; + read.rejected.extend(names); + } + None => { + return Err(anyhow::anyhow!( + "unknown disposition {disposition:?} at {RFC_0006}:{}", + row.line + )); + } + } + } + } + Ok(read) +} + +/// Record the namespace each name's table places it in. +/// +/// A name may be surveyed by more than one table — `win_splitdrive` in the +/// filters table is the shape the rename exceptions turn on — and the tables +/// disagree about the namespace when a row is listed in the wrong one. Two +/// tables agreeing is not an error; two disagreeing is, because the derived +/// namespace would then depend on table order. +fn record_namespaces(read: &mut Read, names: &[String], namespace: Namespace) -> Result<()> { + for name in names { + let displaced = read.namespaces.insert(name.clone(), namespace); + ensure!( + displaced.is_none_or(|earlier| earlier == namespace), + "surveyed name {name} is listed as both {} and {}, so section 7 places it in two \ + namespaces", + displaced.map_or("no namespace", Namespace::label), + namespace.label() + ); + } + Ok(()) +} + +/// Record the section 8 subsection a section 7 row cites, if it cites one. +/// +/// Every disposition is recorded, not only acceptance. The three `Option added` +/// helpers are named by *reject* rows, which defer to section 8 for their +/// contract, and their names must not enter `sections_of`: that map is the +/// accepted set's section 8 membership, which the coverage map's `Owns` clauses +/// expand. Keeping the citation in a side map is what lets those three resolve +/// a section without joining the accepted set. +fn record_cited(read: &mut Read, resolution: &str, names: &[String]) { + let Some(section) = section_ref(resolution) else { + return; + }; + for name in names { + read.cited.insert(name.clone(), section.clone()); + } +} + +/// One accept row of section 7, split into the columns the parse needs. +struct Entry<'a> { + /// The table the row belongs to. + table: &'a CandidateTable, + /// The row's name cell, expanded into individual names. + names: &'a [String], + /// The row's disposition cell, trimmed. + disposition: &'a str, + /// The row's resolution cell. + resolution: &'a str, + /// One-indexed line the row was read from. + line: usize, +} + +/// Record one accept row's registered names. +/// +/// The entry's `names` holds the row's surveyed spellings, which is normally +/// what is registered; its `disposition` overrides that for the one row that +/// reads "Accept as `text_hash`". +fn record_accept(read: &mut Read, entry: &Entry<'_>) -> Result<()> { + let section = section_ref(entry.resolution).with_context(|| { + format!( + "accept row at {RFC_0006}:{} cites no section 8 subsection", + entry.line + ) + })?; + for name in registered_names(entry.disposition, entry.names) { + read.surveyed_names.insert(name.clone()); + read.sections_of.insert(name.clone(), section.clone()); + let helper = Row { + name: name.clone(), + namespace: entry.table.namespace, + }; + if read.accepted.insert(name.clone(), helper.clone()).is_none() { + read.new_rows.push(helper); + } + } + Ok(()) +} + +/// Apply section 7.8's three rename exceptions. +/// +/// `hash` is the odd one: its survey row is an accept row whose disposition cell +/// already names `text_hash`, so it is in; the other two are reject rows whose +/// *capability* is accepted under a new name. +pub(super) fn apply_renames(read: &mut Read) -> Result<()> { + for rename in &RENAMES { + ensure!( + read.surveyed_names.contains(rename.surveyed), + "rename exception {} has no section 7 row", + rename.surveyed + ); + let already = read.accepted.contains_key(rename.registered); + ensure!( + already == (rename.surveyed == "hash"), + "rename exception {} -> {} is {} in the accepted set; only `hash` should already be", + rename.surveyed, + rename.registered, + if already { "present" } else { "absent" } + ); + if !already { + let namespace = *read.namespaces.get(rename.surveyed).with_context(|| { + format!( + "rename exception {} has no namespace, so section 7's tables place it nowhere", + rename.surveyed + ) + })?; + let entry = Row { + name: rename.registered.to_owned(), + namespace, + }; + read.accepted + .insert(rename.registered.to_owned(), entry.clone()); + read.new_rows.push(entry); + } + read.sections_of + .insert(rename.registered.to_owned(), rename.section.to_owned()); + } + Ok(()) +} + +/// Apply the three optioned helpers. +/// +/// These are existing Netsuke helpers, so none contributes a new row: their +/// section 8 subsection is inherited from the section 7 row that cites them. +pub(super) fn apply_optioned(read: &mut Read) -> Result<Vec<String>> { + let mut optioned = Vec::new(); + for option in &OPTIONED { + ensure!( + read.surveyed_names.contains(option.evidence_row), + "the section 7 row citing optioned helper {} does not exist", + option.name + ); + let section = read + .cited + .get(option.evidence_row) + .with_context(|| { + format!( + "the section 7 row naming optioned helper {} cites no section 8 subsection", + option.name + ) + })? + .clone(); + read.sections_of.insert(option.name.to_owned(), section); + // A failing insert rather than an overwrite: an optioned helper is named + // by a *reject* row in section 7, so it never reaches `accepted` through + // `record_accept`, and its namespace cannot come from the document. If + // one is already present, section 7 also accepted it under the same + // spelling, and the two records disagree about its namespace. + let previous = read.accepted.insert( + option.name.to_owned(), + Row { + name: option.name.to_owned(), + namespace: option.namespace, + }, + ); + ensure!( + previous.is_none(), + "optioned helper {} is already recorded as accepted, so section 7 both accepts it \ + and rejects it in favour of an option", + option.name + ); + optioned.push(option.name.to_owned()); + } + Ok(optioned) +} + +/// Classify a disposition cell. +pub(super) fn classify(disposition: &str) -> Option<Disposition> { + let lower = disposition.to_ascii_lowercase(); + if lower == "accept" || lower.starts_with("accept as") { + Some(Disposition::Accept) + } else if lower == "defer" { + Some(Disposition::Defer) + } else if lower == "reject" || lower.starts_with("reject as") { + Some(Disposition::Reject) + } else { + None + } +} + +/// The registered Netsuke names an accept row contributes. +/// +/// Normally the row's own name. For the one row whose disposition cell reads +/// "Accept as `text_hash`", the cell names the spelling that is actually +/// registered, and the surveyed spelling is an existing helper left unchanged. +pub(super) fn registered_names(disposition: &str, names: &[String]) -> Vec<String> { + if disposition.to_ascii_lowercase().starts_with("accept as") { + let registered = backticked(disposition); + if !registered.is_empty() { + return registered; + } + } + names.to_vec() +} + +/// The first `§N.N` reference in `text`, normalized to `N.N`. +/// +/// A sentence-final citation reads `§8.9.`, and the period ends the sentence +/// rather than extending the number: the digits-and-dots scan cannot tell the +/// two apart, so trailing dots are trimmed before the result is checked for +/// emptiness. `§.` therefore yields `None`, as does a citation that is absent. +/// +/// Returns `None` when the cell cites no section, which for an accept row is an +/// error: every accepted helper must be specified somewhere in section 8. +pub(super) fn section_ref(text: &str) -> Option<String> { + let rest = text.split_once('§')?.1; + let end = rest + .find(|ch: char| !(ch.is_ascii_digit() || ch == '.')) + .unwrap_or(rest.len()); + let found = rest.get(..end)?.trim_end_matches('.'); + if found.is_empty() { + return None; + } + Some(found.to_owned()) +} + +#[cfg(test)] +#[path = "section7_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/section7_tests.rs b/tests/rfc_stdlib_coverage/section7_tests.rs new file mode 100644 index 000000000..16c3c0f0b --- /dev/null +++ b/tests/rfc_stdlib_coverage/section7_tests.rs @@ -0,0 +1,271 @@ +//! Exercise survey decisions, rename witnesses, and option evidence directly. +use super::*; +use anyhow::{bail, ensure}; +use rstest::rstest; + +/// Build one ordinary four-column survey table. +fn matrix(disposition: &str, resolution: &str) -> String { + format!( + "### 7.1. Core filters\n| Name | Alias | Decision | Resolution |\n|---|---|---|---|\n| `alpha` | a | {disposition} | {resolution} |\n" + ) +} + +/// Build the smallest survey with all rename and option witnesses. +fn evidence() -> String { + let mut text = matrix("Accept", "§8.1."); + text.push_str(concat!( + "| `hash` | h | Accept as `text_hash` | §8.9 |\n", + "| `quote` | q | Reject | replacement |\n", + "| `win_splitdrive` | w | Reject | replacement |\n", + "| `basename` | b | Reject | §8.6 |\n", + "| `dirname` | d | Reject | §8.6 |\n", + "| `fileglob` | f | Reject | §8.7 |\n" + )); + text +} + +#[rstest] +#[case::unknown( + "Maybe", + "§8.1", + "unknown disposition \"Maybe\" at docs/rfcs/0006-ansible-inspired-template-standard-library.md:4" +)] +#[case::absent_citation( + "Accept", + "none", + "accept row at docs/rfcs/0006-ansible-inspired-template-standard-library.md:4 cites no section 8 subsection" +)] +#[case::empty_citation("Accept", "§.", "cites no section 8 subsection")] +fn disposition_errors_report_source( + #[case] disposition: &str, + #[case] resolution: &str, + #[case] diagnostic: &str, +) { + let text = matrix(disposition, resolution); + let error = read_section_7(&Section::whole(&text)) + .err() + .expect("invalid disposition"); + assert!(format!("{error:#}").contains(diagnostic), "{error:#}"); +} + +#[rstest] +#[case::missing_disposition("| `alpha` | a |", "disposition")] +#[case::missing_resolution("| `alpha` | a | Accept |", "resolution")] +fn narrow_rows_report_missing_column(#[case] replacement: &str, #[case] column: &str) { + let text = matrix("Accept", "§8.1").replace("| `alpha` | a | Accept | §8.1 |", replacement); + let error = read_section_7(&Section::whole(&text)) + .err() + .expect("narrow row"); + assert!(format!("{error:#}").contains(&format!("row at line 4 has no {column} column"))); +} + +#[test] +fn duplicate_survey_names_must_agree_on_namespace() { + let mut text = matrix("Accept", "§8.1"); + text.push_str("### 7.4. Core tests\n| Name | Alias | Decision | Resolution |\n|---|---|---|---|\n| `alpha` | a | Reject | none |\n"); + let error = read_section_7(&Section::whole(&text)) + .err() + .expect("conflicting namespaces"); + assert!( + error + .to_string() + .contains("alpha is listed as both filter and test") + ); +} + +#[test] +fn repeated_accept_rows_do_not_duplicate_new_registrations() { + let text = matrix("Accept", "§8.1") + "| `alpha` | a | Accept | §8.1 |\n"; + let read = read_section_7(&Section::whole(&text)).expect("matching namespace"); + assert_eq!(read.accept_rows, 2); + assert_eq!(read.new_rows.len(), 1); +} + +#[rstest] +#[case::no_row("hash", "rename exception hash has no section 7 row", "row")] +#[case::missing_acceptance("text_hash", "only `hash` should already be", "accepted")] +#[case::unexpected_acceptance("shell_quote", "only `hash` should already be", "insert")] +#[case::missing_namespace("quote", "rename exception quote has no namespace", "namespace")] +fn rename_rejections_identify_missing_witness( + #[case] name: &str, + #[case] diagnostic: &str, + #[case] mutation: &str, +) -> Result<()> { + let text = evidence(); + let mut read = read_section_7(&Section::whole(&text))?; + match mutation { + "row" => { + read.surveyed_names.remove(name); + } + "accepted" => { + read.accepted.remove(name); + } + "insert" => { + read.accepted.insert( + name.into(), + Row { + name: name.into(), + namespace: Namespace::Filter, + }, + ); + } + "namespace" => { + read.namespaces.remove(name); + } + _ => bail!("unknown fixture mutation {mutation}"), + } + let error = apply_renames(&mut read) + .err() + .context("invalid rename witness must fail")?; + ensure!( + error.to_string().contains(diagnostic), + "expected {diagnostic:?}, got {error}" + ); + Ok(()) +} + +#[rstest] +#[case::missing_row("row", "row citing optioned helper basename does not exist")] +#[case::missing_citation( + "citation", + "row naming optioned helper basename cites no section 8 subsection" +)] +#[case::already_accepted("accepted", "optioned helper basename is already recorded as accepted")] +fn option_rejections_identify_missing_witness( + #[case] mutation: &str, + #[case] diagnostic: &str, +) -> Result<()> { + let text = evidence(); + let mut read = read_section_7(&Section::whole(&text))?; + match mutation { + "row" => { + read.surveyed_names.remove("basename"); + } + "citation" => { + read.cited.remove("basename"); + } + "accepted" => { + read.accepted.insert( + "basename".into(), + Row { + name: "basename".into(), + namespace: Namespace::Filter, + }, + ); + } + _ => bail!("unknown fixture mutation {mutation}"), + } + let error = apply_optioned(&mut read) + .err() + .context("invalid option witness must fail")?; + ensure!( + error.to_string().contains(diagnostic), + "expected {diagnostic:?}, got {error}" + ); + Ok(()) +} + +#[test] +fn valid_evidence_preserves_rename_and_option_namespaces() -> Result<()> { + let text = evidence(); + let mut read = read_section_7(&Section::whole(&text))?; + apply_renames(&mut read)?; + let optioned = apply_optioned(&mut read)?; + ensure!( + optioned == ["basename", "dirname", "glob"], + "unexpected optioned helpers {optioned:?}" + ); + let glob = read.accepted.get("glob").context("glob registration")?; + ensure!( + glob.namespace == Namespace::Function, + "glob must be a function" + ); + ensure!( + read.sections_of + .get("splitdrive") + .context("splitdrive section")? + == "8.6", + "splitdrive must own section 8.6" + ); + Ok(()) +} + +#[rstest] +#[case("ACCEPT", Some(Disposition::Accept))] +#[case("Accept as `new`", Some(Disposition::Accept))] +#[case("Defer", Some(Disposition::Defer))] +#[case("Reject as redundant", Some(Disposition::Reject))] +#[case("Reject", Some(Disposition::Reject))] +#[case("Accepted", None)] +fn classify_recognizes_only_disposition_vocabulary( + #[case] text: &str, + #[case] expected: Option<Disposition>, +) { + assert_eq!(classify(text), expected); +} + +#[rstest] +#[case::no_marker("8.1", None)] +#[case::empty("§.", None)] +#[case::sentence("see §8.1.", Some("8.1"))] +#[case::terminated("§8.2 text", Some("8.2"))] +fn citations_stop_at_non_numeric_text(#[case] text: &str, #[case] expected: Option<&str>) { + assert_eq!(section_ref(text).as_deref(), expected); +} + +#[test] +fn registered_names_fall_back_when_accept_as_has_no_backticks() { + let names = vec!["original".to_owned()]; + assert_eq!(registered_names("Accept as replacement", &names), names); + assert_eq!(registered_names("Accept as `new`", &names), ["new"]); +} + +#[rstest] +#[case::core(0)] +#[case::collection_filters(1)] +#[case::url(2)] +#[case::core_tests(3)] +#[case::filesystem(4)] +#[case::collection_tests(5)] +#[case::functions(6)] +fn inventory_tables_select_the_declared_column_and_namespace(#[case] index: usize) -> Result<()> { + let table = CANDIDATE_TABLES + .get(index) + .context("candidate table index")?; + let leading = if table.disposition_column == 1 { + "" + } else { + " alias |" + }; + let text = format!( + "### {}\n| Header |\n|---|\n| `helper` |{leading} Accept | §8.1 |\n", + table.heading + ); + let read = read_section_7(&Section::whole(&text))?; + let helper = read.accepted.get("helper").context("helper registration")?; + ensure!( + helper.namespace == table.namespace, + "wrong namespace for {}", + table.heading + ); + ensure!( + read.sections_of.get("helper").context("helper section")? == "8.1", + "wrong helper section" + ); + Ok(()) +} + +#[test] +fn irrelevant_tables_are_not_survey_candidates() -> Result<()> { + let text = "### unrelated\n| h |\n|---|\n| malformed |"; + let read = read_section_7(&Section::whole(text))?; + ensure!( + read.surveyed_names.is_empty(), + "unrelated table must contribute no names" + ); + ensure!( + read.reject_rows == 0, + "unrelated table must contribute no reject rows" + ); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/section8.rs b/tests/rfc_stdlib_coverage/section8.rs new file mode 100644 index 000000000..a66d79203 --- /dev/null +++ b/tests/rfc_stdlib_coverage/section8.rs @@ -0,0 +1,105 @@ +//! Checking derived section references against RFC 0006 section 8. +//! +//! Every accept row cites the section 8 subsection that specifies the helper it +//! accepts. This module is what makes that citation load-bearing: a row citing +//! `§8.9` for a helper section 8.9 never mentions is a row whose contract does +//! not exist, and the coverage map would hand that helper to a child RFC with +//! nothing to implement against. +//! +//! Heading anchoring is used here, and only here. Section 8's subsections *are* +//! headings, so looking one up by number is exact. The survey's accepted set is +//! never derived this way; see the parent module for why. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result, ensure}; + +use super::{Fences, Row, Section, heading_depth}; + +/// Assert every accepted helper is named in the section 8 subsection it cites. +pub(super) fn check_section_8( + document: &Section<'_>, + accepted: &BTreeMap<String, Row>, + sections_of: &BTreeMap<String, String>, +) -> Result<()> { + let section_8 = document + .subsection("## 8. Accepted capabilities") + .context("RFC 0006 has no section 8")?; + let mut unsectioned = Vec::new(); + let mut unmentioned = Vec::new(); + for name in accepted.keys() { + let Some(section) = sections_of.get(name) else { + unsectioned.push(name.clone()); + continue; + }; + let Some(lines) = subsection_lines(§ion_8, section) else { + unsectioned.push(name.clone()); + continue; + }; + if !mentions(&lines, name) { + unmentioned.push(format!("{name} (cited section {section})")); + } + } + ensure!( + unsectioned.is_empty(), + "accepted helpers {unsectioned:?} name no RFC 0006 section 8 subsection, so they have no \ + contract to implement" + ); + ensure!( + unmentioned.is_empty(), + "accepted helpers {unmentioned:?} are never named as whole tokens in the section 8 \ + subsection that specifies them" + ); + Ok(()) +} + +/// The lines of the section 8 subsection headed `### N.N. …`. +/// +/// The end scan is fence-aware for the same reason the section-6 clause scan is: +/// a `#` line inside a fenced example would otherwise read as a depth-1 heading +/// and truncate the subsection, dropping every helper specified below it. +/// +/// The start scan reads the heading as a prefix, because the subsection title +/// follows the number and is not known in advance. It cannot use +/// [`Section::unfenced_heading`], which compares whole lines; the fence scan is +/// therefore folded in here, and the two scans stay consistent because both skip +/// the same lines. +fn subsection_lines<'a>(section_8: &Section<'a>, number: &str) -> Option<Vec<&'a str>> { + let prefix = format!("### {number}."); + let start = { + let mut fence_state = Fences::default(); + section_8 + .lines + .iter() + .enumerate() + .find(|(_, line)| !fence_state.mark(line) && line.starts_with(&prefix)) + .map(|(offset, _)| offset)? + }; + let depth = heading_depth(section_8.lines.get(start)?)?; + let rest = section_8.lines.get(start + 1..)?; + let end = rest + .iter() + .scan(Fences::default(), |fences, line| { + let fenced = fences.mark(line); + Some((fenced, line)) + }) + .position(|(fenced, line)| !fenced && heading_depth(line).is_some_and(|d| d <= depth)) + .unwrap_or(rest.len()); + rest.get(..end).map(<[&str]>::to_vec) +} + +/// Whether `name` appears in `lines` as a whole identifier token. +/// +/// Splitting on identifier characters is what keeps `difference` from matching +/// inside `symmetric_difference`, `abs` inside `is_abs`, `quote` inside +/// `shell_quote`, and `hash` inside `text_hash`. +fn mentions(lines: &[&str], name: &str) -> bool { + lines.iter().any(|line| { + line.split(|ch: char| !(ch.is_alphanumeric() || ch == '_')) + .any(|token| token == name) + }) +} + +#[cfg(test)] +#[path = "section8_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/section8_tests.rs b/tests/rfc_stdlib_coverage/section8_tests.rs new file mode 100644 index 000000000..fd4196534 --- /dev/null +++ b/tests/rfc_stdlib_coverage/section8_tests.rs @@ -0,0 +1,62 @@ +//! Check contract citations and whole-token mentions with minimal documents. +use super::*; +use crate::rfc_stdlib_coverage::Namespace; +use rstest::rstest; + +#[rstest] +#[case::no_section("# Other", Some("8.1"), "RFC 0006 has no section 8")] +#[case::no_citation( + "## 8. Accepted capabilities\n### 8.1. Helpers\nabs", + None, + "name no RFC 0006 section 8 subsection" +)] +#[case::wrong_citation( + "## 8. Accepted capabilities\n### 8.1. Helpers\nabs", + Some("8.2"), + "name no RFC 0006 section 8 subsection" +)] +#[case::substring( + "## 8. Accepted capabilities\n### 8.1. Helpers\nis_abs", + Some("8.1"), + "never named as whole tokens" +)] +fn section_contract_failures_are_specific( + #[case] text: &str, + #[case] citation: Option<&str>, + #[case] diagnostic: &str, +) { + let accepted = BTreeMap::from([( + "abs".into(), + Row { + name: "abs".into(), + namespace: Namespace::Test, + }, + )]); + let sections = citation + .map(|number| BTreeMap::from([("abs".into(), number.into())])) + .unwrap_or_default(); + let error = + check_section_8(&Section::whole(text), &accepted, §ions).expect_err("invalid contract"); + assert!(error.to_string().contains(diagnostic), "{error}"); +} + +#[rstest] +#[case::abs("abs", "is_abs")] +#[case::hash("hash", "text_hash")] +#[case::quote("quote", "shell_quote")] +#[case::difference("difference", "symmetric_difference")] +fn mentions_require_whole_identifier_tokens(#[case] name: &str, #[case] longer: &str) { + assert!(!mentions(&[longer], name)); + assert!(mentions(&[&format!("`{name}` (value)")], name)); +} + +#[test] +fn subsection_scan_ignores_fenced_starts_and_peer_boundaries() { + let text = concat!( + "~~~\n### 8.1. Fake\n~~~\n### 8.10. Other\nwrong\n", + "### 8.1. Real\n```\n# example\n```\nreal\n### 8.2. Peer\nother" + ); + let lines = subsection_lines(&Section::whole(text), "8.1").expect("real section"); + assert_eq!(lines.last(), Some(&"real")); + assert!(subsection_lines(&Section::whole(text), "8.3").is_none()); +} diff --git a/tests/rfc_stdlib_coverage/survey.rs b/tests/rfc_stdlib_coverage/survey.rs new file mode 100644 index 000000000..4bfabb3fb --- /dev/null +++ b/tests/rfc_stdlib_coverage/survey.rs @@ -0,0 +1,159 @@ +//! Derivation of the accepted and deny sets from RFC 0006 section 7. +//! +//! RFC 0006 is a survey RFC: it records one disposition per surveyed candidate, +//! and the accepted set it leaves behind is what the child RFCs partition. This +//! module is the entry point that turns the document into that set. It holds the +//! derived result and its orchestration; the reading lives in [`section7`], +//! [`section8`], and [`totals`], and the guards in [`assertions`]. +//! +//! The accepted set is 60 helpers: 57 new — 55 accept rows plus the two rename +//! exceptions that arrive from reject rows — and the 3 existing helpers gaining +//! a behaviour-preserving option. The deny set is the complement of the accepted +//! set within the rejected and deferred names: 71 names no child RFC may +//! register. +//! +//! Three quantities in that derivation are transcriptions rather than reads, all +//! of them in [`inventory`] and [`totals`]: the rename exceptions section 7.8 +//! states in a sentence, the optioned helpers table 11 counts without naming, +//! and section 6.1's purity aggregate, which is spelled in number words. Each is +//! witnessed against the document, so a prose edit fails loudly instead of +//! drifting. +//! +//! A fourth quantity is deliberately **not** derived: table 11's split of the 50 +//! reject rows into 22 already-provides, 10 redundant-alias, and 18 +//! on-principle. RFC 0006 states no rule assigning a row to one of those +//! classes, and no rule fitted to the tables recovers the split. A three-way +//! rejection type would therefore be undecidable at runtime, which is why +//! `Disposition` has one `Reject` variant. See decision `D10` in the `ExecPlan`. + +use std::collections::{BTreeMap, BTreeSet}; + +use anyhow::{Context, Result}; + +use super::{ + Namespace, RFC_0006, Repo, Row, Section, + assertions::{check_against_document, check_denied}, + section7::{apply_optioned, apply_renames, read_section_7}, + section8::check_section_8, + totals::{PROPOSED_HELPERS, purity_aggregate, table_11}, +}; + +/// Everything the coverage checks need from RFC 0006. +pub(super) struct Survey { + /// Accepted helpers by registered name, with the row that established them. + pub(super) accepted: BTreeMap<String, Row>, + /// The 57 new helpers, in document order. + pub(super) new_rows: Vec<Row>, + /// The 3 optioned helper names, in the order `inventory::OPTIONED` lists + /// them. + pub(super) optioned: Vec<String>, + /// Names no child RFC may register. + pub(super) denied: BTreeSet<String>, + /// Accepted helper names per section 8 subsection. + pub(super) sections: BTreeMap<String, BTreeSet<String>>, + /// Accept rows parsed from section 7. + pub(super) accept_rows: usize, + /// Defer rows parsed from section 7. + pub(super) defer_rows: usize, + /// Reject rows parsed from section 7. + pub(super) reject_rows: usize, + /// Table 11's count of surveyed entries accepted. + pub(super) stated_accept: usize, + /// Table 11's count of surveyed entries deferred. + pub(super) stated_defer: usize, + /// Table 11's three reject-class counts, in table order. + pub(super) reject_classes: [usize; 3], + /// Table 11's new-filter count. + pub(super) new_filters: usize, + /// Table 11's new-test count. + pub(super) new_tests: usize, + /// Table 11's count of existing helpers gaining an option. + pub(super) optioned_total: usize, + /// Section 6.1's purity aggregate as (pure, filesystem, environment). + pub(super) purity: (usize, usize, usize), + /// Section 6.1's proposed-helper count. + pub(super) proposed: usize, +} + +impl Survey { + /// Accepted helper names by namespace, over the new helpers only. + pub(super) fn new_by_namespace(&self, namespace: Namespace) -> usize { + self.new_rows + .iter() + .filter(|row| row.namespace == namespace) + .count() + } +} + +/// Derive the survey and assert it against the document it came from. +/// +/// The assertions are part of the derivation rather than a separate check +/// because every one of them is a cross-check between two places RFC 0006 states +/// the same thing: the row counts against table 11, the class counts against the +/// reject-row count, the new-filter and new-test counts against the tables, and +/// the deny set against the non-vacuity witnesses. A parse that silently returns +/// nothing must fail here. +pub(super) fn derive_and_check(repo: &Repo) -> Result<Survey> { + let survey = derive(repo)?; + check_against_document(&survey)?; + check_denied(&survey)?; + Ok(survey) +} + +/// Read RFC 0006 and derive the sets and totals the coverage checks assert. +pub(super) fn derive(repo: &Repo) -> Result<Survey> { + let text = repo.read(RFC_0006)?; + let document = Section::whole(&text); + let section_7 = document + .subsection("## 7. Candidate matrix") + .context("RFC 0006 has no section 7")?; + + let mut read = read_section_7(§ion_7)?; + apply_renames(&mut read)?; + let optioned = apply_optioned(&mut read)?; + check_section_8(&document, &read.accepted, &read.sections_of)?; + + // Deny set: the complement of the accepted set within the rejected and + // deferred names. See decision `D10` in the ExecPlan. + let mut deny_candidates = read.rejected.clone(); + deny_candidates.extend(read.deferred.iter().cloned()); + let accepted_names: BTreeSet<String> = read.accepted.keys().cloned().collect(); + let denied: BTreeSet<String> = deny_candidates + .difference(&accepted_names) + .cloned() + .collect(); + + let totals = table_11(§ion_7)?; + let purity = purity_aggregate(&document)?; + + let mut sections: BTreeMap<String, BTreeSet<String>> = BTreeMap::new(); + for (name, section) in &read.sections_of { + sections + .entry(section.clone()) + .or_default() + .insert(name.clone()); + } + + Ok(Survey { + accepted: read.accepted, + new_rows: read.new_rows, + optioned, + denied, + sections, + accept_rows: read.accept_rows, + defer_rows: read.defer_rows, + reject_rows: read.reject_rows, + stated_accept: totals.accept_rows, + stated_defer: totals.defer_rows, + reject_classes: totals.reject_classes, + new_filters: totals.new_filters, + new_tests: totals.new_tests, + optioned_total: totals.optioned, + purity, + proposed: PROPOSED_HELPERS, + }) +} + +#[cfg(test)] +#[path = "survey_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/survey_tests.rs b/tests/rfc_stdlib_coverage/survey_tests.rs new file mode 100644 index 000000000..b6d282753 --- /dev/null +++ b/tests/rfc_stdlib_coverage/survey_tests.rs @@ -0,0 +1,126 @@ +//! Exercise derivation through isolated repository fixtures. +use super::*; +use anyhow::ensure; +use camino::Utf8Path; +use rstest::rstest; + +/// Build a compact survey containing every required prose witness. +fn document() -> String { + let mut text = String::from(concat!( + "## 6. Cross-cutting contract\n### 6.1. Purity classes\n", + "Of the fifty-seven helpers proposed here, fifty-two are pure, four are filesystem-observing, and one is environment-observing.\n", + "## 7. Candidate matrix\n### 7.1. Core filters\n", + "| Name | Alias | Decision | Resolution |\n|---|---|---|---|\n", + "| `hash` | h | Accept as `text_hash` | §8.9 |\n", + "| `quote` | q | Reject | none |\n", + "| `win_splitdrive` | w | Reject | none |\n", + "| `basename` | b | Reject | §8.6 |\n", + "| `dirname` | d | Reject | §8.6 |\n", + "| `fileglob` | f | Reject | §8.7 |\n", + "| `denied` | d | Defer | none |\n", + "### 7.8. Totals\n| Measure | Count |\n|---|---|\n" + )); + let rows = [ + "Surveyed entries accepted", + "Surveyed entries deferred", + "Surveyed entries rejected because", + "Surveyed entries rejected as a redundant alias", + "Surveyed entries rejected on principle", + "New Netsuke filters introduced", + "New Netsuke tests introduced", + "Existing Netsuke helpers gaining", + ] + .into_iter() + .map(|label| format!("| {label} | 1 |\n")) + .collect::<Vec<_>>() + .concat(); + text.push_str(&rows); + text.push_str(concat!( + "## 8. Accepted capabilities\n### 8.6. Paths\n", + "basename dirname splitdrive\n### 8.7. Filesystem\nglob\n", + "### 8.9. Text\ntext_hash shell_quote\n" + )); + text +} + +#[test] +fn missing_survey_file_reports_repository_relative_path() -> Result<()> { + let temp = tempfile::tempdir()?; + let root = Utf8Path::from_path(temp.path()).context("UTF-8 temporary path")?; + let repo = Repo::fixture(root)?; + let error = derive(&repo).err().context("missing survey should fail")?; + ensure!( + format!("{error:#}").contains(&format!("read {RFC_0006}")), + "missing file error lost source context: {error:#}" + ); + Ok(()) +} + +#[rstest] +#[case::missing_matrix("## 7. Candidate matrix", "## 7. Removed", "RFC 0006 has no section 7")] +#[case::missing_rename( + "`quote`", + "`different`", + "rename exception quote has no section 7 row" +)] +#[case::missing_contract("text_hash shell_quote", "different", "never named as whole tokens")] +fn derivation_propagates_specific_parser_failures( + #[case] old: &str, + #[case] new: &str, + #[case] diagnostic: &str, +) -> Result<()> { + let temp = tempfile::tempdir()?; + let root = Utf8Path::from_path(temp.path()).context("UTF-8 temporary path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/rfcs")?; + repo.dir.write(RFC_0006, document().replace(old, new))?; + let error = derive(&repo).err().context("invalid fixture should fail")?; + ensure!( + format!("{error:#}").contains(diagnostic), + "expected {diagnostic:?}, got {error:#}" + ); + Ok(()) +} + +#[test] +fn derivation_complements_denied_names_and_groups_owned_sections() -> Result<()> { + let temp = tempfile::tempdir()?; + let root = Utf8Path::from_path(temp.path()).context("UTF-8 temporary path")?; + let repo = Repo::fixture(root)?; + repo.dir.create_dir_all("docs/rfcs")?; + repo.dir.write(RFC_0006, document())?; + let survey = derive(&repo)?; + ensure!( + survey.denied.contains("denied"), + "deferred candidate must be denied" + ); + ensure!( + survey.denied.contains("quote"), + "rejected spelling must be denied" + ); + ensure!( + !survey.denied.contains("basename"), + "optioned helper cannot be denied" + ); + ensure!( + survey.sections.get("8.6").context("path section")?.len() == 3, + "path section must have three helpers" + ); + ensure!( + survey.new_by_namespace(Namespace::Filter) == 3, + "expected three new filters" + ); + ensure!( + survey.new_by_namespace(Namespace::Function) == 0, + "optioned glob must not count as new" + ); + // The fixture is parse-valid, but deliberately does not impersonate full survey counts. + let error = derive_and_check(&repo) + .err() + .context("inconsistent totals must fail")?; + ensure!( + error.to_string().contains("derived 5 reject rows"), + "{error:#}" + ); + Ok(()) +} diff --git a/tests/rfc_stdlib_coverage/totals.rs b/tests/rfc_stdlib_coverage/totals.rs new file mode 100644 index 000000000..6bcb24a7b --- /dev/null +++ b/tests/rfc_stdlib_coverage/totals.rs @@ -0,0 +1,128 @@ +//! The counts RFC 0006 states about itself. +//! +//! Two of them are consumed by the coverage checks. Table 11 tallies the survey +//! — how many rows fell into each reject class, how many new filters and tests +//! the RFC introduces, how many existing helpers gain an option — and section +//! 6.1's purity sentence aggregates the accepted set into pure, +//! filesystem-observing, and environment-observing. +//! +//! Section 6.1's numbers are number words rather than digits, so they cannot be +//! parsed without a number-word table. They are transcribed and then guarded by +//! asserting the sentence they came from is still present verbatim. + +use std::collections::BTreeMap; + +use anyhow::{Context, Result, ensure}; + +use super::Section; + +/// Section 6.1's purity aggregate, transcribed from words rather than digits. +/// +/// The sentence reads "Of the fifty-seven helpers proposed here, fifty-two are +/// pure, four are filesystem-observing, and one is environment-observing." The +/// numbers are words, so they cannot be parsed without a number-word table; the +/// transcription is guarded by [`purity_aggregate`] asserting the sentence is +/// still present verbatim, whitespace collapsed. +const PURITY: (usize, usize, usize) = (52, 4, 1); + +/// The helper count section 6.1's aggregate ranges over. +pub(super) const PROPOSED_HELPERS: usize = 57; + +/// The verbatim purity sentence [`PURITY`] transcribes, whitespace collapsed. +const PURITY_SENTENCE: &str = "Of the fifty-seven helpers proposed here, fifty-two are pure, \ +four are filesystem-observing, and one is environment-observing."; + +/// The tally rows of RFC 0006 table 11 that this test consumes. +pub(super) struct Totals { + /// Surveyed entries accepted, per table 11's first row. + pub(super) accept_rows: usize, + /// Surveyed entries deferred, per table 11's second row. + pub(super) defer_rows: usize, + /// Reject rows classed "already provides", "redundant alias", "principle". + pub(super) reject_classes: [usize; 3], + /// New Netsuke filters table 11 counts. + pub(super) new_filters: usize, + /// New Netsuke tests table 11 counts. + pub(super) new_tests: usize, + /// Existing helpers gaining an option, per table 11. + pub(super) optioned: usize, +} + +/// Read table 11 by row label. +/// +/// Every row is read by prefix rather than by an index into a fixed list, and +/// the prefix must match exactly one row: a table 11 that grew a second row +/// starting `Surveyed entries accepted` would be ambiguous, and this is the +/// point at which that ambiguity is refused rather than silently resolved to +/// whichever row came first. +pub(super) fn table_11(section_7: &Section<'_>) -> Result<Totals> { + let mut totals: BTreeMap<String, usize> = BTreeMap::new(); + for (heading, rows) in section_7.tables() { + if !heading.starts_with("7.8.") { + continue; + } + for row in &rows { + let label = row.cell(0, "measure")?.to_owned(); + let count: usize = row + .cell(1, "count")? + .replace(',', "") + .parse() + .with_context(|| format!("table 11's count for {label:?} is not a number"))?; + ensure!( + totals.insert(label.clone(), count).is_none(), + "table 11 states {label:?} twice" + ); + } + } + let find = |prefix: &str| -> Result<usize> { + let hits: Vec<(&String, &usize)> = totals + .iter() + .filter(|(label, _)| label.starts_with(prefix)) + .collect(); + ensure!( + hits.len() == 1, + "expected exactly one table 11 row starting {prefix:?}, found {:?}", + hits.iter() + .map(|(label, _)| label.as_str()) + .collect::<Vec<_>>() + ); + hits.first().map(|(_, count)| **count).with_context(|| { + format!("table 11's row starting {prefix:?} vanished between the check and the read") + }) + }; + Ok(Totals { + accept_rows: find("Surveyed entries accepted")?, + defer_rows: find("Surveyed entries deferred")?, + reject_classes: [ + find("Surveyed entries rejected because")?, + find("Surveyed entries rejected as a redundant alias")?, + find("Surveyed entries rejected on principle")?, + ], + new_filters: find("New Netsuke filters introduced")?, + new_tests: find("New Netsuke tests introduced")?, + optioned: find("Existing Netsuke helpers gaining")?, + }) +} + +/// Section 6.1's purity aggregate, guarded by the sentence it is read from. +pub(super) fn purity_aggregate(document: &Section<'_>) -> Result<(usize, usize, usize)> { + let section = document + .subsection("### 6.1. Purity classes") + .context("RFC 0006 has no section 6.1")?; + let collapsed = section + .lines + .join(" ") + .split_whitespace() + .collect::<Vec<_>>() + .join(" "); + ensure!( + collapsed.contains(PURITY_SENTENCE), + "RFC 0006 section 6.1 no longer states the purity aggregate this test transcribes: \ + {PURITY_SENTENCE}" + ); + Ok(PURITY) +} + +#[cfg(test)] +#[path = "totals_tests.rs"] +mod tests; diff --git a/tests/rfc_stdlib_coverage/totals_tests.rs b/tests/rfc_stdlib_coverage/totals_tests.rs new file mode 100644 index 000000000..fd4a4c493 --- /dev/null +++ b/tests/rfc_stdlib_coverage/totals_tests.rs @@ -0,0 +1,84 @@ +//! Pin tally rejection diagnostics and guarded purity evidence. +use super::*; +use rstest::rstest; + +/// Build the smallest complete tally table. +fn tally() -> String { + let labels = [ + "Surveyed entries accepted", + "Surveyed entries deferred", + "Surveyed entries rejected because", + "Surveyed entries rejected as a redundant alias", + "Surveyed entries rejected on principle", + "New Netsuke filters introduced", + "New Netsuke tests introduced", + "Existing Netsuke helpers gaining", + ]; + let rows = labels + .into_iter() + .map(|label| format!("| {label} | 1 |\n")) + .collect::<Vec<_>>() + .concat(); + format!("### 7.8. Totals\n| Measure | Count |\n|---|---|\n{rows}") +} + +#[test] +fn complete_tallies_accept_comma_separated_counts() { + let text = tally().replace("accepted | 1", "accepted | 1,234"); + let totals = table_11(&Section::whole(&text)).expect("valid tally"); + assert_eq!(totals.accept_rows, 1234); + assert_eq!(totals.reject_classes, [1, 1, 1]); +} + +#[rstest] +#[case::missing( + "| Surveyed entries accepted | 1 |\n", + "", + "expected exactly one table 11 row" +)] +#[case::duplicate( + "| Surveyed entries accepted | 1 |", + "| Surveyed entries accepted | 1 |\n| Surveyed entries accepted | 2 |", + "twice" +)] +#[case::ambiguous( + "| Surveyed entries accepted | 1 |", + "| Surveyed entries accepted | 1 |\n| Surveyed entries accepted extra | 2 |", + "expected exactly one table 11 row" +)] +#[case::not_numeric("accepted | 1", "accepted | many", "is not a number")] +#[case::missing_count( + "| Surveyed entries accepted | 1 |", + "| Surveyed entries accepted |", + "row at line 4 has no count column" +)] +fn tally_rejections_are_specific(#[case] old: &str, #[case] new: &str, #[case] diagnostic: &str) { + let text = tally().replace(old, new); + let error = table_11(&Section::whole(&text)) + .err() + .expect("invalid tally"); + assert!(format!("{error:#}").contains(diagnostic), "{error:#}"); +} + +#[rstest] +#[case::missing_heading("## Other", "RFC 0006 has no section 6.1")] +#[case::changed_evidence( + "### 6.1. Purity classes\nDifferent aggregate", + "no longer states the purity aggregate" +)] +fn purity_requires_unchanged_evidence(#[case] text: &str, #[case] diagnostic: &str) { + let error = purity_aggregate(&Section::whole(text)).expect_err("missing evidence"); + assert!(error.to_string().contains(diagnostic)); +} + +#[test] +fn purity_evidence_allows_line_wrapping() { + let text = format!( + "### 6.1. Purity classes\n{}", + PURITY_SENTENCE.replace("pure, ", "pure,\n") + ); + assert_eq!( + purity_aggregate(&Section::whole(&text)).expect("same evidence"), + (52, 4, 1) + ); +} diff --git a/tests/rfc_stdlib_coverage/validation_fixtures.rs b/tests/rfc_stdlib_coverage/validation_fixtures.rs new file mode 100644 index 000000000..0b9ae5640 --- /dev/null +++ b/tests/rfc_stdlib_coverage/validation_fixtures.rs @@ -0,0 +1,104 @@ +//! Construct small synthetic validator inputs shared only by private tests. +//! +//! These constructors belong to the coverage suite's tests. They model parsed +//! data, never infer it from production parsers, and are not runtime APIs. + +use super::{Namespace, Registration, Row, World, map, registries, roadmap, survey}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Construct one filter introduced by the survey. +pub(super) fn helper(name: &str) -> Row { + Row { + name: name.into(), + namespace: Namespace::Filter, + } +} + +/// Construct a self-consistent survey containing one new pure filter. +pub(super) fn survey() -> survey::Survey { + let accepted = BTreeMap::from([("helper".into(), helper("helper"))]); + let mut denied: BTreeSet<_> = [ + "is_file", + "is_dir", + "is_link", + "quote", + "fileglob", + "lookup", + "win_dirname", + "expanduser", + ] + .into_iter() + .map(str::to_owned) + .collect(); + denied.extend((0..63).map(|index| format!("denied{index}"))); + survey::Survey { + accepted, + new_rows: vec![helper("helper")], + optioned: vec![], + denied, + sections: BTreeMap::new(), + accept_rows: 1, + defer_rows: 0, + reject_rows: 0, + stated_accept: 1, + stated_defer: 0, + reject_classes: [0; 3], + new_filters: 1, + new_tests: 0, + optioned_total: 0, + purity: (1, 0, 0), + proposed: 1, + } +} + +/// Construct one new pure registry row. +pub(super) fn registry() -> registries::Registry { + registries::Registry { + file: "docs/rfcs/0013-child.md".into(), + number: "0013".into(), + rows: vec![registries::RegistryRow { + helper: helper("helper"), + registration: Registration::New, + purity: registries::Purity::Pure, + }], + } +} + +/// Construct eight groups, one written, with one registry and one scheduled helper. +pub(super) fn world() -> World { + let rows = (0..8) + .map(|index| map::MapRow { + number: format!("{:04}", 13 + index), + written: None, + owns: if index == 0 { + vec!["helper".into()] + } else { + vec![] + }, + optioned: vec![], + step: format!("6.{}", index + 2), + is_written: index == 0, + }) + .collect(); + World { + survey: survey(), + map: map::Map { rows }, + registries: vec![registry()], + roadmap: roadmap::Steps { + names: (2..10) + .map(|step| { + ( + format!("6.{step}"), + if step == 2 { + BTreeSet::from(["helper".into()]) + } else { + BTreeSet::new() + }, + ) + }) + .collect(), + order: vec!["6.2".into()], + }, + child_paths: BTreeMap::new(), + } +} diff --git a/tests/rfc_stdlib_coverage_tests.rs b/tests/rfc_stdlib_coverage_tests.rs new file mode 100644 index 000000000..dfc7ed9dd --- /dev/null +++ b/tests/rfc_stdlib_coverage_tests.rs @@ -0,0 +1,74 @@ +//! Coverage contract for RFC 0006's split into focused child RFCs. +//! +//! RFC 0006 surveys 111 ansible-core candidates, accepts 60 helpers, defers 6, +//! and rejects 50. Roadmap 6.1.1 splits that accepted set into eight child RFCs +//! and requires that every accepted capability is covered exactly once and every +//! deferred or rejected candidate is covered not at all. Fifty-seven names are +//! not reliably partitioned by reading, so this binary derives the sets from +//! RFC 0006's own tables and asserts the partition. +//! +//! The helper sets are derived from tracked Markdown and checked against +//! tracked Markdown, so no count of names is carried here that could drift from +//! the documents. What the derivation rests on are anchors it cannot parse out +//! of prose — the seven candidate-table headings, the three renames, the three +//! optioned helpers, and section 6.1's proposed-helper count — and each of those +//! is transcribed and then witnessed against the document it came from rather +//! than trusted as written. The derivation lives in the +//! [`rfc_stdlib_coverage`] module tree, one module per source document, so a +//! failure names the obligation it discharges rather than a line in a helper. +//! +//! Failures name the file and line of the offending row, because the fix is +//! almost always an edit to a document rather than to this test. +//! +//! Each check is called through its module path rather than imported, so the +//! test function and the check it runs can share a name. + +mod rfc_stdlib_coverage; + +use anyhow::Result; + +use rfc_stdlib_coverage::Repo; + +/// Run `check` against a freshly opened view of the repository. +/// +/// Each test opens its own handle so the tests can run in any order and in +/// parallel without sharing state. +fn run(check: impl FnOnce(&Repo) -> Result<()>) -> Result<()> { + let repo = Repo::open()?; + check(&repo) +} + +#[test] +fn every_accepted_helper_has_exactly_one_owner() -> Result<()> { + run(rfc_stdlib_coverage::every_accepted_helper_has_exactly_one_owner) +} + +#[test] +fn no_forbidden_helper_is_registered() -> Result<()> { + run(rfc_stdlib_coverage::no_forbidden_helper_is_registered) +} + +#[test] +fn totals_and_purity_aggregate_agree() -> Result<()> { + run(rfc_stdlib_coverage::totals_and_purity_aggregate_agree) +} + +#[test] +fn coverage_map_status_is_reported() -> Result<()> { + run(rfc_stdlib_coverage::coverage_map_status_is_reported) +} + +#[test] +fn inter_document_links_resolve() -> Result<()> { + run(rfc_stdlib_coverage::inter_document_links_resolve) +} + +#[test] +fn every_capability_has_a_roadmap_task() -> Result<()> { + run(rfc_stdlib_coverage::every_capability_has_a_roadmap_task) +} + +#[test] +fn every_child_discharges_every_clause() -> Result<()> { + run(rfc_stdlib_coverage::every_child_discharges_every_clause) +} diff --git a/tests/workflow_contracts/nextest_success_output.py b/tests/workflow_contracts/nextest_success_output.py new file mode 100644 index 000000000..c0a1dc2ac --- /dev/null +++ b/tests/workflow_contracts/nextest_success_output.py @@ -0,0 +1,87 @@ +"""Validate the RFC coverage count's immediate-success-output override. + +Only the default profile is required here. Existing child-Cargo contracts own +all-filter syntax and declared-test-name validation; this contract compares the +required filter exactly rather than introducing another selector parser. +""" + +import typing as typ + +from nextest_child_cargo_group_invariants import NEXTEST_CONFIG as NEXTEST_CONFIG +from workflow_loading import require_list, require_mapping + +if typ.TYPE_CHECKING: + import collections.abc as cabc + +IMMEDIATE_OUTPUT_TESTS: typ.Final[tuple[str, ...]] = ( + "coverage_map_status_is_reported", +) +INHERITED_PROFILE: typ.Final[str] = "default" +IMMEDIATE_SUCCESS_OUTPUT: typ.Final[str] = "immediate" + + +def _override_offences(override: cabc.Mapping[str, object], test: str) -> list[str]: + """Check policy fields after selecting one exact default-profile override. + + Keep this helper private to the coverage-output contract; selection and + duplicate detection remain the caller's responsibility. + + Returns + ------- + list[str] + Diagnostics for output mode, group, or timeout fields, or an empty list + when the selected override satisfies those policies. + """ + offences: list[str] = [] + if override.get("success-output") != IMMEDIATE_SUCCESS_OUTPUT: + offences.append( + f"{test}: success-output must be 'immediate'; " + f"found {override.get('success-output')!r}" + ) + if "test-group" in override: + offences.append(f"{test}: output override must not set test-group") + timeouts = sorted( + key for key in override if key == "timeout" or key.endswith("-timeout") + ) + if timeouts: + offences.append( + f"{test}: output override must not set timeout fields: {timeouts}" + ) + return offences + + +def immediate_output_offences(config: cabc.Mapping[str, object]) -> list[str]: + """Report missing, duplicate, or altered coverage-output overrides. + + An immediate override prints the unwritten count as soon as the test passes; + the final output mode instead delays that evidence until the run ends. + Group and timeout policies belong in their own overrides. + + Returns + ------- + list[str] + Diagnostics naming each violated override condition, or an empty list + when every guarded override satisfies the contract. + """ + profiles = require_mapping(config.get("profile"), "nextest profile table") + default = require_mapping( + profiles.get(INHERITED_PROFILE, {}), "nextest default profile" + ) + overrides = [ + require_mapping(entry, "nextest default override") + for entry in require_list( + default.get("overrides", []), "nextest default profile overrides" + ) + ] + offences: list[str] = [] + for test in IMMEDIATE_OUTPUT_TESTS: + selector = f"test(/^{test}($|::)/)" + matching = [entry for entry in overrides if entry.get("filter") == selector] + if len(matching) != 1: + offences.append( + f"{test}: expected exactly one default-profile override with " + f"filter {selector!r}; found {len(matching)}" + ) + continue + offences.extend(_override_offences(matching[0], test)) + return offences diff --git a/tests/workflow_contracts/nextest_success_output_mutations.py b/tests/workflow_contracts/nextest_success_output_mutations.py new file mode 100644 index 000000000..97ab2e792 --- /dev/null +++ b/tests/workflow_contracts/nextest_success_output_mutations.py @@ -0,0 +1,70 @@ +"""Build one-condition mutations of the RFC count's Nextest override. + +Each fixture includes an unrelated override, so deleting the required override +exercises selection rather than an empty document. The contract loads these +fixtures through the existing TOML reader. +""" + +import typing as typ + +from nextest_success_output import IMMEDIATE_OUTPUT_TESTS + +GUARDED_TEST: typ.Final[str] = IMMEDIATE_OUTPUT_TESTS[0] +_SELECTOR: typ.Final[str] = f"test(/^{GUARDED_TEST}($|::)/)" +_VALID: typ.Final[str] = f"filter = '{_SELECTOR}'\nsuccess-output = 'immediate'\n" +_UNRELATED: typ.Final[str] = "filter = 'test(/^some_other_test($|::)/)'\n" + +EXPECTED_OFFENCE: typ.Final[dict[str, str]] = { + "deleted": "found 0", + "duplicated": "found 2", + "filter-changed": "found 0", + "filter-widened": "found 0", + "mode-final": "success-output must be 'immediate'; found 'final'", + "mode-absent": "success-output must be 'immediate'; found None", + "group-added": "must not set test-group", + "timeout-added": "timeout fields: ['slow-timeout']", + "leak-timeout-added": "timeout fields: ['leak-timeout']", + "in-another-profile": "found 0", +} +MUTATIONS: typ.Final[tuple[str, ...]] = ("valid", *EXPECTED_OFFENCE) + + +def mutate_success_output(mutation: str) -> str: + """Return a valid TOML document with one named override mutation. + + Returns + ------- + str + A configuration with the named mutation and an unrelated override. + + Raises + ------ + ValueError + If the mutation name is unknown, so a typo cannot select an unmutated + fixture. + """ + bodies = { + "valid": [_VALID], + "deleted": [], + "duplicated": [_VALID, _VALID], + "filter-changed": [_VALID.replace(GUARDED_TEST, "some_other_test")], + "filter-widened": [ + _VALID.replace(_SELECTOR, f"{_SELECTOR} | test(/^some_other_test($|::)/)") + ], + "mode-final": [_VALID.replace("'immediate'", "'final'")], + "mode-absent": [_VALID.replace("success-output = 'immediate'\n", "")], + "group-added": [_VALID + "test-group = 'nested-cargo-builds'\n"], + "timeout-added": [ + _VALID + "slow-timeout = { period = '60s', terminate-after = 10 }\n" + ], + "leak-timeout-added": [_VALID + "leak-timeout = '100ms'\n"], + "in-another-profile": [], + } + if mutation not in bodies: + message = f"unknown success-output mutation: {mutation}" + raise ValueError(message) + entries = [_UNRELATED, *bodies[mutation]] + document = "\n".join(f"[[profile.default.overrides]]\n{body}" for body in entries) + if mutation == "in-another-profile": + document += f"\n[[profile.ci.overrides]]\n{_VALID}" + return document diff --git a/tests/workflow_contracts/nextest_success_output_test.py b/tests/workflow_contracts/nextest_success_output_test.py new file mode 100644 index 000000000..099cc9199 --- /dev/null +++ b/tests/workflow_contracts/nextest_success_output_test.py @@ -0,0 +1,80 @@ +"""Guard immediate passing output for the RFC coverage progress count. + +A partial split can pass the coverage assertions. The count therefore needs +immediate output, so progress is visible before Nextest's final report. +Run these contracts with ``make test-workflow-contracts``. +""" + +import typing as typ + +import pytest +from nextest_child_cargo_group_invariants import nextest_config +from nextest_rust_test_discovery import declared_test_names +from nextest_success_output import ( + IMMEDIATE_OUTPUT_TESTS, + NEXTEST_CONFIG, + immediate_output_offences, +) +from nextest_success_output_mutations import ( + EXPECTED_OFFENCE, + GUARDED_TEST, + MUTATIONS, + mutate_success_output, +) + +if typ.TYPE_CHECKING: + from pathlib import Path + +BREAKING = tuple(EXPECTED_OFFENCE) + + +def _parsed(tmp_path: Path, document: str) -> dict[str, object]: + """Load an isolated synthetic configuration through the shared TOML reader.""" + source = tmp_path / "nextest.toml" + source.write_text(document, encoding="utf-8") + return nextest_config(source) + + +def test_the_repository_configuration_keeps_its_evidence_channel() -> None: + """The checked-in default profile preserves immediate progress output.""" + offences = immediate_output_offences(nextest_config(NEXTEST_CONFIG)) + assert not offences, f"the {GUARDED_TEST} output override is invalid: {offences}" + + +def test_every_guarded_test_is_declared_by_the_test_tree() -> None: + """The guarded names resolve through the existing Rust discovery helper.""" + declared = declared_test_names() + unresolved = [test for test in IMMEDIATE_OUTPUT_TESTS if test not in declared] + assert not unresolved, f"guarded Rust tests are undeclared: {unresolved}" + + +def test_the_identity_configuration_is_accepted(tmp_path: Path) -> None: + """A valid fixture passes before its individual conditions are mutated.""" + valid = _parsed(tmp_path, mutate_success_output("valid")) + offences = immediate_output_offences(valid) + assert not offences, f"valid override rejected: {offences}" + + +@pytest.mark.parametrize("mutation", BREAKING) +def test_every_mutation_is_reported_for_the_reason_it_names( + tmp_path: Path, mutation: str +) -> None: + """Every one-condition mutation fails for its intended diagnostic.""" + config = _parsed(tmp_path, mutate_success_output(mutation)) + offences = immediate_output_offences(config) + expected = EXPECTED_OFFENCE[mutation] + assert len(offences) == 1, f"{mutation}: expected one offence, found {offences}" + assert GUARDED_TEST in offences[0], offences + assert expected in offences[0], f"{mutation}: expected {expected!r} in {offences}" + + +def test_the_identity_is_the_only_accepted_mutation(tmp_path: Path) -> None: + """Every declared negative fixture changes an enforced condition.""" + accepted = [ + mutation + for mutation in MUTATIONS + if not immediate_output_offences( + _parsed(tmp_path, mutate_success_output(mutation)) + ) + ] + assert accepted == ["valid"], f"unexpected accepted mutations: {accepted}"